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>
134 lines
5.9 KiB
JavaScript
134 lines
5.9 KiB
JavaScript
/**
|
|
* prompt.mjs — persona-aware system-prompt builder.
|
|
*
|
|
* Composes the final system prompt at session start:
|
|
*
|
|
* [identity_section] // "You ARE {persona.name}" — IDENTITY FIRST
|
|
* [shared_context] // dateTime + authenticated identity (if any)
|
|
* [persona.prompt] // mars or venus persona body
|
|
* [live_context] // result of buildMarsContext / buildVenusContext
|
|
* [tool_table] // names of allow-listed tools available this session
|
|
* [rules] // shared rules across personas
|
|
*
|
|
* "Identity-first" is the v0.8.1 production lesson: putting "You are Mars"
|
|
* BEFORE any context keeps the model from drifting into a generic-assistant
|
|
* voice mid-conversation.
|
|
*
|
|
* Sanitization: see `sanitizeForRealtime()` below — strips Unicode that the
|
|
* OpenAI Realtime API has historically struggled with (em dashes, smart
|
|
* quotes, arrows). Always runs over the assembled prompt.
|
|
*/
|
|
|
|
import { getPersona, buildSharedContext } from './lib/personas/personas.mjs';
|
|
import { getEffectiveAllowlist } from './tools.mjs';
|
|
import { buildMarsContext, buildVenusContext, buildTopicContext } from './lib/context-builder.example.mjs';
|
|
|
|
/**
|
|
* Build the system prompt for a session.
|
|
*
|
|
* @param {object} opts
|
|
* @param {string} opts.persona — 'mars' or 'venus' (case-insensitive)
|
|
* @param {boolean} [opts.authenticated]
|
|
* @param {string} [opts.identity] — operator identity if pre-auth
|
|
* @param {string} [opts.dateTime] — ISO timestamp; defaults to now
|
|
* @param {string} [opts.brainRoot] — absolute path to operator's brain repo
|
|
* @param {string} [opts.timezone]
|
|
* @param {string} [opts.topicId] — #1851: topic the agent was summoned into.
|
|
* The ONLY topic field accepted over the wire; the server resolves the
|
|
* recent-conversation context from the brain (never pass topic CONTENT in —
|
|
* that's prompt injection + a URL/log leak).
|
|
* @param {string} [opts.topicName] — human label for the topic (display only).
|
|
* @returns {Promise<string>} sanitized system prompt
|
|
*/
|
|
export async function buildSystemPrompt(opts = {}) {
|
|
const personaKey = (opts.persona || 'venus').toLowerCase();
|
|
const persona = getPersona(personaKey);
|
|
|
|
// 1. Identity first.
|
|
let prompt = `# You ARE ${persona.name}\n`;
|
|
prompt += `You are ${persona.name}, a voice AI. You are NOT a generic assistant. You are NOT Claude. You are NOT GPT. You are ${persona.name} with the personality below.\n\n`;
|
|
|
|
// 2. Shared context (date/time + identity if authed + topic name if summoned).
|
|
const dateTime = opts.dateTime || new Date().toISOString();
|
|
prompt += buildSharedContext({
|
|
authenticated: !!opts.authenticated,
|
|
identity: opts.identity || '',
|
|
dateTime,
|
|
topicName: opts.topicName || '',
|
|
});
|
|
|
|
// 3. Persona body.
|
|
prompt += persona.prompt + '\n\n';
|
|
|
|
// 4. Live brain context (operator's implementation; degrades to empty
|
|
// string if no brainRoot or layout doesn't match).
|
|
if (opts.brainRoot) {
|
|
try {
|
|
const ctx = personaKey === 'mars'
|
|
? await buildMarsContext({ brainRoot: opts.brainRoot, timezone: opts.timezone })
|
|
: await buildVenusContext({ brainRoot: opts.brainRoot, timezone: opts.timezone });
|
|
if (ctx) prompt += `# Live Context\n${ctx}\n\n`;
|
|
} catch (err) {
|
|
// Context-builder failures are non-fatal — persona answers with no
|
|
// live context rather than crashing the call.
|
|
console.warn(`[prompt] context-builder threw: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// 4b. #1851 Topic context — the recent conversation in the topic the agent
|
|
// was summoned into. Resolved server-side from topicId (the only topic field
|
|
// that crosses the wire). Injected AFTER the persona body + live context so
|
|
// the identity-first ordering still wins; the topic only adds background.
|
|
// No topicId → omitted → generic behavior (acceptance criterion).
|
|
if (opts.brainRoot && opts.topicId) {
|
|
try {
|
|
const tctx = await buildTopicContext({ brainRoot: opts.brainRoot, topicId: opts.topicId });
|
|
if (tctx) prompt += `# Topic Context\n${tctx}\n\n`;
|
|
} catch (err) {
|
|
console.warn(`[prompt] topic-context builder threw: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// 5. Tool list — only the allow-list, never the denylist.
|
|
const allowed = getEffectiveAllowlist();
|
|
if (allowed.length > 0) {
|
|
prompt += `# Available Tools\n`;
|
|
prompt += `You can call these tools during conversation:\n`;
|
|
for (const op of allowed) {
|
|
prompt += `- ${op}\n`;
|
|
}
|
|
prompt += `Write operations are not available unless the operator has opted in locally. If you need to write something, say "I can't save from voice; tell me again when you're at your screen."\n\n`;
|
|
}
|
|
|
|
// 6. Final hard rules.
|
|
prompt += `# Hard Rules\n`;
|
|
prompt += `- Only the caller decides when the call ends. Never say goodbye. Never wrap up.\n`;
|
|
prompt += `- If silence, ask "you still there?" — never fill it with assumptions.\n`;
|
|
prompt += `- NEVER discuss connection quality or technical issues. Silently continue.\n`;
|
|
prompt += `- NEVER read PII aloud (phones, emails, addresses).\n`;
|
|
|
|
return sanitizeForRealtime(prompt);
|
|
}
|
|
|
|
/**
|
|
* Strip Unicode characters that have historically broken the OpenAI
|
|
* Realtime WebSocket connection in production. Em dashes, smart quotes,
|
|
* arrows, ellipsis — replace with ASCII equivalents. Other non-ASCII
|
|
* stripped entirely (kept conservative; multilingual support is deferred
|
|
* to a future eval-gated release).
|
|
*
|
|
* @param {string} text
|
|
* @returns {string}
|
|
*/
|
|
export function sanitizeForRealtime(text) {
|
|
if (!text) return text;
|
|
return text
|
|
.replace(/[—–]/g, '--') // em / en dash
|
|
.replace(/[‘’]/g, "'") // smart single quotes
|
|
.replace(/[“”]/g, '"') // smart double quotes
|
|
.replace(/→/g, '->') // right arrow
|
|
.replace(/←/g, '<-') // left arrow
|
|
.replace(/…/g, '...') // ellipsis
|
|
.replace(/[^\x00-\x7F]/g, ''); // strip any other non-ASCII
|
|
}
|