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>
This commit is contained in:
Garry Tan
2026-05-22 21:54:13 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 6987934ebb
commit e9fa51d46e
67 changed files with 7736 additions and 7 deletions
+672 -1
View File
@@ -34,12 +34,21 @@ interface RecipeSecret {
where: string;
}
/**
* Install mode discriminator. New recipes default to 'local-managed' (the
* legacy path that writes to ~/.gbrain/skills/). 'copy-into-host-repo'
* recipes write their bundle into the operator's host agent repo via the
* `gbrain integrations install` subcommand.
*/
type InstallKind = 'local-managed' | 'copy-into-host-repo';
interface RecipeFrontmatter {
id: string;
name: string;
version: string;
description: string;
category: 'infra' | 'sense' | 'reflex';
category: 'infra' | 'sense' | 'reflex' | 'voice';
install_kind: InstallKind;
requires: string[];
secrets: RecipeSecret[];
health_checks: HealthCheck[];
@@ -296,6 +305,8 @@ export function parseRecipe(content: string, filename: string): ParsedRecipe | n
try {
const { data, content: body } = matter(content);
if (!data.id) return null;
const installKind: InstallKind =
data.install_kind === 'copy-into-host-repo' ? 'copy-into-host-repo' : 'local-managed';
return {
frontmatter: {
id: data.id,
@@ -303,6 +314,7 @@ export function parseRecipe(content: string, filename: string): ParsedRecipe | n
version: data.version || '0.0.0',
description: data.description || '',
category: data.category || 'sense',
install_kind: installKind,
requires: data.requires || [],
secrets: data.secrets || [],
health_checks: (data.health_checks || []) as HealthCheck[],
@@ -851,6 +863,662 @@ USAGE
// --- Main Entry ---
// =============================================================================
// `gbrain integrations install <recipe-id>` — copy-into-host-repo path.
//
// Reads the recipe's `install/manifest.json` (sibling to `recipes/<id>.md`),
// validates the target host repo, copies each manifest entry to the target,
// computes SHA-256 hashes during the copy, writes
// <target>/services/voice-agent/.gbrain-source.json so future --refresh calls
// can do three-way classification (unchanged-identical / unchanged-stale /
// locally-modified).
//
// Target validation (path-traversal + privacy hardening):
// - Must be an existing directory.
// - Must NOT be gbrain itself OR a parent of gbrain.
// - Must contain a `.git` directory (refuses missing-git-root).
// - Must NOT contain existing files at any target path (unless --overwrite).
// - All manifest target paths must be relative; rejects `..` and absolute.
// - Symlink-escape check via realpath comparison.
//
// Refresh mode (`--refresh`) is documented in install/refresh-algorithm.md.
// The v0 install command implements the COPY path; refresh is a follow-up.
// =============================================================================
import { createHash } from 'node:crypto';
import {
copyFileSync,
statSync as fsStatSync,
realpathSync,
chmodSync,
appendFileSync as fsAppendFileSync,
} from 'node:fs';
import { resolve as pathResolve, dirname as pathDirname } from 'node:path';
interface ManifestFileEntry {
src: string;
target: string;
mode?: string;
}
interface InstallManifest {
recipe: string;
version: string;
install_kind: InstallKind;
target_root_relative_to_host_repo: string;
skills_target_root_relative_to_host_repo: string;
files: ManifestFileEntry[];
skills: ManifestFileEntry[];
resolver_rows_to_append?: string[];
}
interface InstalledFileRecord {
src: string;
target: string;
sha256: string;
mode: string;
}
interface GbrainSourceJson {
recipe: string;
gbrain_version: string;
install_kind: InstallKind;
copied_at: string;
files: InstalledFileRecord[];
}
function sha256OfFile(path: string): string {
const h = createHash('sha256');
h.update(readFileSync(path));
return h.digest('hex');
}
/**
* Validate a target path inside the host repo. Rejects:
* - Absolute paths
* - Paths containing '..' segments
* - Paths that escape via symlink (resolved real path leaves target root)
*/
function validateManifestTarget(target: string): string | null {
if (target.startsWith('/')) return `absolute path not allowed: ${target}`;
if (target.includes('..')) return `parent-dir escape not allowed: ${target}`;
if (target.includes('\0')) return `null byte in path: ${target}`;
return null;
}
/**
* Validate the host target repo.
* - Exists + is a directory
* - Has a `.git` (refuses missing-git-root)
* - Not gbrain itself; not a parent of gbrain
* - Refuses if any manifest target already exists (unless --overwrite)
*/
function validateTargetRepo(
targetRepo: string,
manifestEntries: ManifestFileEntry[],
overwrite: boolean,
): string | null {
let resolvedTarget: string;
try {
resolvedTarget = realpathSync(targetRepo);
} catch {
return `target repo does not exist or is not accessible: ${targetRepo}`;
}
let stat;
try {
stat = fsStatSync(resolvedTarget);
} catch {
return `target repo stat failed: ${resolvedTarget}`;
}
if (!stat.isDirectory()) return `target is not a directory: ${resolvedTarget}`;
// Refuse if target is gbrain itself or contains gbrain.
let gbrainRoot: string | null = null;
try {
gbrainRoot = realpathSync(pathResolve(__dirname, '..', '..'));
} catch {
// ignore — non-fatal
}
if (gbrainRoot && (resolvedTarget === gbrainRoot || gbrainRoot.startsWith(resolvedTarget + '/'))) {
return `refusing to install into gbrain itself (or a parent dir): ${resolvedTarget}`;
}
// Must have a .git
try {
const gitStat = fsStatSync(join(resolvedTarget, '.git'));
if (!gitStat.isDirectory() && !gitStat.isFile()) {
return `target has no .git: ${resolvedTarget}`;
}
} catch {
return `target has no .git: ${resolvedTarget}`;
}
if (!overwrite) {
for (const entry of manifestEntries) {
const targetPath = join(resolvedTarget, entry.target);
try {
fsStatSync(targetPath);
return `refusing to overwrite existing file at ${entry.target} (pass --overwrite to force)`;
} catch {
// not exists; fine
}
}
}
return null;
}
interface InstallOpts {
target: string;
refresh?: boolean;
overwrite?: boolean;
dryRun?: boolean;
autoMode?: 'keep-mine' | 'take-theirs' | null;
}
// Per-file refresh classification per recipes/agent-voice/install/refresh-algorithm.md.
type FileRefreshState =
| 'unchanged-identical'
| 'unchanged-stale'
| 'locally-modified'
| 'source-deleted'
| 'host-deleted'
| 'new-in-manifest';
interface RefreshClassification {
src: string;
target: string;
state: FileRefreshState;
recordedSha?: string;
currentSrcSha?: string;
currentHostSha?: string;
}
/**
* Compute SHA-256 of a string buffer.
*/
function sha256OfBuffer(buf: Buffer): string {
const h = createHash('sha256');
h.update(buf);
return h.digest('hex');
}
/**
* Three-way classification for refresh mode.
*
* For every manifest entry + every host file:
* - identical: host hash == src hash → no-op
* - stale: host hash == recorded hash && host hash != src hash → safe to update
* - locally-modified: host hash != recorded hash && host hash != src hash → operator edited
* - source-deleted: manifest dropped this file; host still has it
* - host-deleted: manifest has it; host file missing
* - new-in-manifest: file in manifest, not in prior install record
*/
function classifyForRefresh(
manifestEntries: ManifestFileEntry[],
recordedFiles: InstalledFileRecord[],
recipeBundleRoot: string,
resolvedTarget: string,
): RefreshClassification[] {
const recordedByTarget = new Map<string, InstalledFileRecord>();
for (const r of recordedFiles) recordedByTarget.set(r.target, r);
const classifications: RefreshClassification[] = [];
const manifestTargets = new Set<string>();
// Pass 1: walk current manifest entries.
for (const entry of manifestEntries) {
manifestTargets.add(entry.target);
const srcPath = pathResolve(recipeBundleRoot, entry.src);
const targetPath = pathResolve(resolvedTarget, entry.target);
let currentSrcSha: string | undefined;
try {
currentSrcSha = sha256OfBuffer(readFileSync(srcPath));
} catch {
// src missing? Skip — this would mean a manifest pointing at a missing file in gbrain.
continue;
}
let currentHostSha: string | undefined;
let hostExists = false;
try {
currentHostSha = sha256OfBuffer(readFileSync(targetPath));
hostExists = true;
} catch {
hostExists = false;
}
const recorded = recordedByTarget.get(entry.target);
if (!hostExists) {
classifications.push({ src: entry.src, target: entry.target, state: 'host-deleted', recordedSha: recorded?.sha256, currentSrcSha });
continue;
}
if (!recorded) {
classifications.push({ src: entry.src, target: entry.target, state: 'new-in-manifest', currentSrcSha, currentHostSha });
continue;
}
if (currentHostSha === currentSrcSha) {
classifications.push({ src: entry.src, target: entry.target, state: 'unchanged-identical', recordedSha: recorded.sha256, currentSrcSha, currentHostSha });
} else if (currentHostSha === recorded.sha256) {
classifications.push({ src: entry.src, target: entry.target, state: 'unchanged-stale', recordedSha: recorded.sha256, currentSrcSha, currentHostSha });
} else {
classifications.push({ src: entry.src, target: entry.target, state: 'locally-modified', recordedSha: recorded.sha256, currentSrcSha, currentHostSha });
}
}
// Pass 2: anything in recorded but NOT in current manifest = source-deleted.
for (const r of recordedFiles) {
if (!manifestTargets.has(r.target)) {
classifications.push({ src: r.src, target: r.target, state: 'source-deleted', recordedSha: r.sha256 });
}
}
return classifications;
}
/**
* Append one event to the refresh transaction journal.
*/
function appendRefreshLog(targetVoiceAgentDir: string, event: object) {
try {
const logPath = pathResolve(targetVoiceAgentDir, '.gbrain-source.refresh.log');
fsAppendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n');
} catch {
/* non-fatal */
}
}
/**
* Refresh mode: read `.gbrain-source.json`, classify, apply decisions.
*/
async function refreshRecipeIntoHostRepo(
recipeId: string,
opts: InstallOpts,
): Promise<{ classifications: RefreshClassification[]; applied: number; manifestPath: string }> {
const recipe = findRecipe(recipeId);
if (!recipe) throw new Error(`recipe not found: ${recipeId}`);
if (recipe.frontmatter.install_kind !== 'copy-into-host-repo') {
throw new Error(`recipe ${recipeId} is not copy-into-host-repo (install_kind=${recipe.frontmatter.install_kind})`);
}
const recipeBundleRoot = pathResolve(
pathDirname(pathResolve(__dirname, '..', '..', 'recipes', recipe.filename)),
recipe.filename.replace(/\.md$/, ''),
);
const manifestPath = join(recipeBundleRoot, 'install', 'manifest.json');
const manifest: InstallManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
let resolvedTarget: string;
try {
resolvedTarget = realpathSync(opts.target);
} catch {
throw new Error(`target repo does not exist: ${opts.target}`);
}
const sourceFilePath = pathResolve(
resolvedTarget,
manifest.target_root_relative_to_host_repo,
'.gbrain-source.json',
);
if (!existsSync(sourceFilePath)) {
throw new Error(`.gbrain-source.json not found at ${sourceFilePath} — this target was never installed via copy-into-host-repo; run without --refresh first`);
}
let recorded: GbrainSourceJson;
try {
recorded = JSON.parse(readFileSync(sourceFilePath, 'utf8'));
} catch (err) {
throw new Error(`failed to parse .gbrain-source.json: ${(err as Error).message}`);
}
if (recorded.recipe !== recipeId) {
throw new Error(`.gbrain-source.json recipe="${recorded.recipe}" does not match requested recipe="${recipeId}"`);
}
const allManifestEntries: ManifestFileEntry[] = [...(manifest.files || []), ...(manifest.skills || [])];
const classifications = classifyForRefresh(allManifestEntries, recorded.files || [], recipeBundleRoot, resolvedTarget);
// Print classification summary.
const counts: Record<string, number> = {};
for (const c of classifications) counts[c.state] = (counts[c.state] || 0) + 1;
console.log(`[refresh] ${recipeId}${resolvedTarget}`);
for (const [state, n] of Object.entries(counts)) {
console.log(` ${state}: ${n}`);
}
if (opts.dryRun) {
console.log('[refresh] DRY RUN — no files written. Per-file detail:');
for (const c of classifications) {
console.log(` [${c.state}] ${c.target}`);
}
return { classifications, applied: 0, manifestPath };
}
const targetVoiceAgentDir = pathResolve(resolvedTarget, manifest.target_root_relative_to_host_repo);
appendRefreshLog(targetVoiceAgentDir, { event: 'refresh_started', recipe: recipeId, counts });
let applied = 0;
const updatedFiles: InstalledFileRecord[] = [];
for (const c of classifications) {
const srcAbs = pathResolve(recipeBundleRoot, c.src);
const targetAbs = pathResolve(resolvedTarget, c.target);
const manifestEntry = allManifestEntries.find((e) => e.target === c.target);
switch (c.state) {
case 'unchanged-identical': {
// No-op. Carry forward the recorded entry.
const r = recorded.files.find((f) => f.target === c.target);
if (r) updatedFiles.push(r);
break;
}
case 'unchanged-stale': {
// Operator's file matches the recorded SHA; source has moved. Auto-update.
copyFileSync(srcAbs, targetAbs);
if (manifestEntry?.mode) {
try { chmodSync(targetAbs, parseInt(manifestEntry.mode, 8)); } catch { /* ignore */ }
}
const newSha = sha256OfBuffer(readFileSync(srcAbs));
updatedFiles.push({ src: c.src, target: c.target, sha256: newSha, mode: manifestEntry?.mode || '0644' });
applied++;
appendRefreshLog(targetVoiceAgentDir, { event: 'updated', src: c.src, target: c.target, decision: 'take-theirs' });
break;
}
case 'locally-modified': {
const decision = opts.autoMode || 'keep-mine'; // Default to safety: preserve local edit.
if (decision === 'take-theirs') {
copyFileSync(srcAbs, targetAbs);
if (manifestEntry?.mode) {
try { chmodSync(targetAbs, parseInt(manifestEntry.mode, 8)); } catch { /* ignore */ }
}
const newSha = sha256OfBuffer(readFileSync(srcAbs));
updatedFiles.push({ src: c.src, target: c.target, sha256: newSha, mode: manifestEntry?.mode || '0644' });
applied++;
appendRefreshLog(targetVoiceAgentDir, { event: 'overwrote_local', src: c.src, target: c.target, decision: 'take-theirs' });
} else {
// keep-mine — the operator's file stays; we update the recorded SHA to their current host SHA
// so future refreshes don't re-flag the same file until either side changes again.
updatedFiles.push({ src: c.src, target: c.target, sha256: c.currentHostSha!, mode: manifestEntry?.mode || '0644' });
appendRefreshLog(targetVoiceAgentDir, { event: 'preserved_local', src: c.src, target: c.target, decision: 'keep-mine' });
}
console.log(` [locally-modified] ${c.target}${decision}`);
break;
}
case 'host-deleted': {
// Operator removed the file. Offer to restore (auto-mode 'take-theirs') or leave it gone (default).
const decision = opts.autoMode === 'take-theirs' ? 'restore' : 'leave-deleted';
if (decision === 'restore') {
mkdirSync(pathDirname(targetAbs), { recursive: true });
copyFileSync(srcAbs, targetAbs);
if (manifestEntry?.mode) {
try { chmodSync(targetAbs, parseInt(manifestEntry.mode, 8)); } catch { /* ignore */ }
}
const newSha = sha256OfBuffer(readFileSync(srcAbs));
updatedFiles.push({ src: c.src, target: c.target, sha256: newSha, mode: manifestEntry?.mode || '0644' });
applied++;
appendRefreshLog(targetVoiceAgentDir, { event: 'restored', src: c.src, target: c.target, decision: 'restore' });
} else {
appendRefreshLog(targetVoiceAgentDir, { event: 'host_deleted_left_alone', src: c.src, target: c.target });
// Don't carry forward into updatedFiles — the file is genuinely gone.
}
console.log(` [host-deleted] ${c.target}${decision}`);
break;
}
case 'source-deleted': {
// gbrain reference removed this file; offer cleanup with --auto take-theirs.
const decision = opts.autoMode === 'take-theirs' ? 'cleanup' : 'leave-orphan';
if (decision === 'cleanup') {
try {
// Just unlink — keep things conservative.
const unlinkSync = require('node:fs').unlinkSync;
unlinkSync(targetAbs);
applied++;
appendRefreshLog(targetVoiceAgentDir, { event: 'removed_orphan', target: c.target, decision: 'cleanup' });
} catch (err) {
console.warn(` [source-deleted] failed to remove orphan ${c.target}: ${(err as Error).message}`);
}
} else {
appendRefreshLog(targetVoiceAgentDir, { event: 'orphan_left_alone', target: c.target });
}
console.log(` [source-deleted] ${c.target}${decision}`);
break;
}
case 'new-in-manifest': {
// Wasn't in the recorded manifest; was added in this refresh. Default: install it.
mkdirSync(pathDirname(targetAbs), { recursive: true });
copyFileSync(srcAbs, targetAbs);
if (manifestEntry?.mode) {
try { chmodSync(targetAbs, parseInt(manifestEntry.mode, 8)); } catch { /* ignore */ }
}
const newSha = sha256OfBuffer(readFileSync(srcAbs));
updatedFiles.push({ src: c.src, target: c.target, sha256: newSha, mode: manifestEntry?.mode || '0644' });
applied++;
appendRefreshLog(targetVoiceAgentDir, { event: 'added_new', src: c.src, target: c.target });
break;
}
}
}
// Re-write .gbrain-source.json with the updated SHAs.
const gbrainVersion = (() => {
try {
const pkgPath = pathResolve(__dirname, '..', '..', 'package.json');
return JSON.parse(readFileSync(pkgPath, 'utf8')).version || 'unknown';
} catch { return 'unknown'; }
})();
const updatedRecord: GbrainSourceJson = {
recipe: recipeId,
gbrain_version: gbrainVersion,
install_kind: 'copy-into-host-repo',
copied_at: new Date().toISOString(),
files: updatedFiles,
};
const fsModule = require('node:fs');
fsModule.writeFileSync(sourceFilePath, JSON.stringify(updatedRecord, null, 2) + '\n');
appendRefreshLog(targetVoiceAgentDir, { event: 'refresh_complete', applied });
return { classifications, applied, manifestPath };
}
export { refreshRecipeIntoHostRepo, classifyForRefresh };
export async function installRecipeIntoHostRepo(
recipeId: string,
opts: InstallOpts,
): Promise<{ written: number; manifestPath: string }> {
const recipe = findRecipe(recipeId);
if (!recipe) throw new Error(`recipe not found: ${recipeId}`);
if (recipe.frontmatter.install_kind !== 'copy-into-host-repo') {
throw new Error(
`recipe ${recipeId} uses install_kind=${recipe.frontmatter.install_kind}; ` +
`this command only supports copy-into-host-repo recipes.`,
);
}
// Find the recipe bundle root: recipes/<id>/ (sibling to recipes/<id>.md).
const recipeBundleRoot = pathResolve(
pathDirname(pathResolve(__dirname, '..', '..', 'recipes', recipe.filename)),
recipe.filename.replace(/\.md$/, ''),
);
const manifestPath = join(recipeBundleRoot, 'install', 'manifest.json');
let manifest: InstallManifest;
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
} catch (err) {
throw new Error(`failed to read manifest at ${manifestPath}: ${(err as Error).message}`);
}
// Combine file + skill entries for the validation + copy loop.
const allEntries: ManifestFileEntry[] = [
...(manifest.files || []),
...(manifest.skills || []),
];
// Validate every manifest target path.
for (const entry of allEntries) {
const reason = validateManifestTarget(entry.target);
if (reason) throw new Error(`manifest entry invalid (${entry.src}${entry.target}): ${reason}`);
}
// Validate the target repo.
const targetRepoError = validateTargetRepo(opts.target, allEntries, !!opts.overwrite);
if (targetRepoError) throw new Error(targetRepoError);
const resolvedTarget = realpathSync(opts.target);
if (opts.dryRun) {
console.log(`[install] DRY RUN — would copy ${allEntries.length} files into ${resolvedTarget}`);
for (const entry of allEntries) {
console.log(` ${entry.src}${entry.target}`);
}
return { written: 0, manifestPath };
}
// Copy each entry.
const installedRecords: InstalledFileRecord[] = [];
for (const entry of allEntries) {
const srcPath = join(recipeBundleRoot, entry.src);
const targetPath = join(resolvedTarget, entry.target);
mkdirSync(pathDirname(targetPath), { recursive: true });
copyFileSync(srcPath, targetPath);
if (entry.mode) {
try { chmodSync(targetPath, parseInt(entry.mode, 8)); } catch { /* non-fatal */ }
}
const hash = sha256OfFile(srcPath);
installedRecords.push({
src: entry.src,
target: entry.target,
sha256: hash,
mode: entry.mode || '0644',
});
}
// Write the .gbrain-source.json manifest into the target repo.
// Per D11-A: NO upstream_repo field, NO imported_from field.
const gbrainVersion = (() => {
try {
const pkgPath = pathResolve(__dirname, '..', '..', 'package.json');
return JSON.parse(readFileSync(pkgPath, 'utf8')).version || 'unknown';
} catch { return 'unknown'; }
})();
const gbrainSource: GbrainSourceJson = {
recipe: recipeId,
gbrain_version: gbrainVersion,
install_kind: 'copy-into-host-repo',
copied_at: new Date().toISOString(),
files: installedRecords,
};
const sourceFilePath = join(
resolvedTarget,
manifest.target_root_relative_to_host_repo,
'.gbrain-source.json',
);
mkdirSync(pathDirname(sourceFilePath), { recursive: true });
writeFileSync(sourceFilePath, JSON.stringify(gbrainSource, null, 2) + '\n');
// Append resolver rows (if any) to the host's RESOLVER.md or AGENTS.md.
if (manifest.resolver_rows_to_append && manifest.resolver_rows_to_append.length > 0) {
const resolverCandidates = ['RESOLVER.md', 'AGENTS.md', 'skills/RESOLVER.md', 'skills/AGENTS.md'];
let resolverPath: string | null = null;
for (const candidate of resolverCandidates) {
const candidatePath = join(resolvedTarget, candidate);
try {
fsStatSync(candidatePath);
resolverPath = candidatePath;
break;
} catch { /* not present */ }
}
if (resolverPath) {
const rowsBlock = `\n\n<!-- gbrain:agent-voice:resolver-rows -->\n` +
manifest.resolver_rows_to_append.map((r) => `- ${r}`).join('\n') +
'\n<!-- /gbrain:agent-voice:resolver-rows -->\n';
fsAppendFileSync(resolverPath, rowsBlock);
} else {
console.warn(
`[install] no RESOLVER.md or AGENTS.md in target repo; ` +
`add these rows manually:\n` +
manifest.resolver_rows_to_append.map((r) => ` ${r}`).join('\n'),
);
}
}
return { written: installedRecords.length, manifestPath };
}
async function cmdInstall(args: string[]): Promise<void> {
let recipeId: string | null = null;
const opts: InstallOpts = { target: '' };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--target' || arg === '-t') {
opts.target = args[++i];
} else if (arg === '--refresh') {
opts.refresh = true;
} else if (arg === '--overwrite') {
opts.overwrite = true;
} else if (arg === '--dry-run' || arg === '-n') {
opts.dryRun = true;
} else if (arg === '--auto') {
const mode = args[++i];
if (mode !== 'keep-mine' && mode !== 'take-theirs') {
console.error(`--auto must be 'keep-mine' or 'take-theirs', got: ${mode}`);
process.exit(2);
}
opts.autoMode = mode;
} else if (arg === '--help' || arg === '-h') {
console.log('Usage:');
console.log(' gbrain integrations install <recipe-id> --target <host-repo-path> [--overwrite] [--dry-run]');
console.log(' gbrain integrations install <recipe-id> --target <host-repo-path> --refresh [--auto keep-mine|take-theirs] [--dry-run]');
return;
} else if (!recipeId && !arg.startsWith('-')) {
recipeId = arg;
}
}
if (!recipeId) {
console.error('Usage: gbrain integrations install <recipe-id> --target <host-repo-path>');
process.exit(2);
}
if (!opts.target) {
opts.target = process.env.OPENCLAW_WORKSPACE || '';
if (!opts.target) {
console.error('--target <host-repo-path> required (or set $OPENCLAW_WORKSPACE)');
process.exit(2);
}
}
try {
if (opts.refresh) {
const { applied, manifestPath } = await refreshRecipeIntoHostRepo(recipeId, opts);
console.log(`[refresh] ${recipeId}: applied ${applied} changes to ${realpathSync(opts.target)}`);
console.log(`[refresh] manifest: ${manifestPath}`);
if (opts.dryRun) {
console.log('[refresh] DRY RUN — no files written.');
}
} else {
const { written, manifestPath } = await installRecipeIntoHostRepo(recipeId, opts);
console.log(`[install] ${recipeId}: copied ${written} files into ${realpathSync(opts.target)}`);
console.log(`[install] manifest: ${manifestPath}`);
if (!opts.dryRun) {
console.log('[install] next steps: see recipes/' + recipeId + '/install/post-install-hint.md');
}
}
} catch (err) {
console.error(`[install] FAIL: ${(err as Error).message}`);
process.exit(1);
}
}
export async function runIntegrations(args: string[]): Promise<void> {
const sub = args[0];
@@ -873,6 +1541,9 @@ export async function runIntegrations(args: string[]): Promise<void> {
case 'show':
cmdShow(subArgs);
break;
case 'install':
await cmdInstall(subArgs);
break;
case 'status':
cmdStatus(subArgs);
break;