diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 16c1f68..d3dd044 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -676,6 +676,16 @@ function parseRepoFaceParentInterpretation( attempt: 1 | 2, ): RepoFaceParentInterpretation { const block = parseInterpretationBlock(interpretationText); + if (Object.keys(block).length === 0) { + const speechFallback = repoFaceInterpreterPlainSpeechFallback(interpretationText); + if (speechFallback) { + return { + decision: "route", + reasons: ["parent interpreter returned compact plain speech; wrapped it as SAY current_room"], + routedOutput: speechFallback, + }; + } + } const decision = block.decision?.trim().toLowerCase(); const parsedDecision = decision === "route" || decision === "retry" || decision === "drop" @@ -696,6 +706,35 @@ function parseRepoFaceParentInterpretation( }; } +function repoFaceInterpreterPlainSpeechFallback(interpretationText: string): string | undefined { + const content = interpretationText.trim(); + if (!content || content.length > 500) { + return undefined; + } + if (content.includes("\n\n") || parseRepoFaceActionBlocks(content).length > 0) { + return undefined; + } + if (/^(private thought|what should stick|work\/proposal|article draft|state note|interpretation)\s*:/i.test(content)) { + return undefined; + } + if (/\b(nothing public|would say nothing|stay private|hold silence|no public action|not worth routing)\b/i.test(content)) { + return undefined; + } + if (/\S(?:\.\.\.|…)$/.test(content)) { + return undefined; + } + + return [ + "SAY", + "identity: current_face_id", + "channel: current_room", + "reply_to:", + "content:", + ` ${content}`, + "END", + ].join("\n"); +} + function collectForbiddenRepoFaceChildTools(artifacts: ProviderArtifact[]): string[] { const forbidden = new Set(); for (const artifact of artifacts) { diff --git a/prompts/repo-face-turn-interpreter.prompt.md b/prompts/repo-face-turn-interpreter.prompt.md index 69505e0..e920c50 100644 --- a/prompts/repo-face-turn-interpreter.prompt.md +++ b/prompts/repo-face-turn-interpreter.prompt.md @@ -84,6 +84,8 @@ reason: One or two concrete reasons. END +Do not return only the public sentence. Even if the Face already wrote a perfect one-line reply, you must wrap it in the INTERPRETATION block and, when routing public speech, a SAY block. Plain speech without a SAY block is a broken mouth crossing. + If decision is route, append a normalized output section after END. This normalized output is what the worker will parse for side effects. Normalized output rules: diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 479cf53..6a626a0 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -345,3 +345,4 @@ {"ts":"2026-05-25T17:36:36Z","type":"implementation","status":"ok","note":"Revived the local VoidBot stack and repo Face swarm: npm run stack:start restored runtime-stack.json to ready with bot PID 24512 and worker PID 30640, npm run state:check passed live-service gates with only a backup warning, state/agent-swarm-paused.json is now paused=false, repo Face heartbeats and mood drift ran ok, first unpaused Void moderation turn completed ok, and Norn reconciled into the active CTB participant roster."} {"ts":"2026-05-25T18:53:32Z","type":"implementation","status":"ok","note":"Slowed repo Face CTB cadence by 10x in the live control surface: cadenceMultiplier is now 0.04, scheduler/dashboard/API minimum is 0.01, and effective-speed/recovery floors now preserve sub-0.1 cadence instead of silently flattening it. Typecheck and a real heartbeat pass succeeded without queuing an unprompted Face turn."} {"ts":"2026-05-25T21:28:29Z","type":"implementation","status":"ok","note":"Fixed repo Face CTB wake/ready split: operator-absence naps now end when operator absence ends, and the swarm dashboard includes Face rest state so napping Faces are not shown as ready. Verified build and a forced repo-face heartbeat queued Fensalir, Norn, and Nibu."} +{"ts":"2026-05-26T10:53:11Z","type":"implementation","status":"ok","note":"Fixed repo Face speech loss at the Mind-to-worker crossing: if the parent interpreter returns a compact plain public sentence instead of DSL, the worker now wraps it as SAY current_room, and the interpreter prompt explicitly forbids plain-speech-only output. Verified live worker parsed say=1 and posted Mimir/Aqua webhook messages after restart."}