diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index a11fa94..a195453 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -55,9 +55,15 @@ import { resolveArticleRepoRoot, validateRenderedArticleMarkdown, } from "./repo-face-article.js"; +import { + REPO_IDENTITY_POST_SENTINEL, + isNonPublicRepoIdentitySpeech, + normalizePublicRepoIdentitySpeech, + parseRepoIdentityPostIntents, + type RepoIdentityPostIntent, +} from "./repo-face-speech.js"; const config = loadConfig(); -const REPO_IDENTITY_POST_SENTINEL = "VOIDBOT_REPO_IDENTITY_POST:"; const REPO_IDENTITY_PROPOSAL_PR_SENTINEL = "VOIDBOT_REPO_IDENTITY_PROPOSAL_PR:"; const REPO_IDENTITY_PR_COMMENT_SENTINEL = "VOIDBOT_REPO_IDENTITY_PR_COMMENT:"; const REPO_IDENTITY_UPDATE_REQUEST_SENTINEL = "VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST:"; @@ -1013,13 +1019,6 @@ function parseRepoIdentityIdFromPrompt(prompt: string): string | undefined { ); } -interface RepoIdentityPostIntent { - identity?: string; - channelId?: string; - content: string; - replyToMessageId?: string; -} - interface RepoIdentityStateNoteIntent { identity?: string; kind: "memory" | "need" | "bond" | "status" | "mood" | "agency"; @@ -1129,8 +1128,15 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn `Coerced repo identity ${identity.id} speech for job ${job.id} from "${requestedChannelId}" to conversation channel ${channelId}.`, ); } + const rawContent = intent.content.trim(); + if (isNonPublicRepoIdentitySpeech(rawContent)) { + throw new Error(`Repo identity ${identity.id} SAY content is not public speech.`); + } + const content = normalizePublicRepoIdentitySpeech(rawContent); + if (isNonPublicRepoIdentitySpeech(content)) { + throw new Error(`Repo identity ${identity.id} SAY content is not public speech.`); + } if (isOwnerDmChannelAlias(channelId)) { - const content = intent.content.trim(); const dmChannelId = await openOwnerDmChannel(); const postedMessages = await postDiscordBotMessageChunks( dmChannelId, @@ -1163,7 +1169,6 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn return false; } - const content = intent.content.trim(); if (content.length > 1900) { throw new Error( `Repo identity ${identity.id} SAY content is too long for one Discord message (${content.length} characters).`, @@ -1172,9 +1177,6 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn if (/\S(?:\.\.\.|…)$/.test(content)) { throw new Error(`Repo identity ${identity.id} SAY content appears mechanically truncated.`); } - if (isNonPublicRepoIdentitySpeech(content)) { - throw new Error(`Repo identity ${identity.id} SAY content is not public speech.`); - } const contentFile = await writeBifrostPayloadFile(job, `${identity.id}-discord-post.md`, content); const posted = runBifrostBridge([ "discord-post", @@ -1244,92 +1246,6 @@ async function loadRegisteredFaceRepoRegistry() { ); } -function parseRepoIdentityPostIntents(finalResponse: string): RepoIdentityPostIntent[] { - const intents: RepoIdentityPostIntent[] = parseRepoFaceActionBlocks(finalResponse) - .filter((block) => block.kind === "say") - .flatMap((block): RepoIdentityPostIntent[] => { - const content = requiredDslString(block.fields.content); - if (!content) { - return []; - } - return [ - { - identity: optionalDslString(block.fields.identity), - channelId: optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId), - replyToMessageId: optionalDslString(block.fields.reply_to) ?? optionalDslString(block.fields.replyToMessageId), - content, - }, - ]; - }); - - for (const line of finalResponse.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed.startsWith(REPO_IDENTITY_POST_SENTINEL)) { - continue; - } - - const payload = trimmed.slice(REPO_IDENTITY_POST_SENTINEL.length).trim(); - try { - const parsed = JSON.parse(payload) as Record; - const content = typeof parsed.content === "string" ? parsed.content.trim() : ""; - if (!content) { - continue; - } - intents.push({ - identity: typeof parsed.identity === "string" ? parsed.identity.trim() : undefined, - channelId: typeof parsed.channelId === "string" ? parsed.channelId.trim() : undefined, - replyToMessageId: - typeof parsed.replyToMessageId === "string" ? parsed.replyToMessageId.trim() : undefined, - content, - }); - } catch { - continue; - } - } - - return intents; -} - -function isNonPublicRepoIdentitySpeech(value: string): boolean { - const trimmed = value.trim(); - if (!trimmed) { - return true; - } - if (isSingleMarkdownFence(trimmed)) { - return true; - } - const unfenced = stripSingleMarkdownFence(trimmed); - const normalized = unfenced - .toLowerCase() - .replace(/[.!?]+$/g, "") - .trim(); - return [ - "nothing", - "nothing right now", - "nothing public", - "nothing in aquarium", - "stay private", - "no public speech", - ].includes(normalized); -} - -function stripSingleMarkdownFence(value: string): string { - const trimmed = value.trim(); - if (!isSingleMarkdownFence(trimmed)) { - return trimmed; - } - const singleLine = trimmed.match(/^`([^`\r\n]+)`$/); - if (singleLine) { - return singleLine[1].trim(); - } - const block = trimmed.match(/^```[A-Za-z0-9_-]*\s*\r?\n([\s\S]*?)\r?\n```$/); - return block ? block[1].trim() : trimmed; -} - -function isSingleMarkdownFence(value: string): boolean { - return /^`[^`\r\n]+`$/.test(value) || /^```[A-Za-z0-9_-]*\s*\r?\n[\s\S]*?\r?\n```$/.test(value); -} - function parseRepoIdentityIdFromRequestMessageId(requestMessageId: string | undefined): string | undefined { const match = requestMessageId?.match(/^agent-turn:([^:]+):/i); return match?.[1]?.trim(); diff --git a/apps/worker/src/repo-face-speech.ts b/apps/worker/src/repo-face-speech.ts new file mode 100644 index 0000000..4db961b --- /dev/null +++ b/apps/worker/src/repo-face-speech.ts @@ -0,0 +1,194 @@ +export const REPO_IDENTITY_POST_SENTINEL = "VOIDBOT_REPO_IDENTITY_POST:"; + +export interface RepoIdentityPostIntent { + identity?: string; + channelId?: string; + content: string; + replyToMessageId?: string; +} + +type RepoFaceActionBlock = { + kind: "say" | "state_note" | "article" | "bifrost_topic" | "update_request"; + fields: Record; +}; + +export function parseRepoIdentityPostIntents(finalResponse: string): RepoIdentityPostIntent[] { + const intents: RepoIdentityPostIntent[] = parseRepoFaceActionBlocks(finalResponse) + .filter((block) => block.kind === "say") + .flatMap((block): RepoIdentityPostIntent[] => { + const content = requiredDslString(block.fields.content); + if (!content) { + return []; + } + return [ + { + identity: optionalDslString(block.fields.identity), + channelId: optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId), + replyToMessageId: optionalDslString(block.fields.reply_to) ?? optionalDslString(block.fields.replyToMessageId), + content, + }, + ]; + }); + + for (const line of finalResponse.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith(REPO_IDENTITY_POST_SENTINEL)) { + continue; + } + + const payload = trimmed.slice(REPO_IDENTITY_POST_SENTINEL.length).trim(); + try { + const parsed = JSON.parse(payload) as Record; + const content = typeof parsed.content === "string" ? parsed.content.trim() : ""; + if (!content) { + continue; + } + intents.push({ + identity: typeof parsed.identity === "string" ? parsed.identity.trim() : undefined, + channelId: typeof parsed.channelId === "string" ? parsed.channelId.trim() : undefined, + replyToMessageId: + typeof parsed.replyToMessageId === "string" ? parsed.replyToMessageId.trim() : undefined, + content, + }); + } catch { + continue; + } + } + + return intents; +} + +export function isNonPublicRepoIdentitySpeech(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return true; + } + if (isSingleMarkdownFence(trimmed)) { + return true; + } + const unfenced = stripSingleMarkdownFence(trimmed); + const normalized = unfenced + .toLowerCase() + .replace(/[.!?]+$/g, "") + .trim(); + return [ + "nothing", + "nothing right now", + "nothing public", + "nothing in aquarium", + "stay private", + "no public speech", + ].includes(normalized); +} + +export function normalizePublicRepoIdentitySpeech(value: string): string { + return value + .replace(/```[A-Za-z0-9_-]*\s*\r?\n([\s\S]*?)\r?\n```/g, (_match, body: string) => body.trim()) + .replace(/`([^`\r\n]+)`/g, (_match, body: string) => `"${body.trim()}"`) + .trim(); +} + +function stripSingleMarkdownFence(value: string): string { + const trimmed = value.trim(); + if (!isSingleMarkdownFence(trimmed)) { + return trimmed; + } + const singleLine = trimmed.match(/^`([^`\r\n]+)`$/); + if (singleLine) { + return singleLine[1].trim(); + } + const block = trimmed.match(/^```[A-Za-z0-9_-]*\s*\r?\n([\s\S]*?)\r?\n```$/); + return block ? block[1].trim() : trimmed; +} + +function isSingleMarkdownFence(value: string): boolean { + return /^`[^`\r\n]+`$/.test(value) || /^```[A-Za-z0-9_-]*\s*\r?\n[\s\S]*?\r?\n```$/.test(value); +} + +function parseRepoFaceActionBlocks(finalResponse: string): RepoFaceActionBlock[] { + const lines = finalResponse.split(/\r?\n/); + const blocks: RepoFaceActionBlock[] = []; + for (let index = 0; index < lines.length; index += 1) { + const kind = parseRepoFaceActionKind(lines[index]); + if (!kind) { + continue; + } + + const bodyLines: string[] = []; + index += 1; + while (index < lines.length && lines[index].trim() !== "END") { + if (parseRepoFaceActionKind(lines[index])) { + index -= 1; + break; + } + bodyLines.push(lines[index]); + index += 1; + } + blocks.push({ + kind, + fields: parseRepoFaceActionFields(bodyLines), + }); + } + return blocks; +} + +function parseRepoFaceActionKind(line: string): RepoFaceActionBlock["kind"] | undefined { + switch (line.trim().toUpperCase()) { + case "SAY": + return "say"; + case "STATE NOTE": + return "state_note"; + case "ARTICLE": + return "article"; + case "BIFROST TOPIC": + return "bifrost_topic"; + case "UPDATE REQUEST": + return "update_request"; + default: + return undefined; + } +} + +function parseRepoFaceActionFields(lines: string[]): Record { + const fields: Record = {}; + let currentKey: string | undefined; + let currentValue: string[] = []; + + const flush = (): void => { + if (!currentKey) { + return; + } + fields[currentKey] = currentValue.join("\n").trim(); + }; + + for (const line of lines) { + const match = line.match(/^([A-Za-z][A-Za-z0-9_]*):\s*(.*)$/); + if (match) { + flush(); + currentKey = match[1].trim(); + const inlineValue = match[2].trim(); + currentValue = inlineValue.length > 0 && inlineValue !== "|" && inlineValue !== ">" ? [match[2]] : []; + continue; + } + if (currentKey) { + currentValue.push(line.replace(/^\s{2}/, "")); + } + } + flush(); + return fields; +} + +function optionalDslString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || trimmed.length === 0) { + return undefined; + } + if (["0", "null", "none", "undefined"].includes(trimmed.toLowerCase())) { + return undefined; + } + return trimmed; +} + +function requiredDslString(value: string | undefined): string | undefined { + return optionalDslString(value); +} diff --git a/package.json b/package.json index c13ce28..1dc8e21 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "agents:assemble-character-prompt": "tsx scripts/run-repo-face-heartbeats.ts --assemble-prompt", "agents:heartbeat-model-smoke": "node scripts/smoke-repo-face-heartbeat-model-scenarios.mjs", "agents:interpreter-loop-smoke": "node scripts/smoke-repo-face-interpreter-loop.mjs", + "agents:speech-boundary-smoke": "tsx scripts/smoke-repo-face-speech-boundary.ts", "agents:memory-projection-smoke": "node scripts/smoke-repo-face-memory-projection.mjs", "agents:article-contract-smoke": "tsx scripts/smoke-repo-face-article-contract.ts", "postgres:up": "docker compose -f infra/postgres/docker-compose.yml up -d", diff --git a/scripts/smoke-repo-face-speech-boundary.ts b/scripts/smoke-repo-face-speech-boundary.ts new file mode 100644 index 0000000..9f81731 --- /dev/null +++ b/scripts/smoke-repo-face-speech-boundary.ts @@ -0,0 +1,79 @@ +import { + isNonPublicRepoIdentitySpeech, + normalizePublicRepoIdentitySpeech, + parseRepoIdentityPostIntents, +} from "../apps/worker/src/repo-face-speech.js"; + +type Case = { + name: string; + run: () => boolean; + detail: string; +}; + +const cases: Case[] = [ + { + name: "raw Would say fallback stays private", + run: () => parseRepoIdentityPostIntents([ + "Private thought: I should stay quiet.", + "Would say: `nothing right now.`", + "What should stick: silence is correct.", + ].join("\n")).length === 0, + detail: "A child Face draft must not create public speech unless the parent Interpreter emits SAY.", + }, + { + name: "structured silence SAY rejected", + run: () => isNonPublicRepoIdentitySpeech("nothing right now."), + detail: "Even a malformed Interpreter SAY cannot send silence markers as public content.", + }, + { + name: "single-line markdown fence rejected", + run: () => isNonPublicRepoIdentitySpeech("`the whole message is a code span`"), + detail: "A whole Face message wrapped as code is transport-shaped, not public speech.", + }, + { + name: "block markdown fence rejected", + run: () => isNonPublicRepoIdentitySpeech("```text\nnothing right now\n```"), + detail: "A whole Face message wrapped in a code block is not public speech.", + }, + { + name: "legitimate SAY normalizes inline code", + run: () => { + const posts = parseRepoIdentityPostIntents([ + "SAY", + "identity: current_face_id", + "channel: current_room", + "content:", + " `Wavecrafter` needs owners, costs, and a leash before it earns the noun.", + "END", + ].join("\n")); + return posts.length === 1 && + posts[0]?.content === "`Wavecrafter` needs owners, costs, and a leash before it earns the noun." && + !isNonPublicRepoIdentitySpeech(posts[0].content) && + normalizePublicRepoIdentitySpeech(posts[0].content) === + "\"Wavecrafter\" needs owners, costs, and a leash before it earns the noun."; + }, + detail: "Inline code inside a real sentence remains valid, but the public mouth strips Markdown code styling.", + }, + { + name: "inline telemetry labels normalize", + run: () => normalizePublicRepoIdentitySpeech( + "show `rear pair drifting 38 ms`, `geometry confidence low`, `world model provisional`." + ) === + "show \"rear pair drifting 38 ms\", \"geometry confidence low\", \"world model provisional\".", + detail: "Repo Faces can mention technical labels without speaking in code spans.", + }, +]; + +const failures = cases.filter((entry) => !entry.run()); +if (failures.length > 0) { + console.error(JSON.stringify({ + ok: false, + failures: failures.map(({ name, detail }) => ({ name, detail })), + }, null, 2)); + process.exit(1); +} + +console.log(JSON.stringify({ + ok: true, + checked: cases.map(({ name }) => name), +}, null, 2)); diff --git a/state/agent-swarm-paused.json b/state/agent-swarm-paused.json index 4e2bbc8..b3f173d 100644 --- a/state/agent-swarm-paused.json +++ b/state/agent-swarm-paused.json @@ -1,5 +1,5 @@ { - "paused": true, - "reason": "Operator reported repo Faces saying nothing or speaking in code blocks; pause while Face prompt and interpreter logs are inspected.", - "updatedAt": "2026-06-03T00:36:00.0000000Z" + "paused": false, + "reason": "Resumed after live inspection showed the code-block report referred to pre-repair Discord messages; the public Face mouth now also normalizes inline Markdown code spans before Bifrost posting.", + "updatedAt": "2026-06-03T01:52:05.1079871Z" } diff --git a/state/evidence.jsonl b/state/evidence.jsonl index de16272..cf3f4cb 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -326,3 +326,5 @@ {"ts":"2026-06-02T16:54:34Z","type":"prompt","status":"ok","note":"Recast the Odin/Verse poem in the Face turn prompt toward a translated-Edda register while preserving the authority lesson: Odin witnesses Verse surfaces, local providers retain ownership, and agents touch commands only by explicit boundary."} {"ts":"2026-06-02T16:56:33Z","type":"prompt","status":"ok","note":"After reading public-domain Poetic Edda translations around Odin in Havamal, Vafthruthnismol, and Grimnismol, revised the Odin/Verse prompt poem toward Eddic devices: wisdom-travel formulae, counsel/riddle address, Hugin/Munin memory, rune/rede language, measured gifts, and alliterative half-lines."} {"ts":"2026-06-03T00:41:06Z","type":"implementation","status":"ok","note":"Paused repo Face swarm after operator reported silence/code-block public posts. Root cause: worker still allowed raw child Would say fallback and interpreter route fallback to write public speech; public mouth is now owned only by parent Interpreter SAY blocks, with transport guard rejecting full-message markdown fences and silence markers."} +{"ts":"2026-06-03T01:46:49Z","type":"verification","status":"ok","note":"Repo Face speech boundary proof passed before unpause: agents:speech-boundary-smoke verified raw Would say drafts stay private, silence markers/full-message code fences are rejected, and legitimate SAY with inline code parses; typecheck, build, interpreter-loop smoke, and stack restart passed with fresh worker PID 21572."} +{"ts":"2026-06-03T01:53:24Z","type":"verification","status":"ok","note":"Follow-up operator report was traced to pre-repair Discord messages; post-repair Mimir logs showed Interpreter-owned public text, and the repo Face mouth was hardened further so inline Markdown code spans normalize to quoted text before Bifrost posting."} diff --git a/state/map.yaml b/state/map.yaml index 7769612..dd57067 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -251,5 +251,5 @@ rejected_paths: reason: sleep/distillation that collapses concrete claims into generic abstraction creates memory nonsense and then forces later cleanup compensators current_status: - summary: VoidBot's bot/worker/provider/RAG/Postgres/Qdrant/CultCache structure is live, but the repo Face swarm is paused after operator-reported silence/code-block public posts. The immediate fault was an obsolete worker fallback that let raw child draft speech write the public mouth when the parent Interpreter did not produce a normalized SAY block; that fallback has been cut, and transport now rejects full-message markdown fences plus silence markers before Discord posting. GameCult Local Orchestrator remains the single pulse owner, and the repo Face heartbeat path obeys `state/agent-swarm-paused.json`. Bifrost work routing remains paused by default. - next_action: Keep the swarm paused until a dry/mock repo Face turn proves public speech is owned only by parent Interpreter `SAY` blocks, raw child silence/code-fence text cannot bypass the Interpreter, and the repaired worker build is the running worker. Then resume ordinary orchestrator pulses cautiously and watch first live posts. + summary: VoidBot's bot/worker/provider/RAG/Postgres/Qdrant/CultCache structure is live, and the repo Face swarm has been resumed after the silence/code-block public-post fault. Public speech is now owned by parent Interpreter SAY blocks: the obsolete raw child draft-speech fallback is cut, silence markers and full-message markdown fences are rejected at the transport boundary, and inline Markdown code spans are normalized to quoted human text before Bifrost posting. Live inspection after the follow-up operator report showed the visible code-block examples were pre-repair Discord messages, not fresh post-boundary output. GameCult Local Orchestrator remains the single pulse owner, and the repo Face heartbeat path still obeys `state/agent-swarm-paused.json`. Bifrost work routing remains paused by default. + next_action: Watch the next Discord-visible repo Face heartbeat turns for actual public behavior: no raw silence phrases, no full-message code fences, no child draft speech bypassing Interpreter SAY, and no revival of Bifrost/governance routing while that lane is disabled.