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