Harden repo Face public speech boundary

This commit is contained in:
Meta Vens 2026-06-03 02:59:54 +01:00
commit 8f53f00a77
7 changed files with 296 additions and 104 deletions

View file

@ -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<string, unknown>;
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();

View file

@ -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<string, string>;
};
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<string, unknown>;
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<string, string> {
const fields: Record<string, string> = {};
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);
}

View file

@ -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",

View file

@ -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));

View file

@ -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"
}

View file

@ -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."}

View file

@ -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.