mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +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>
207 lines
7.1 KiB
JavaScript
207 lines
7.1 KiB
JavaScript
/**
|
|
* context-builder.example.mjs — Working example implementation.
|
|
*
|
|
* Provides buildMarsContext() and buildVenusContext() against a documented
|
|
* brain layout. Operators with a different layout edit the path constants
|
|
* at the top, OR replace this file in place with their own implementation
|
|
* that satisfies `context-builder.contract.md`.
|
|
*
|
|
* This example reads:
|
|
* $BRAIN_ROOT/memory/YYYY-MM-DD.md (daily memory; emotional signal)
|
|
* $BRAIN_ROOT/SOUL.md (stable emotional landscape)
|
|
* $BRAIN_ROOT/tasks/open.md (active tasks for Venus)
|
|
* $BRAIN_ROOT/calendar/today.md (today's events for Venus)
|
|
* $BRAIN_ROOT/memory/heartbeat-state.json (optional timezone)
|
|
*
|
|
* The implementation is intentionally generic. It does NOT name specific
|
|
* family members, therapists, or projects. It uses a content-agnostic
|
|
* emotion-word filter that catches what's emotionally loaded in the
|
|
* operator's own words.
|
|
*
|
|
* Latency budget: ≤ 200ms wall time.
|
|
* Output cap: 2500 chars (truncated at boundary).
|
|
* PII scrub: emails + phones → [redacted] at the boundary.
|
|
*/
|
|
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const MAX_CHARS = 2500;
|
|
|
|
// Emotion-word filter. Content-agnostic — catches what's loaded in the
|
|
// operator's OWN words without hardcoding names of people in their life.
|
|
// Add words to this list if your brain uses domain-specific vocabulary.
|
|
const EMOTION_WORDS = [
|
|
'feel', 'feeling', 'felt',
|
|
'heart', 'love', 'lonely', 'alone',
|
|
'joy', 'happy', 'happiness',
|
|
'grief', 'sad', 'sadness', 'cry', 'crying',
|
|
'anger', 'angry', 'rage', 'frustrated',
|
|
'fear', 'afraid', 'scared', 'anxious', 'anxiety',
|
|
'hope', 'hopeful', 'hopeless',
|
|
'ache', 'aching', 'miss', 'missing', 'longing',
|
|
'alive', 'dead', 'numb', 'numbing',
|
|
'therapy', 'therapist',
|
|
'family', 'father', 'mother', 'son', 'daughter',
|
|
'relationship', 'partner',
|
|
'tired', 'exhausted', 'burnt out', 'burned out',
|
|
'present', 'presence', 'mindful',
|
|
'meaning', 'meaningful', 'purpose',
|
|
'pattern', 'insight', 'realize', 'realized',
|
|
'memory', 'remember', 'forgot',
|
|
];
|
|
|
|
const REDACT_RE = {
|
|
email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
|
phone: /(?:\+?\d{1,3}[\s.-]?)?(?:\(\d{3}\)|\d{3})[\s.-]?\d{3}[\s.-]?\d{4}/g,
|
|
};
|
|
|
|
/** Scrub emails + phones from a string. Conservative; misses long-tail PII. */
|
|
function scrub(ctx) {
|
|
return ctx.replace(REDACT_RE.email, '[email]').replace(REDACT_RE.phone, '[phone]');
|
|
}
|
|
|
|
/** Truncate to MAX_CHARS at a word boundary. */
|
|
function cap(ctx) {
|
|
if (ctx.length <= MAX_CHARS) return ctx;
|
|
const slice = ctx.slice(0, MAX_CHARS);
|
|
const lastBreak = slice.lastIndexOf('\n');
|
|
return lastBreak > MAX_CHARS - 200 ? slice.slice(0, lastBreak) : slice;
|
|
}
|
|
|
|
/** ISO YYYY-MM-DD from a Date in the given timezone. Defaults to UTC. */
|
|
function isoDate(date, tz) {
|
|
if (!tz) return date.toISOString().slice(0, 10);
|
|
// Intl is slow; only when caller passes a tz.
|
|
const fmt = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
});
|
|
return fmt.format(date);
|
|
}
|
|
|
|
/** Detect timezone from optional heartbeat-state.json. */
|
|
function detectTimezone(brainRoot) {
|
|
const hbPath = join(brainRoot, 'memory', 'heartbeat-state.json');
|
|
if (!existsSync(hbPath)) return undefined;
|
|
try {
|
|
const hb = JSON.parse(readFileSync(hbPath, 'utf8'));
|
|
return hb?.currentLocation?.timezone;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build emotionally-salient context for Mars.
|
|
*
|
|
* Strategy:
|
|
* 1. Date + timezone awareness for "what time is it" questions.
|
|
* 2. SOUL.md (capped at 600 chars) for stable emotional landscape.
|
|
* 3. Last 2 days of memory files, emotion-word-filtered, ≤ 8 lines each.
|
|
* 4. PII scrub + truncation cap.
|
|
*/
|
|
export async function buildMarsContext({ brainRoot, timezone } = {}) {
|
|
if (!brainRoot) return '';
|
|
const tz = timezone || detectTimezone(brainRoot) || 'UTC';
|
|
|
|
let ctx = "WHAT IS GOING ON IN THE OPERATOR'S INNER LIFE RIGHT NOW.\n";
|
|
ctx += "Use this as background. Don't recite it. Let it inform your questions and responses.\n\n";
|
|
|
|
// 1. Date + time
|
|
try {
|
|
const now = new Date();
|
|
const dateStr = now.toLocaleDateString('en-US', {
|
|
timeZone: tz, weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
|
});
|
|
const timeStr = now.toLocaleTimeString('en-US', {
|
|
timeZone: tz, hour: 'numeric', minute: '2-digit',
|
|
});
|
|
ctx += `It's ${dateStr}, ${timeStr} (${tz}).\n\n`;
|
|
} catch {
|
|
// tz invalid — fall through with no date line
|
|
}
|
|
|
|
// 2. Stable emotional landscape
|
|
try {
|
|
const soulPath = join(brainRoot, 'SOUL.md');
|
|
if (existsSync(soulPath)) {
|
|
const soul = readFileSync(soulPath, 'utf8');
|
|
// Take the first ~600 chars as the "core context."
|
|
ctx += `CORE CONTEXT:\n${soul.slice(0, 600).trim()}\n\n`;
|
|
}
|
|
} catch {}
|
|
|
|
// 3. Recent daily memory, emotion-filtered.
|
|
try {
|
|
const now = new Date();
|
|
const dates = [now, new Date(now.getTime() - 86400000)].map((d) => isoDate(d, tz));
|
|
for (const date of dates) {
|
|
const dayPath = join(brainRoot, 'memory', `${date}.md`);
|
|
if (!existsSync(dayPath)) continue;
|
|
const day = readFileSync(dayPath, 'utf8');
|
|
const emotionalLines = day.split('\n').filter((l) => {
|
|
const lower = l.toLowerCase();
|
|
return EMOTION_WORDS.some((w) => lower.includes(w));
|
|
}).slice(0, 8);
|
|
if (emotionalLines.length > 0) {
|
|
ctx += `RECENT (${date}):\n${emotionalLines.join('\n')}\n\n`;
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
return cap(scrub(ctx));
|
|
}
|
|
|
|
/**
|
|
* Build logistics-salient context for Venus.
|
|
*
|
|
* Strategy:
|
|
* 1. Date + timezone.
|
|
* 2. Today's calendar events (calendar/today.md if present).
|
|
* 3. Top open tasks (tasks/open.md if present, first ~5 lines).
|
|
* 4. PII scrub + cap.
|
|
*/
|
|
export async function buildVenusContext({ brainRoot, timezone } = {}) {
|
|
if (!brainRoot) return '';
|
|
const tz = timezone || detectTimezone(brainRoot) || 'UTC';
|
|
|
|
let ctx = "TODAY AT A GLANCE for the operator.\n";
|
|
ctx += "Use this for fast logistics answers. Don't recite it; pull from it.\n\n";
|
|
|
|
try {
|
|
const now = new Date();
|
|
const dateStr = now.toLocaleDateString('en-US', {
|
|
timeZone: tz, weekday: 'long', month: 'long', day: 'numeric',
|
|
});
|
|
const timeStr = now.toLocaleTimeString('en-US', {
|
|
timeZone: tz, hour: 'numeric', minute: '2-digit',
|
|
});
|
|
ctx += `${dateStr}, ${timeStr} (${tz}).\n\n`;
|
|
} catch {}
|
|
|
|
// Calendar
|
|
try {
|
|
const calPath = join(brainRoot, 'calendar', 'today.md');
|
|
if (existsSync(calPath)) {
|
|
const cal = readFileSync(calPath, 'utf8').trim();
|
|
if (cal) {
|
|
ctx += `CALENDAR:\n${cal.slice(0, 800)}\n\n`;
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// Open tasks
|
|
try {
|
|
const tasksPath = join(brainRoot, 'tasks', 'open.md');
|
|
if (existsSync(tasksPath)) {
|
|
const tasks = readFileSync(tasksPath, 'utf8');
|
|
const lines = tasks.split('\n').filter((l) => l.trim().startsWith('-') || l.trim().startsWith('*')).slice(0, 5);
|
|
if (lines.length > 0) {
|
|
ctx += `TOP TASKS:\n${lines.join('\n')}\n\n`;
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
return cap(scrub(ctx));
|
|
}
|