Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
facf0ff
feat: add UI preview mockup for Agent Chat and Provider Settings
Jul 12, 2026
70a1603
feat: add multi-provider API key management and agent chat
Jul 12, 2026
5746062
feat: fully customizable provider system — default star, model select…
Jul 12, 2026
62743e5
fix: image upload broken bucket + fallback system
Jul 12, 2026
7cc0499
fix: announcements route 404 + AI analyze undefined summary bug
Jul 12, 2026
fe7e0c5
fix: agent-chat poll title regex, intent reordering, unban+reject han…
Jul 12, 2026
829ab73
feat: 60-agent AI team, 122 RBAC roles, meta-agent tool builder, visu…
Jul 13, 2026
28c71c5
fix(agent-chat): resolve all 5 bugs causing generic fallback responses
Jul 14, 2026
8139533
fix: critical security and bug fixes — .env gitignore, XSS sanitizati…
Jul 14, 2026
c24be95
fix(providers): switch NVIDIA to 8B model, increase LLM timeout to20s
Jul 15, 2026
97dd6fc
fix: restore framer-motion animations with no build errors
Jul 17, 2026
989f626
fix: POST 500 Invalid JSON — body parsing fallback, Supabase retry, v…
Jul 18, 2026
c3ff5e2
fix: correct callLLMChain signature in agent-team (system, user strin…
Jul 18, 2026
60a9857
fix: run agent-team agents in parallel, increase maxDuration to 60s
Jul 18, 2026
b4f307f
fix: increase maxDuration to 60s for agent-team parallel execution
Jul 18, 2026
8e04f77
fix: wrap hardcoded agent results in analyzeWithLLM for real AI insights
Jul 18, 2026
944c71c
fix: increase per-agent timeout to 25s for LLM calls
Jul 18, 2026
f5b1ebf
fix: increase per-agent timeout to 30s
Jul 18, 2026
79d3708
fix: reduce per-provider timeout to 10s for faster failover
Jul 18, 2026
d63539b
fix: reduce per-provider timeout to 5s for faster failover
Jul 18, 2026
2802fb8
fix: wrap all 34 hardcoded agent paths with analyzeWithLLM for real L…
Jul 18, 2026
c148209
feat(agent-chat): rewrite to use /api/ai-chat with SSE streaming and …
Jul 18, 2026
2f62527
feat: add hourly agent activation via GitHub Actions
Jul 20, 2026
1fa6d6f
fix: remove non-existent DB columns, fix knowledge_base.status querie…
Jul 24, 2026
b894c99
fix(security): patch SQL injection, auth bypass, wrong column, and PI…
Jul 25, 2026
d8aecf1
fix: restore vercel.json with CSP, fix Content Review queue error han…
Jul 25, 2026
d8fdfaf
fix(csp): allow Google Fonts stylesheets in CSP style-src
Jul 25, 2026
59063e4
fix: ESLint 0 errors - all linting clean, TS clean, build green
Jul 25, 2026
4999510
fix(types): eliminate all @typescript-eslint/no-explicit-any warnings…
Jul 25, 2026
13e0274
fix: type remaining 15 files - zero @typescript-eslint/no-explicit-an…
Jul 25, 2026
4288fcb
fix: resolve all TypeScript strict mode errors across 19 files
Jul 25, 2026
65a2a3c
feat: add Agent Reports feed tab, persistence layer, cron system, and…
Jul 26, 2026
87bce1a
fix: change cron to daily schedule (Hobby plan limit)
Jul 26, 2026
89b07d5
fix: remove duplicate exports of setAgentState and ALL_AGENTS from _a…
Jul 26, 2026
a15b769
fix: correct API paths for agent-team reports and supervisor endpoints
Jul 26, 2026
2764d96
feat: add learning system, supervisor alerts, and AdminAI reports tab…
Jul 26, 2026
2296149
test: add full API test suite (131 tests, 131 passing) with vitest co…
Jul 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions freeclaw/freeclaw/voice-box/.github/workflows/agent-activation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Agent Activation (Hourly)

on:
schedule:
# Run every hour at minute 0
- cron: '0 * * * *'
workflow_dispatch: # Allow manual trigger

jobs:
activate-agents:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Trigger Agent Rotation
id: rotate
run: |
echo "[$(date -u)] Triggering agent rotation..."

# Call the rotation endpoint (tier-based, runs ~8-12 agents per tick)
RESPONSE=$(curl -s -w "\n%{http_code}" \
"https://voice-box-psi.vercel.app/api/agents-cron?action=rotate" \
--max-time 55)

HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -n -1)

echo "HTTP Status: $HTTP_CODE"

if [ "$HTTP_CODE" = "200" ]; then
echo "✅ Rotation triggered successfully"
AGENTS_RUN=$(echo "$BODY" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('agents_run',0))" 2>/dev/null || echo "?")
SUCCEEDED=$(echo "$BODY" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('agents_succeeded',0))" 2>/dev/null || echo "?")
FAILED=$(echo "$BODY" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('agents_failed',0))" 2>/dev/null || echo "?")
STEP=$(echo "$BODY" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('step',0))" 2>/dev/null || echo "?")
echo "Step: $STEP | Run: $AGENTS_RUN | Succeeded: $SUCCEEDED | Failed: $FAILED"
else
echo "❌ Rotation failed with HTTP $HTTP_CODE"
echo "$BODY" | head -5
exit 1
fi

- name: Consume Pending Events
run: |
echo "[$(date -u)] Consuming pending agent events..."

RESPONSE=$(curl -s -w "\n%{http_code}" \
"https://voice-box-psi.vercel.app/api/agents-cron?agent=consume-all" \
--max-time 55)

HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -n -1)

if [ "$HTTP_CODE" = "200" ]; then
EVENTS=$(echo "$BODY" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('total_consumed',0))" 2>/dev/null || echo "?")
echo "✅ Events consumed: $EVENTS"
else
echo "⚠️ Event consumption returned HTTP $HTTP_CODE (non-critical)"
fi

- name: Health Check
run: |
echo "[$(date -u)] Running platform health check..."

RESPONSE=$(curl -s -w "\n%{http_code}" \
"https://voice-box-psi.vercel.app/api/health" \
--max-time 10)

HTTP_CODE=$(echo "$RESPONSE" | tail -1)

if [ "$HTTP_CODE" = "200" ]; then
echo "✅ Platform healthy"
else
echo "⚠️ Health check returned HTTP $HTTP_CODE"
fi
29 changes: 29 additions & 0 deletions freeclaw/freeclaw/voice-box/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local
.env
.env.*

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vite-source-tags.js
.vercel
vercel.json
210 changes: 210 additions & 0 deletions freeclaw/freeclaw/voice-box/api/_admin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// Admin auth (hashed password + session timeout), user management, logs, settings
import supabase from './_db-client.js';
import { cors, isAdmin, auditLog, clean } from './_auth.js';
import crypto from 'crypto';
import { sanitizeError } from './_error.js';

const SESSION_MS = 60 * 60 * 1000; // 60 minute session timeout

async function getSetting(key) {
const { data } = await supabase.from('settings').select('value').eq('key', key).maybeSingle();
return data?.value ?? null;
}
async function setSetting(key, value) {
const { data } = await supabase.from('settings').select('key').eq('key', key).maybeSingle();
if (data) await supabase.from('settings').update({ value }).eq('key', key);
else await supabase.from('settings').insert({ key, value });
}

export default async function handler(req, res) {
cors(res, req);
if (req.method === 'OPTIONS') return res.status(204).end();

try {
const b = req.body || {};
const action = req.method === 'GET' ? req.query.action : b.action;

// ---------- AUTH ----------
if (action === 'login') {
const stored = await getSetting('admin_password');
const hash = clean(b.password_hash, 128);
// Timing-safe comparison to prevent timing attacks on password hash
let hashMatch = false;
if (stored?.hash && hash && stored.hash.length === hash.length) {
try {
const storedBuf = Buffer.from(stored.hash, 'hex');
const inputBuf = Buffer.from(hash, 'hex');
if (storedBuf.length === inputBuf.length) {
hashMatch = crypto.timingSafeEqual(storedBuf, inputBuf);
}
} catch { /* hex parse failure = no match */ }
}
if (!hashMatch) {
await auditLog('system', 'failed_login', 'Bad password attempt');
return res.status(401).json({ error: 'Incorrect password.' });
}
const token = crypto.randomBytes(24).toString('hex');
const sessions = (await getSetting('admin_sessions')) || { tokens: [] };
const now = Date.now();
sessions.tokens = [...sessions.tokens.filter((t) => t.exp > now), { t: token, exp: now + SESSION_MS }].slice(-10);
await setSetting('admin_sessions', sessions);
await auditLog('admin', 'login', 'Admin signed in');
return res.status(200).json({ token, expires_at: now + SESSION_MS });
}

if (action === 'verify') {
return res.status(200).json({ valid: await isAdmin(req) });
}

if (action === 'logout') {
const token = req.headers['x-admin-token'];
const sessions = (await getSetting('admin_sessions')) || { tokens: [] };
sessions.tokens = sessions.tokens.filter((t) => t.t !== token);
await setSetting('admin_sessions', sessions);
return res.status(200).json({ ok: true });
}

// ---------- everything below requires admin ----------
if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' });

if (action === 'change_password') {
const newHash = clean(b.new_hash, 128);
if (!newHash || newHash.length < 32) return res.status(400).json({ error: 'Invalid hash' });
await setSetting('admin_password', { hash: newHash });
await auditLog('admin', 'change_password', 'Admin password updated');
return res.status(200).json({ ok: true });
}

if (action === 'logs') {
const { cursor, limit: limitParam, paginate } = req.query;
const isPaginated = paginate === '1' || paginate === 'true';
const PAGE_LIMIT = Math.min(parseInt(limitParam) || 30, 100);

let q = supabase.from('activity_logs').select('*').order('created_at', { ascending: false });
if (isPaginated) {
if (cursor) q = q.lt('created_at', cursor);
q = q.limit(PAGE_LIMIT + 1);
} else {
q = q.limit(300);
}
const { data, error } = await q;
if (error) throw error;

if (isPaginated) {
const rows = data || [];
const hasMore = rows.length > PAGE_LIMIT;
const sliced = hasMore ? rows.slice(0, PAGE_LIMIT) : rows;
const nextCursor = hasMore ? sliced[sliced.length - 1]?.created_at : null;
const { count } = await supabase.from('activity_logs').select('id', { count: 'exact', head: true });
return res.status(200).json({ data: sliced, nextCursor, total: count || 0 });
}

return res.status(200).json(data || []);
}

if (action === 'log') {
await auditLog('admin', clean(b.log_action, 60), b.detail);
return res.status(200).json({ ok: true });
}

// ---------- USER MANAGEMENT ----------
if (action === 'users') {
const { cursor, limit: limitParam, paginate } = b;
const isPaginated = !!paginate;
const PAGE_LIMIT = Math.min(parseInt(limitParam) || 30, 100);

let q = supabase.from('users_meta').select('*').order('created_at', { ascending: false });
if (isPaginated && cursor) q = q.lt('created_at', cursor);
if (isPaginated) q = q.limit(PAGE_LIMIT + 1);
else q = q.limit(500);
const { data: users, error } = await q;
if (error) throw error;

const rows = users || [];

if (isPaginated) {
const hasMore = rows.length > PAGE_LIMIT;
const sliced = hasMore ? rows.slice(0, PAGE_LIMIT) : rows;
const nextCursor = hasMore ? sliced[sliced.length - 1]?.created_at : null;
const anonIds = sliced.map((u) => u.anon_id);
const [{ data: posts }, { data: comments }, { data: reactions }] = await Promise.all([
supabase.from('posts').select('author_id').in('author_id', anonIds),
supabase.from('comments').select('author_id').in('author_id', anonIds),
supabase.from('reactions').select('author_id').in('author_id', anonIds),
]);
const count = (rows2, id) => (rows2 || []).filter((r) => r.author_id === id).length;
const { count: total } = await supabase.from('users_meta').select('anon_id', { count: 'exact', head: true });
return res.status(200).json({
data: sliced.map((u) => ({
...u,
post_count: count(posts, u.anon_id),
comment_count: count(comments, u.anon_id),
reaction_count: count(reactions, u.anon_id),
})),
nextCursor,
total: total || 0,
});
}

// Non-paginated: fetch user counts per-user without loading all reactions/comments into memory
const anonIds = (rows || []).map((u) => u.anon_id);
const countForUser = async (table, ids) => {
if (!ids.length) return {};
// Batch count per user using select + groupby equivalent
const { data } = await supabase.from(table).select('author_id').in('author_id', ids);
const map = {};
(data || []).forEach((r) => { map[r.author_id] = (map[r.author_id] || 0) + 1; });
return map;
};
const [postCounts, commentCounts, reactionCounts] = await Promise.all([
countForUser('posts', anonIds),
countForUser('comments', anonIds),
countForUser('reactions', anonIds),
]);
return res.status(200).json((rows || []).map((u) => ({
...u,
post_count: postCounts[u.anon_id] || 0,
comment_count: commentCounts[u.anon_id] || 0,
reaction_count: reactionCounts[u.anon_id] || 0,
})));
}

if (action === 'user_detail') {
const id = clean(b.anon_id, 40).toLowerCase();
const [{ data: meta }, { data: posts }, { data: comments }, { data: reactions }, { data: reports }] = await Promise.all([
supabase.from('users_meta').select('*').eq('anon_id', id).maybeSingle(),
supabase.from('posts').select('*').eq('author_id', id).order('created_at', { ascending: false }),
supabase.from('comments').select('*').eq('author_id', id).order('created_at', { ascending: false }),
supabase.from('reactions').select('*').eq('author_id', id),
supabase.from('reports').select('*').eq('author_id', id),
]);
return res.status(200).json({ meta, posts: posts || [], comments: comments || [], reactions: reactions || [], reports: reports || [] });
}

if (action === 'update_user') {
const id = clean(b.anon_id, 40).toLowerCase();
const { data: existing } = await supabase.from('users_meta').select('anon_id,warnings,strikes').eq('anon_id', id).maybeSingle();
if (!existing) await supabase.from('users_meta').insert({ anon_id: id, warnings: [] });
const patch = {};
if (b.warn) {
patch.warnings = [...(existing?.warnings || []), { text: clean(b.warn, 300), at: new Date().toISOString() }];
patch.strikes = (existing?.strikes || 0) + 1;
}
if (b.suspend_days !== undefined) {
patch.suspended_until = b.suspend_days === 0 ? null : new Date(Date.now() + b.suspend_days * 86400000).toISOString();
}
if (typeof b.banned === 'boolean') patch.banned = b.banned;
if (b.notes !== undefined) patch.notes = clean(b.notes, 2000);
if (typeof b.spam_score === 'number') patch.spam_score = b.spam_score;
if (typeof b.strikes === 'number') patch.strikes = b.strikes;
const { data, error } = await supabase.from('users_meta').update(patch).eq('anon_id', id).select().single();
if (error) throw error;
await auditLog('admin', 'update_user', `${id}: ${Object.keys(patch).join(', ')}`);
return res.status(200).json(data);
}

return res.status(400).json({ error: 'Unknown action' });
} catch (err) {
return sanitizeError(res, err, 'admin');
}
}
Loading