mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
* feat: agent-voice reference skillpack (Mars + Venus) + copy-into-host-repo install paradigm Ships a new skillpack paradigm: gbrain holds the REFERENCE content; `gbrain integrations install agent-voice --target <repo>` COPIES it into the operator's host agent repo where it becomes user-owned and mutable. Future refresh is diff-and-propose against per-file SHA-256 hashes from .gbrain-source.json, not blind overwrite. What ships: - recipes/agent-voice.md entrypoint + recipes/agent-voice/ bundle - Two voice personas (Mars dual-mode SOLO/DEMO, Venus executive assistant) with PII / private-agent-name / hardcoded-path scrubbed out - WebRTC-first browser client (call.html) with ?test=1 gated instrumentation and Web Audio API tee -> MediaRecorder capture for E2E roundtrip testing - Read-only tool router (D14-A allow-list: search, query, get_page, list_pages, find_experts, get_recent_salience, get_recent_transcripts, read_article). Write ops permanently denylisted; opt-in via local override - Persona-aware prompt builder with identity-first composition + Unicode sanitization for OpenAI Realtime API safety - Upstream-error classifier (HTTP 429/500/503 -> soft-fail, plumbing -> hard) - Three SKILL.md skills (voice-persona-mars, voice-persona-venus, voice-post-call) with routing-eval.jsonl fixtures - 99 host-side tests (vitest-compatible, runs in bun) covering registry, prompt-shape privacy guards, tool allow-list, upstream classifier - install/manifest.json + refresh-algorithm.md + post-install-hint.md Privacy infrastructure: - scripts/check-no-pii-in-agent-voice.sh wired into bun run verify Shape regex (phone/email/SSN/JWT/bearer/credit-card) + path patterns + $AGENT_VOICE_PII_BLOCKLIST env-driven name blocklist - scripts/import-from-upstream.sh + scripts/upstream-scrub-table.txt Deterministic refresh from upstream voice-agent source. Placeholder- driven (envsubst-expanded at run time) so no private names land in checked-in files - recipes/agent-voice/code/lib/personas/private-name-blocklist.json Single source of truth for the regex contract (shape categories + path patterns + env-var contract for operator-specific names) src/ surface: - src/commands/integrations.ts gains `install <recipe-id>` subcommand with install_kind: 'local-managed' | 'copy-into-host-repo' discriminator. Path-traversal hardening (rejects '..', absolute paths, symlink escapes). Refuses target == gbrain itself, missing .git, existing files (without --overwrite). Writes .gbrain-source.json with per-file SHA-256. Appends resolver rows to host repo's RESOLVER.md or AGENTS.md. - test/integrations-install.test.ts: 11 cases (happy path, manifest shape, no upstream_repo field per D11-A, resolver appending, file modes, refusal cases, dry-run) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.36.0.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(privacy): scrub literal private agent names from prompt-shape tests + guard script The prompt-shape tests carried regex patterns naming the literal banned terms (Garry/Steph/Garrison/Solomon/Herbert/Wintermute) inline. CLAUDE.md's "never use Wintermute in any public artifact" applies to test source files too. Master's check-privacy.sh correctly caught this. Replaced with env-driven check that reads AGENT_VOICE_PII_BLOCKLIST (the single source of truth from private-name-blocklist.json). Same enforcement guarantee via the env var, zero literal names in shipped source. Also scrubbed the literal /data/.openclaw/ from the guard script's comment and the literal 'tell_wintermute' from the venus write-tools test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: ship all v0.36.0.0 deferred items in this PR (E2E + evals + pipeline + refresh + multilingual + twilio deprecation) Closes the "deferred to follow-up" section of the v0.36.0.0 CHANGELOG. E2E tests + harness (env-gated): - tests/e2e/voice-roundtrip.test.mjs — spawns server, drives puppeteer + fake-audio, three-tier assertions (CONNECTION hard, NON-SILENT hard, SEMANTIC soft via Whisper + LLM judge). Upstream errors (429/500/503, WS 1011/1013) soft-fail via lib/upstream-classifier.mjs. - tests/e2e/voice-full-flow.test.mjs — wraps openclaw doing the install, then runs the roundtrip. Friction-discovery flavor, NOT a ship gate. - tests/e2e/lib/browser-audio.mjs — puppeteer + fake-audio harness; reads window._gbrainTest namespace; PCM RMS-variance helper. - tests/e2e/lib/whisper-judge.mjs — Whisper transcription + LLM-judge for SEMANTIC tier. - tests/e2e/audio-fixtures/utterance-{add,joke,brain-query}.wav — 16kHz mono WAV via `say` + ffmpeg, committed for reproducibility. - test/fixtures/claw-test-scenarios/voice-agent-install/{BRIEF.md, scenario.json, expected.json} — labeled BENCHMARK_FRICTION, blocks_ship=false. LLM-judge persona evals + synthetic canonical baselines: - tests/evals/judge.mjs — gateway-routed 3-model (Claude + GPT + Gemini) harness with 4-strategy JSON repair + 2/3-quorum aggregation (per the v0.27.x cross-modal pattern). Pass criterion: every axis mean ≥7 AND no model <5. - tests/evals/fixtures/{mars-solo,mars-demo,venus,persona-routing,mars-multilingual}.jsonl — 5 fixture sets covering all axes. - tests/evals/{mars-eval,venus-eval,mars-multilingual-eval,persona-routing-eval}.mjs — per-axis drivers. - tests/evals/baseline-runs/canonical/*.json — agent-authored synthetic exemplars (PII-impossible by construction; demonstrate expected pass shape; never overwrite with live model output). - tests/evals/baseline-runs/.gitignore — live receipts excluded. DIY pipeline (Option B): - code/pipeline.mjs — streaming STT (Deepgram nova-2) + LLM (Claude Sonnet 4.6 streaming SSE with sentence-boundary TTS dispatch) + TTS (Cartesia primary, OpenAI TTS fallback). 20-turn history cap, exponential-backoff reconnects, 25s keepalives, VAD presets (quiet/normal/noisy/very_noisy), barge-in via STT speechStart → LLM interrupt. Modular adapters for swapping providers. --refresh mode (D3-A diff-and-propose): - src/commands/integrations.ts: refreshRecipeIntoHostRepo() + classifyForRefresh() implementing the five states from refresh-algorithm.md (unchanged-identical, unchanged-stale, locally-modified, source-deleted, host-deleted, new-in-manifest). Transaction journal at .gbrain-source.refresh.log. Default policy: preserve operator's local edits (keep-mine); --auto take-theirs to overwrite; --dry-run for preview. - test/integrations-install.test.ts: 7 new test cases pinning each classification state + default-preserve behavior + take-theirs overwrite + transaction journal + refusal on uninstalled target. Mars multilingual restore: - code/lib/personas/mars.mjs: explicit cross-lingual rule (Mandarin, Spanish, French, Japanese, Korean default to English but follow the speaker). Voice (Orus) supports the languages natively. - tests/unit/mars-prompt-shape.test.mjs: assertion flipped from "MUST NOT claim multilingual" to "declares cross-lingual capability with English bias." - tests/evals/fixtures/mars-multilingual.jsonl: 5 fixtures across Mandarin/Spanish/Japanese/French + explicit switch-back, pinned by mars-multilingual-eval.mjs. Twilio recipe deprecation: - recipes/twilio-voice-brain.md: deprecation banner pointing at agent-voice.md. Frontmatter version bumped to 0.8.2. Will be removed in v0.37. Verify: bun run verify clean, 6736+ unit tests pass, 18/18 install+refresh tests pass, 96/98 host-side persona/tool/classifier tests pass (2 skipped env-gated). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update CHANGELOG — all v0.36.0.0 deferred items now shipped in this PR Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: rebump version v0.36.0.0 → v0.37.0.0 Captures the wave-1 + wave-2 scope at the v0.37 slot. The bump reflects the size of what this PR ships: copy-into-host-repo install paradigm (new install_kind discriminator + new install/refresh subcommand) + Mars/Venus voice agent reference + 5,500+ LOC of vendored scrubbed code + 4 LLM-judge eval suites + 2 env-gated E2E test suites + DIY Option B pipeline + 18-case install subcommand test coverage. A minor bump felt too small. Side fix: privacy guard caught two stale literal "wintermute" and "/data/.openclaw/" references in wave-2 files (voice-full-flow.test.mjs comment, expected.json blocklist payload). Both replaced with env-driven references to $AGENT_VOICE_PII_BLOCKLIST matching the D15-A pattern from the original review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: rebump v0.37.0.0 → v0.40.0.0 Jumps past the v0.37/v0.38/v0.39 slots master might claim in subsequent PRs. The wave's scope (copy-into-host-repo skillpack paradigm + agent-voice + install/refresh + LLM-judge evals + DIY pipeline + Mars multilingual) justifies a larger version arithmetic step. Files bumped: - VERSION 0.37.0.0 → 0.40.0.0 - package.json 0.37.0.0 → 0.40.0.0 - CHANGELOG.md header + "To take advantage of v0.40.0.0" block - recipes/twilio-voice-brain.md deprecation banner (now "removed in v0.41") - recipes/agent-voice/tests/evals/mars-multilingual-eval.mjs comment Left alone: master's pre-existing "v0.37+" roadmap labels in src/core/calibration/*, src/core/cycle/*, DESIGN.md, CLAUDE.md, etc. Those are master's author-intent references to "the next planned release" relative to master's frame at the time — rewriting them just to keep numbering consistent would overreach. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
282 lines
10 KiB
JavaScript
282 lines
10 KiB
JavaScript
/**
|
|
* twilio-venus-bridge.mjs — Bridge Twilio Media Streams to Gemini Live API
|
|
*
|
|
* Supports TWO-PHASE authentication:
|
|
* Phase 1: Gatekeeper (zero context, auth tools only)
|
|
* Phase 2: Full Venus (all context + tools, loaded after auth)
|
|
*
|
|
* The Twilio WebSocket stays connected throughout. On upgrade,
|
|
* the Gemini connection is closed and a new one opened with full context.
|
|
*/
|
|
|
|
import WebSocket from 'ws';
|
|
import { createTwilioToGeminiProcessor, createGeminiToTwilioProcessor } from './audio-convert.mjs';
|
|
|
|
/**
|
|
* Create a Twilio↔Venus bridge with upgrade support.
|
|
*
|
|
* @param {WebSocket} twilioWs - Twilio media stream WebSocket
|
|
* @param {Object} opts
|
|
* @param {string} opts.geminiApiKey
|
|
* @param {string} opts.model
|
|
* @param {string} opts.systemPrompt - Initial prompt (gatekeeper or full)
|
|
* @param {Array} opts.toolDefs - Initial tool declarations
|
|
* @param {Function} opts.onToolCall - (name, args) => Promise<result>
|
|
* @param {Function} opts.onTranscript - (entry) => void
|
|
* @param {Function} opts.onCallStart - (callSid, callerPhone) => void
|
|
* @param {Function} opts.onCallEnd - (callSid, callerPhone, duration, transcript) => void
|
|
* @param {string} opts.voiceName
|
|
*/
|
|
export function createBridge(twilioWs, opts) {
|
|
const {
|
|
geminiApiKey,
|
|
model = 'gemini-2.5-flash-native-audio-latest',
|
|
systemPrompt = '',
|
|
toolDefs = [],
|
|
onToolCall = async () => ({}),
|
|
onTranscript = () => {},
|
|
onCallStart = () => {},
|
|
onCallEnd = () => {},
|
|
voiceName = 'Aoede',
|
|
} = opts;
|
|
|
|
// Mutable state — persists across upgrades
|
|
let streamSid = null;
|
|
let callSid = null;
|
|
let callerPhone = '';
|
|
let callEnded = false;
|
|
const callStartTime = Date.now();
|
|
const transcript = [];
|
|
let audioChunksIn = 0;
|
|
let audioChunksOut = 0;
|
|
let audioFlushes = 0;
|
|
|
|
// Current Gemini session — replaced on upgrade
|
|
let geminiWs = null;
|
|
let setupDone = false;
|
|
let currentToolCall = opts.onToolCall;
|
|
let _twilioToolAbort = null;
|
|
let _twilioToolCancelled = false;
|
|
|
|
// Audio processors — recreated on upgrade for clean state
|
|
let inProcessor = null;
|
|
let outProcessor = null;
|
|
|
|
function createAudioProcessors() {
|
|
outProcessor = createGeminiToTwilioProcessor();
|
|
inProcessor = createTwilioToGeminiProcessor((pcmBase64) => {
|
|
audioFlushes++;
|
|
if (setupDone && geminiWs?.readyState === WebSocket.OPEN) {
|
|
geminiWs.send(JSON.stringify({
|
|
realtime_input: {
|
|
audio: { mime_type: 'audio/pcm;rate=16000', data: pcmBase64 }
|
|
}
|
|
}));
|
|
if (audioFlushes % 50 === 1) {
|
|
console.log(`[twilio-venus] Audio: ${audioChunksIn} in, ${audioFlushes} flushes, ${audioChunksOut} out`);
|
|
}
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
// ── Connect to Gemini ─────────────────────────────────
|
|
function connectGemini(prompt, tools, toolHandler, voice) {
|
|
setupDone = false;
|
|
currentToolCall = toolHandler;
|
|
createAudioProcessors();
|
|
|
|
const geminiUrl = `wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=${geminiApiKey}`;
|
|
geminiWs = new WebSocket(geminiUrl);
|
|
|
|
geminiWs.on('open', () => {
|
|
console.log('[twilio-venus] Gemini connected, sending setup');
|
|
const setup = {
|
|
setup: {
|
|
model: `models/${model}`,
|
|
generation_config: Object.assign(
|
|
{ response_modalities: ['AUDIO'] },
|
|
!model.includes('3.1-flash-live') ? {
|
|
speech_config: { voice_config: { prebuilt_voice_config: { voice_name: voice || voiceName } } }
|
|
} : {}
|
|
),
|
|
system_instruction: { parts: [{ text: prompt }] },
|
|
tools: tools.length > 0 ? [{ function_declarations: tools }] : undefined,
|
|
}
|
|
};
|
|
geminiWs.send(JSON.stringify(setup));
|
|
});
|
|
|
|
geminiWs.on('message', (data) => {
|
|
try {
|
|
const msg = JSON.parse(data.toString());
|
|
|
|
if (msg.setupComplete) {
|
|
setupDone = true;
|
|
console.log(`[twilio-venus] Gemini setupComplete (${audioChunksIn} chunks buffered)`);
|
|
return;
|
|
}
|
|
|
|
// Tool call cancellation (BUG FIX 2026-04-23)
|
|
if (msg.toolCallCancellation) {
|
|
console.log(`[twilio-venus] Tool call cancelled: ${JSON.stringify(msg.toolCallCancellation?.ids || [])}`);
|
|
if (_twilioToolAbort) { _twilioToolAbort.abort(); _twilioToolAbort = null; }
|
|
_twilioToolCancelled = true;
|
|
return;
|
|
}
|
|
|
|
// Tool call
|
|
if (msg.toolCall) {
|
|
_twilioToolCancelled = false;
|
|
_twilioToolAbort = new AbortController();
|
|
const calls = msg.toolCall.functionCalls || [];
|
|
Promise.all(calls.map(async (fc) => {
|
|
if (_twilioToolCancelled) return null;
|
|
console.log(`[twilio-venus] Tool: ${fc.name}(${JSON.stringify(fc.args).slice(0, 80)})`);
|
|
const elapsed = ((Date.now() - callStartTime) / 1000).toFixed(1);
|
|
transcript.push({ role: 'tool', text: `[${elapsed}s] ${fc.name}`, ts: elapsed });
|
|
onTranscript({ role: 'tool', text: fc.name, ts: elapsed });
|
|
|
|
try {
|
|
const result = await currentToolCall(fc.name, fc.args || {});
|
|
if (_twilioToolCancelled) return null;
|
|
return { id: fc.id, name: fc.name, response: result };
|
|
} catch (e) {
|
|
if (_twilioToolCancelled || e.name === 'AbortError') return null;
|
|
return { id: fc.id, name: fc.name, response: { error: e.message } };
|
|
}
|
|
})).then((responses) => {
|
|
const valid = responses.filter(r => r !== null);
|
|
if (!_twilioToolCancelled && valid.length > 0 && geminiWs?.readyState === WebSocket.OPEN) {
|
|
geminiWs.send(JSON.stringify({
|
|
tool_response: { function_responses: valid }
|
|
}));
|
|
} else if (_twilioToolCancelled) {
|
|
console.log('[twilio-venus] Tool response suppressed — cancelled');
|
|
}
|
|
_twilioToolAbort = null;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Audio/text from Gemini → Twilio
|
|
if (msg.serverContent?.modelTurn?.parts) {
|
|
for (const part of msg.serverContent.modelTurn.parts) {
|
|
if (part.inlineData?.data && streamSid && twilioWs.readyState === WebSocket.OPEN) {
|
|
const ulawBase64 = outProcessor.process(part.inlineData.data);
|
|
audioChunksOut++;
|
|
twilioWs.send(JSON.stringify({
|
|
event: 'media', streamSid,
|
|
media: { payload: ulawBase64 }
|
|
}));
|
|
}
|
|
if (part.text) {
|
|
const elapsed = ((Date.now() - callStartTime) / 1000).toFixed(1);
|
|
transcript.push({ role: 'venus', text: part.text, ts: elapsed });
|
|
onTranscript({ role: 'venus', text: part.text, ts: elapsed });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Turn complete
|
|
if (msg.serverContent?.turnComplete) {
|
|
if (streamSid && twilioWs.readyState === WebSocket.OPEN) {
|
|
twilioWs.send(JSON.stringify({ event: 'mark', streamSid, mark: { name: 'turn-end' } }));
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('[twilio-venus] Parse error:', e.message);
|
|
}
|
|
});
|
|
|
|
geminiWs.on('close', (code, reason) => {
|
|
console.log(`[twilio-venus] Gemini closed: ${code} ${reason?.toString()?.slice(0, 80)}`);
|
|
});
|
|
|
|
geminiWs.on('error', (e) => {
|
|
console.error('[twilio-venus] Gemini error:', e.message);
|
|
});
|
|
}
|
|
|
|
// Start initial Gemini connection
|
|
connectGemini(systemPrompt, toolDefs, opts.onToolCall, voiceName);
|
|
|
|
// ── Handle Twilio messages ────────────────────────────
|
|
twilioWs.on('message', (raw) => {
|
|
try {
|
|
const msg = JSON.parse(raw.toString());
|
|
|
|
if (msg.event === 'start') {
|
|
streamSid = msg.start.streamSid;
|
|
callSid = msg.start.callSid;
|
|
callerPhone = msg.start.customParameters?.callerPhone || '';
|
|
console.log(`[twilio-venus] Call started: ${streamSid} from ${callerPhone || 'unknown'}`);
|
|
onCallStart(callSid, callerPhone);
|
|
}
|
|
|
|
if (msg.event === 'media' && msg.media?.payload) {
|
|
audioChunksIn++;
|
|
if (setupDone && inProcessor) {
|
|
inProcessor.push(msg.media.payload);
|
|
}
|
|
}
|
|
|
|
if (msg.event === 'stop') {
|
|
console.log(`[twilio-venus] Call stopped: ${streamSid} (${audioChunksIn} in, ${audioFlushes} flushes, ${audioChunksOut} out)`);
|
|
if (inProcessor) inProcessor.flush();
|
|
if (!callEnded) {
|
|
callEnded = true;
|
|
const duration = Math.round((Date.now() - callStartTime) / 1000);
|
|
onCallEnd(callSid, callerPhone, duration, transcript);
|
|
}
|
|
if (geminiWs?.readyState === WebSocket.OPEN) geminiWs.close();
|
|
}
|
|
} catch (e) {
|
|
console.error('[twilio-venus] Twilio msg error:', e.message);
|
|
}
|
|
});
|
|
|
|
twilioWs.on('close', () => {
|
|
console.log('[twilio-venus] Twilio disconnected');
|
|
if (!callEnded && callSid) {
|
|
callEnded = true;
|
|
const duration = Math.round((Date.now() - callStartTime) / 1000);
|
|
onCallEnd(callSid, callerPhone, duration, transcript);
|
|
}
|
|
if (geminiWs?.readyState === WebSocket.OPEN) geminiWs.close();
|
|
});
|
|
|
|
// ── Public API ────────────────────────────────────────
|
|
return {
|
|
get streamSid() { return streamSid; },
|
|
get callSid() { return callSid; },
|
|
get callerPhone() { return callerPhone; },
|
|
get transcript() { return transcript; },
|
|
|
|
/**
|
|
* UPGRADE: Close gatekeeper Gemini, open full Venus Gemini.
|
|
* Twilio audio stream stays connected throughout.
|
|
* Caller hears a brief pause while the new session connects.
|
|
*/
|
|
upgrade(newPrompt, newTools, newToolHandler, newVoice) {
|
|
console.log('[twilio-venus] ⬆️ UPGRADING: gatekeeper → full Venus');
|
|
|
|
// Close gatekeeper Gemini
|
|
if (geminiWs?.readyState === WebSocket.OPEN) {
|
|
geminiWs.close();
|
|
}
|
|
|
|
// Log the upgrade in transcript
|
|
const elapsed = ((Date.now() - callStartTime) / 1000).toFixed(1);
|
|
transcript.push({ role: 'system', text: `[${elapsed}s] UPGRADED to full Venus (${newTools.length} tools)`, ts: elapsed });
|
|
|
|
// Connect new Gemini with full context
|
|
connectGemini(newPrompt, newTools, newToolHandler, newVoice);
|
|
},
|
|
|
|
close() {
|
|
if (geminiWs?.readyState === WebSocket.OPEN) geminiWs.close();
|
|
if (twilioWs.readyState === WebSocket.OPEN) twilioWs.close();
|
|
}
|
|
};
|
|
}
|