Files
gbrain/recipes/agent-voice/code/public/call.html
T
e9fa51d46e v0.40.0.0 feat: agent-voice (Mars + Venus) + copy-into-host-repo skillpack paradigm (#1128)
* 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>
2026-05-22 21:54:13 -07:00

329 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Voice Agent</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
color-scheme: dark;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Inter, Roboto, "Helvetica Neue", sans-serif;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: radial-gradient(circle at top, #1a1a25, #0a0a0f);
color: #eaeaf0;
}
.card {
width: min(420px, 92vw);
padding: 32px;
border-radius: 16px;
background: rgba(30, 30, 40, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
}
h1 {
margin: 0 0 8px;
font-size: 24px;
font-weight: 600;
letter-spacing: -0.01em;
}
.subtitle { color: #9c9caa; font-size: 14px; margin-bottom: 24px; }
.status {
font-family: ui-monospace, "JetBrains Mono", Menlo, monospace;
font-size: 12px;
color: #9c9caa;
padding: 12px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
margin-bottom: 16px;
min-height: 60px;
white-space: pre-wrap;
}
.call-btn {
width: 100%;
padding: 14px 20px;
border-radius: 999px;
border: none;
background: linear-gradient(180deg, #4f46e5, #4338ca);
color: white;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: transform 0.05s ease, background 0.2s ease;
}
.call-btn:hover:not(:disabled) { background: linear-gradient(180deg, #5b52ec, #4d42d4); }
.call-btn:active:not(:disabled) { transform: scale(0.98); }
.call-btn:disabled { background: #2b2b36; cursor: not-allowed; }
.call-btn.active { background: linear-gradient(180deg, #dc2626, #b91c1c); }
.meta {
margin-top: 16px;
font-size: 12px;
color: #6c6c7a;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.badge {
padding: 2px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
}
</style>
</head>
<body>
<div class="card">
<h1>Voice Agent</h1>
<div class="subtitle" id="subtitle">Press connect to start a voice session.</div>
<div class="status" id="status">idle</div>
<button class="call-btn" id="callBtn">Connect</button>
<div class="meta">
<span class="badge" id="personaBadge">persona: venus</span>
<span class="badge" id="testBadge" style="display:none">?test=1 active</span>
</div>
</div>
<audio id="remoteAudio" autoplay playsinline></audio>
<script type="module">
// ── Configuration from query params ─────────────────────────
const params = new URLSearchParams(location.search);
const persona = (params.get('persona') || 'venus').toLowerCase();
const TEST_MODE = params.get('test') === '1';
document.getElementById('personaBadge').textContent = `persona: ${persona}`;
if (TEST_MODE) document.getElementById('testBadge').style.display = '';
// ── D5-A: ?test=1-gated instrumentation namespace ───────────
// Production load of /call has ZERO window._gbrainTest. The MediaRecorder
// never instantiates. No audio Blob lives in browser memory unless the
// test harness explicitly opts in via the query param.
if (TEST_MODE) {
window._gbrainTest = {
setupDone: false,
audioSendCount: 0,
audioPlayCount: 0,
lastResponseBlob: null,
log: [],
};
}
const callBtn = document.getElementById('callBtn');
const statusEl = document.getElementById('status');
const remoteAudio = document.getElementById('remoteAudio');
let pc = null; // RTCPeerConnection
let dc = null; // RTCDataChannel
let localStream = null; // microphone MediaStream
let mediaRecorder = null;
let recorderChunks = [];
let audioContext = null;
let teeDestination = null;
let callActive = false;
function setStatus(text) {
statusEl.textContent = text;
if (TEST_MODE) window._gbrainTest.log.push({ t: Date.now(), text });
}
async function startCall() {
callBtn.disabled = true;
callBtn.textContent = 'Connecting...';
setStatus('requesting microphone...');
try {
// 1. Mic.
localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
setStatus('microphone OK; creating peer connection...');
// 2. RTCPeerConnection.
pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
for (const track of localStream.getAudioTracks()) {
pc.addTrack(track, localStream);
}
// 3. Data channel for tool calls.
dc = pc.createDataChannel('oai-events');
dc.onopen = () => {
setStatus('data channel open');
};
dc.onmessage = (ev) => handleDataChannelMessage(ev.data);
// 4. Remote track handler — D12-A WebAudio-tee for capture in test mode.
pc.ontrack = (ev) => {
const stream = ev.streams[0];
remoteAudio.srcObject = stream;
if (TEST_MODE) {
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
const src = audioContext.createMediaStreamSource(stream);
teeDestination = audioContext.createMediaStreamDestination();
// Connect ONLY to the tee destination. The <audio> element
// handles playback via its own srcObject above; we don't
// double-connect to audioContext.destination (would cause
// a 2x playback echo).
src.connect(teeDestination);
mediaRecorder = new MediaRecorder(teeDestination.stream, {
mimeType: 'audio/webm; codecs=opus',
});
recorderChunks = [];
mediaRecorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) recorderChunks.push(e.data);
};
mediaRecorder.onstop = () => {
window._gbrainTest.lastResponseBlob = new Blob(recorderChunks, {
type: 'audio/webm',
});
};
mediaRecorder.start(250); // 250ms chunks
} catch (err) {
setStatus(`tee setup failed: ${err.message}`);
}
}
// Hook playback counter.
const audio = stream.getAudioTracks()[0];
if (audio) {
audio.onunmute = () => {
if (TEST_MODE) window._gbrainTest.audioPlayCount++;
};
}
};
// 5. Outbound counter — fires whenever we send audio frames.
// RTCPeerConnection doesn't expose a "frame sent" event natively,
// but we can poll getStats() on a short interval and count
// outbound-rtp packets.
if (TEST_MODE) {
const statsTimer = setInterval(async () => {
if (!pc || pc.connectionState === 'closed') {
clearInterval(statsTimer);
return;
}
try {
const stats = await pc.getStats();
stats.forEach((report) => {
if (report.type === 'outbound-rtp' && report.kind === 'audio') {
// Each packetsSent tick is roughly 20ms of audio.
window._gbrainTest.audioSendCount = report.packetsSent || 0;
}
if (report.type === 'inbound-rtp' && report.kind === 'audio') {
if (report.packetsReceived > 0 && window._gbrainTest.audioPlayCount === 0) {
// Mark playback as starting if we've received any packets,
// since `onunmute` doesn't always fire.
window._gbrainTest.audioPlayCount = report.packetsReceived;
}
}
});
} catch {
// ignore
}
}, 500);
}
// 6. SDP offer.
const offer = await pc.createOffer({ offerToReceiveAudio: true });
await pc.setLocalDescription(offer);
setStatus('sending SDP offer to /session...');
const sessionUrl = `/session?persona=${encodeURIComponent(persona)}`;
const res = await fetch(sessionUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`/session ${res.status}: ${text.slice(0, 200)}`);
}
const answerSdp = await res.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
setStatus('SDP exchanged; waiting for media...');
callActive = true;
callBtn.disabled = false;
callBtn.textContent = 'Hang up';
callBtn.classList.add('active');
if (TEST_MODE) window._gbrainTest.setupDone = true;
} catch (err) {
setStatus(`ERROR: ${err.message}`);
callBtn.disabled = false;
callBtn.textContent = 'Connect';
await endCall();
if (TEST_MODE) {
window._gbrainTest.error = err.message;
}
}
}
// Handle tool-call messages from the WebRTC data channel.
async function handleDataChannelMessage(raw) {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
// OpenAI Realtime emits various event types. We only act on function calls.
if (msg.type === 'response.function_call_arguments.done' ||
msg.type === 'conversation.item.input_audio_transcription.completed') {
// Function call complete — dispatch to /tool.
if (msg.name && msg.call_id) {
try {
const args = msg.arguments ? JSON.parse(msg.arguments) : {};
const res = await fetch('/tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: msg.name, arguments: args }),
});
const result = await res.json();
// Return the result via the data channel.
dc.send(JSON.stringify({
type: 'conversation.item.create',
item: {
type: 'function_call_output',
call_id: msg.call_id,
output: JSON.stringify(result),
},
}));
dc.send(JSON.stringify({ type: 'response.create' }));
} catch (err) {
setStatus(`tool dispatch error: ${err.message}`);
}
}
}
}
async function endCall() {
callActive = false;
callBtn.textContent = 'Connect';
callBtn.classList.remove('active');
setStatus('hung up');
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try { mediaRecorder.stop(); } catch {}
}
if (pc) {
try { pc.close(); } catch {}
pc = null;
}
if (localStream) {
for (const track of localStream.getTracks()) track.stop();
localStream = null;
}
if (audioContext) {
try { await audioContext.close(); } catch {}
audioContext = null;
}
}
callBtn.addEventListener('click', () => {
if (callActive) endCall();
else startCall();
});
</script>
</body>
</html>