mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
* fix(minions): honest attempt accounting + cooperative abort-honoring + per-handler timeouts (#1737) - Wall-clock and stall dead-letter paths now increment attempts_made (terminal, no retry — wall-clock fires at 2x cumulative timeout; retrying non-idempotent embed/subagent work would duplicate side effects). Surface stalled_counter in jobs get so 'started 3 / stalled 2 / attempts 0' reads true instead of looking like broken accounting. - Thread AbortSignal through embed-backfill/autopilot-cycle -> runPhaseEmbed -> runEmbedCore -> embedAll(Stale)/embedPage, checking it on BOTH --stale and --all paths and between embed batches. A timed-out embed phase now bails within a batch, so the cycle finally releases gbrain_cycle_locks instead of running the full 10-15 min after the job was killed (the daily cycle-wedge). New shared src/core/abort-check.ts (isAborted/throwIfAborted/anySignal). - Per-handler default wall-clock budget (handler-timeouts.ts) stamped at submit for long handlers without an explicit timeout_ms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(minions): queue-scoped DB supervisor singleton + canonical pidfile + doctor max-rss check (#1849) - Acquire a queue-scoped DB lock (tryAcquireDbLock, keyed on the raw DB identity + queue) on supervisor.start(): a second supervisor on the same (db, queue) fails fast with exit 2 regardless of $HOME/--pid-file. Refresh on a dedicated timer; on refresh failure past the threshold, fail SAFE (exit non-zero) before the TTL could lapse and let a second supervisor take over. Release on shutdown. - Canonical default pidfile keyed on brain id (currentBrainId, config-only, no DB connect) so two brains under one HOME no longer share supervisor.pid. - doctor: new supervisor_singleton check surfaces the effective --max-rss (from the started audit event) and warns when the lock holder's (host,pid) differs from the local pidfile — comparing host+pid, not bare pid. Registered in doctor-categories. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(agent-voice): topic-aware persona context via server-resolved topicId (#1851) Summon Mars/Venus into a specific conversation topic so they boot already knowing the recent thread. A per-topic call link carries only topicId (+ optional display topicName); the server resolves the recent-conversation context from the brain (topics/<topicId>.md) — topic CONTENT is never accepted over the wire (that would be prompt injection + a leak into URLs/referrers/logs). topicId is a strict slug with a path-traversal guard. New '# Topic Context' prompt slot injected after the persona body so identity-first ordering still wins; persona identity unchanged. No topicId -> generic behavior. Contract doc + persona skill docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: file #1737 slot-reservation fair-scheduling follow-up TODO (F7) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.42.29.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: sync KEY_FILES.md for v0.42.29.0 minions + abort wave (#1737, #1849) Fold the #1737/#1849 behavior into the existing per-file entries and add the two new core files, keeping the doc at current-state truth: - queue.ts: honest attempt accounting on wall-clock + stall dead-letter paths; defaultTimeoutMsFor stamping at submit. - supervisor.ts: queue-scoped DB singleton lock (supervisorLockId, classifySupervisorSingleton, LOCK_LOST, refresh-fail-safe, brain-id pidfile, max_rss_mb audit). - worker-registry.ts: currentDbIdentity(). - New entries: src/core/abort-check.ts, src/core/minions/handler-timeouts.ts. - New doctor.ts extension: supervisor_singleton check. - cycle.ts / embed.ts extensions: AbortSignal threading note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
278 lines
10 KiB
JavaScript
278 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* server.mjs — agent-voice reference server.
|
|
*
|
|
* WebRTC-first: the primary surface is browser-side via /call.
|
|
* GET / → redirect to /call
|
|
* GET /call → serves public/call.html (browser client)
|
|
* POST /session → SDP exchange with OpenAI Realtime; returns SDP answer
|
|
* POST /tool → tool-call dispatch from WebRTC data channel
|
|
* GET /health → {ok:true} liveness
|
|
*
|
|
* Twilio inbound (optional adapter):
|
|
* POST /voice → returns TwiML to open a Media Stream
|
|
* WSS /ws → Twilio↔OpenAI Realtime audio bridge
|
|
* POST /fallback → fallback TwiML (forward to operator's cell)
|
|
*
|
|
* The Twilio path is OPTIONAL; recipe Option A (WebRTC-only) doesn't need
|
|
* it. Operators wiring Twilio inbound implement the bridge themselves
|
|
* against `lib/twilio-bridge.mjs` (port-ready stubs included).
|
|
*
|
|
* Configuration via env:
|
|
* PORT default 8765
|
|
* OPENAI_API_KEY required for /session
|
|
* OPENAI_REALTIME_MODEL default 'gpt-4o-realtime-preview'
|
|
* DEFAULT_PERSONA default 'venus' (one of 'mars' | 'venus')
|
|
* BRAIN_ROOT passed through to context-builder
|
|
* TIMEZONE passed through to context-builder
|
|
*
|
|
* Security posture: this is reference code. It does NOT ship hardening for
|
|
* production deployment (no rate limiting, no Twilio signature validation,
|
|
* no CORS allowlist). Operators add those at install time per the recipe's
|
|
* "production checklist."
|
|
*/
|
|
|
|
import { createServer } from 'node:http';
|
|
import { readFileSync, statSync, existsSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join, extname } from 'node:path';
|
|
import { buildSystemPrompt } from './prompt.mjs';
|
|
import { dispatchTool, getEffectiveAllowlist } from './tools.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const PUBLIC_DIR = join(__dirname, 'public');
|
|
|
|
const PORT = parseInt(process.env.PORT || '8765', 10);
|
|
const DEFAULT_PERSONA = (process.env.DEFAULT_PERSONA || 'venus').toLowerCase();
|
|
const OPENAI_REALTIME_MODEL = process.env.OPENAI_REALTIME_MODEL || 'gpt-4o-realtime-preview';
|
|
const OPENAI_REALTIME_URL = 'https://api.openai.com/v1/realtime/calls';
|
|
|
|
const MIME = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'application/javascript; charset=utf-8',
|
|
'.mjs': 'application/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.wasm': 'application/wasm',
|
|
'.png': 'image/png',
|
|
'.svg': 'image/svg+xml',
|
|
'.ico': 'image/x-icon',
|
|
};
|
|
|
|
function send(res, status, body, headers = {}) {
|
|
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', ...headers });
|
|
res.end(body);
|
|
}
|
|
|
|
function sendJson(res, status, obj) {
|
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
res.end(JSON.stringify(obj));
|
|
}
|
|
|
|
async function readBody(req, max = 1 << 20) {
|
|
const chunks = [];
|
|
let n = 0;
|
|
for await (const chunk of req) {
|
|
n += chunk.length;
|
|
if (n > max) {
|
|
const err = new Error('payload too large');
|
|
err.status = 413;
|
|
throw err;
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
function serveStatic(res, relPath) {
|
|
const full = join(PUBLIC_DIR, relPath);
|
|
if (!full.startsWith(PUBLIC_DIR)) return send(res, 403, 'forbidden');
|
|
if (!existsSync(full)) return send(res, 404, 'not found');
|
|
try {
|
|
const stat = statSync(full);
|
|
if (!stat.isFile()) return send(res, 404, 'not found');
|
|
const body = readFileSync(full);
|
|
const mime = MIME[extname(full)] || 'application/octet-stream';
|
|
res.writeHead(200, {
|
|
'content-type': mime,
|
|
'content-length': stat.size,
|
|
'cache-control': 'no-cache',
|
|
});
|
|
res.end(body);
|
|
} catch (err) {
|
|
send(res, 500, `read error: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// ── /session: WebRTC SDP exchange with OpenAI Realtime ────────────────
|
|
async function handleSession(req, res) {
|
|
if (req.method !== 'POST') return send(res, 405, 'method not allowed');
|
|
if (!process.env.OPENAI_API_KEY) {
|
|
return sendJson(res, 500, { error: 'OPENAI_API_KEY not set' });
|
|
}
|
|
|
|
let sdpOffer;
|
|
try {
|
|
sdpOffer = (await readBody(req)).toString('utf8');
|
|
} catch (err) {
|
|
return send(res, err.status || 400, err.message);
|
|
}
|
|
|
|
if (!sdpOffer || !sdpOffer.startsWith('v=')) {
|
|
return sendJson(res, 400, { error: 'missing or malformed SDP offer' });
|
|
}
|
|
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
const persona = (url.searchParams.get('persona') || DEFAULT_PERSONA).toLowerCase();
|
|
// #1851: a call link minted from a Telegram topic carries topicId (+ an
|
|
// optional display topicName). The id is the ONLY topic data we accept over
|
|
// the wire — buildSystemPrompt resolves the recent-conversation context from
|
|
// the brain server-side. We never accept topic CONTENT as a param (that would
|
|
// be prompt injection + a leak into URLs/referrers/access logs).
|
|
const topicId = url.searchParams.get('topicId') || undefined;
|
|
const topicName = url.searchParams.get('topicName') || undefined;
|
|
|
|
// Build the persona-aware system prompt at session start.
|
|
const systemPrompt = await buildSystemPrompt({
|
|
persona,
|
|
brainRoot: process.env.BRAIN_ROOT,
|
|
timezone: process.env.TIMEZONE,
|
|
topicId,
|
|
topicName,
|
|
});
|
|
|
|
// Session config for OpenAI Realtime /v1/realtime/calls.
|
|
// Important gotchas (from production):
|
|
// - `voice` goes under `audio.output.voice`, NOT top-level
|
|
// - Do NOT send `turn_detection` (rejected by /v1/realtime/calls)
|
|
// - All `session.update` calls must include `type: 'realtime'`
|
|
const personaVoice = persona === 'mars' ? 'Orus' : 'Aoede';
|
|
const sessionConfig = {
|
|
type: 'realtime',
|
|
model: OPENAI_REALTIME_MODEL,
|
|
audio: { output: { voice: personaVoice } },
|
|
instructions: systemPrompt,
|
|
// Tools advertised to the model; the actual dispatch happens via /tool.
|
|
tools: getEffectiveAllowlist().map((name) => ({
|
|
type: 'function',
|
|
name,
|
|
description: `gbrain operation: ${name}`,
|
|
parameters: { type: 'object', properties: {}, additionalProperties: true },
|
|
})),
|
|
};
|
|
|
|
// OpenAI Realtime expects multipart/form-data with two parts:
|
|
// sdp: the WebRTC SDP offer
|
|
// session: JSON.stringify(sessionConfig)
|
|
const form = new FormData();
|
|
form.set('sdp', sdpOffer);
|
|
form.set('session', JSON.stringify(sessionConfig));
|
|
|
|
try {
|
|
const upstream = await fetch(OPENAI_REALTIME_URL, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
|
|
body: form,
|
|
});
|
|
if (!upstream.ok) {
|
|
const text = await upstream.text();
|
|
console.error(`[session] OpenAI Realtime returned ${upstream.status}: ${text.slice(0, 400)}`);
|
|
return send(res, upstream.status, text);
|
|
}
|
|
const sdpAnswer = await upstream.text();
|
|
res.writeHead(200, { 'content-type': 'application/sdp; charset=utf-8' });
|
|
res.end(sdpAnswer);
|
|
} catch (err) {
|
|
console.error(`[session] upstream error: ${err.message}`);
|
|
sendJson(res, 502, { error: 'upstream_unreachable', detail: err.message });
|
|
}
|
|
}
|
|
|
|
// ── /tool: tool-call dispatch from the WebRTC data channel ────────────
|
|
async function handleTool(req, res) {
|
|
if (req.method !== 'POST') return send(res, 405, 'method not allowed');
|
|
let body;
|
|
try {
|
|
body = JSON.parse((await readBody(req)).toString('utf8'));
|
|
} catch (err) {
|
|
return sendJson(res, 400, { error: 'invalid_json', detail: err.message });
|
|
}
|
|
const { name, arguments: params } = body || {};
|
|
if (typeof name !== 'string') {
|
|
return sendJson(res, 400, { error: 'missing tool name' });
|
|
}
|
|
const result = await dispatchTool(name, params || {});
|
|
// dispatchTool always returns either {data} or {error}; never throws.
|
|
sendJson(res, 200, result);
|
|
}
|
|
|
|
// ── /voice: Twilio TwiML stub (optional Twilio inbound) ───────────────
|
|
function handleVoiceTwiml(req, res) {
|
|
const host = req.headers.host;
|
|
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Response>
|
|
<Connect>
|
|
<Stream url="wss://${host}/ws" />
|
|
</Connect>
|
|
</Response>`;
|
|
res.writeHead(200, { 'content-type': 'text/xml; charset=utf-8' });
|
|
res.end(twiml);
|
|
}
|
|
|
|
// ── HTTP router ──────────────────────────────────────────────────────
|
|
const server = createServer(async (req, res) => {
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
|
|
// CORS: allow same-origin only by default. Operators relax in production.
|
|
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
if (req.method === 'OPTIONS') return send(res, 204, '');
|
|
|
|
try {
|
|
if (url.pathname === '/health') {
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
if (url.pathname === '/' || url.pathname === '/call') {
|
|
return serveStatic(res, 'call.html');
|
|
}
|
|
if (url.pathname === '/directory') {
|
|
return serveStatic(res, 'directory.html');
|
|
}
|
|
if (url.pathname === '/session') {
|
|
return handleSession(req, res);
|
|
}
|
|
if (url.pathname === '/tool') {
|
|
return handleTool(req, res);
|
|
}
|
|
if (url.pathname === '/voice') {
|
|
return handleVoiceTwiml(req, res);
|
|
}
|
|
if (url.pathname.startsWith('/public/')) {
|
|
return serveStatic(res, url.pathname.slice('/public/'.length));
|
|
}
|
|
// Static fallback for files in public/ at root path (e.g., /rnnoise-processor.js).
|
|
const candidate = url.pathname.slice(1);
|
|
if (candidate && !candidate.includes('..')) {
|
|
const candPath = join(PUBLIC_DIR, candidate);
|
|
if (existsSync(candPath) && statSync(candPath).isFile()) {
|
|
return serveStatic(res, candidate);
|
|
}
|
|
}
|
|
send(res, 404, 'not found');
|
|
} catch (err) {
|
|
console.error(`[server] ${err.message}\n${err.stack}`);
|
|
if (!res.headersSent) sendJson(res, 500, { error: err.message });
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`[agent-voice] listening on http://localhost:${PORT}`);
|
|
console.log(`[agent-voice] default persona: ${DEFAULT_PERSONA}`);
|
|
console.log(`[agent-voice] read-only tools: ${getEffectiveAllowlist().join(', ')}`);
|
|
});
|
|
|
|
process.on('SIGTERM', () => server.close(() => process.exit(0)));
|
|
process.on('SIGINT', () => server.close(() => process.exit(0)));
|