Fix repo Face collaboration speech routing

This commit is contained in:
Meta Vens 2026-06-04 17:19:03 +01:00
commit e5eca5d954
5 changed files with 146 additions and 3 deletions

View file

@ -314,6 +314,17 @@ export async function startBot(): Promise<void> {
client.on(Events.MessageCreate, async (message) => {
if (message.author.bot) {
await queueRepoFaceCollaborationHooksFromBotMessage({
message,
registry: repoDiscordIdentities,
statePath: config.repoFaceHeartbeats.statePath,
storageRoot: config.storageRoot,
sourceRepoRoot: config.sourceRepoRoot,
epiphanyAgentRoot: config.epiphanyAgentRoot,
workspaceRoot: process.cwd(),
birthMode: config.repoFaceBirthMode,
birthExecutor: config.repoFaceBirthExecutor,
});
return;
}
@ -801,6 +812,68 @@ function uniqueRepoIdentities(identities: RepoDiscordIdentity[]): RepoDiscordIde
return result;
}
async function queueRepoFaceCollaborationHooksFromBotMessage(input: {
message: Message;
registry: { identities: RepoDiscordIdentity[] };
statePath: string;
storageRoot: string;
sourceRepoRoot?: string;
epiphanyAgentRoot: string;
workspaceRoot: string;
birthMode: Parameters<typeof ensureRepoFaceInitialized>[0]["birthMode"];
birthExecutor: Parameters<typeof ensureRepoFaceInitialized>[0]["birthExecutor"];
}): Promise<void> {
if (!input.message.inGuild() || !isRepoFaceCollaborationHook(input.message.content)) {
return;
}
const addressed = uniqueRepoIdentities(findRepoDiscordIdentitiesByTextMentions(
input.registry,
stripBotMention(input.message.content),
input.message.channelId,
));
if (addressed.length === 0) {
return;
}
const speakerIdentity = findRepoDiscordIdentityByPersonaName(
input.registry,
input.message.author.username,
input.message.channelId,
);
for (const identity of addressed.filter((entry) => entry.id !== speakerIdentity?.id)) {
const faceInitialization = await ensureRepoFaceInitialized({
identity,
storageRoot: input.storageRoot,
sourceRepoRoot: input.sourceRepoRoot,
epiphanyAgentRoot: input.epiphanyAgentRoot,
workspaceRoot: input.workspaceRoot,
birthMode: input.birthMode,
birthExecutor: input.birthExecutor,
});
const queuedMention = await queueRepoFaceMention({
statePath: input.statePath,
identity,
channelId: input.message.channelId,
messageId: input.message.id,
authorId: input.message.author.id,
authorName: input.message.author.username,
content: input.message.content,
visiblePrompt:
`A public collaboration hook named ${identity.displayName}. Treat this as a live consultation or coauthorship obligation, not passive background: ${input.message.content}`,
});
console.log(
`Queued repo Face collaboration hook ${input.message.id} for ${identity.id} via CTB turn queue (${queuedMention.pendingCount} pending). Birth status: ${
faceInitialization.birthStatusPath ?? faceInitialization.skippedReason ?? "unknown"
}`,
);
}
}
function isRepoFaceCollaborationHook(content: string): boolean {
return /\b(consult(?:ation|ed|ing)?|co[- ]?author(?:ship|ed|ing)?|collab(?:oration|orate|orating)?|parallel byline|byline|stewardship|consensus check|ask(?:ed|ing)?|invite(?:d|s|ing)?)\b/i.test(content);
}
async function ensureRepoIdentityRoles(options: {
botToken: string;
guildId?: string;

View file

@ -574,6 +574,14 @@ async function executeRepoFaceJobWithInterpreter(
});
}
const firstObligationRoute = routeUnparsedWouldSay(firstResponse, firstInterpretation, {
job,
faceOutputText: firstText,
});
if (firstObligationRoute) {
return firstObligationRoute;
}
if (firstInterpretation.decision === "drop") {
return dropRepoFaceActionBlocks(firstResponse, firstInterpretation);
}
@ -627,6 +635,14 @@ async function executeRepoFaceJobWithInterpreter(
});
}
const retryObligationRoute = routeUnparsedWouldSay(retryResponse, retryInterpretation, {
job,
faceOutputText: retryText,
});
if (retryObligationRoute) {
return retryObligationRoute;
}
await auditLog.record({
type: "repo_face.parent_interpreter_drop",
actorId: job.requester.id,
@ -770,6 +786,59 @@ function routeRepoFaceInterpretedOutput(
};
}
function routeUnparsedWouldSay(
response: ProviderResponse,
interpretation: RepoFaceParentInterpretation,
input: { job: JobRecord; faceOutputText: string },
): ProviderResponse | undefined {
if (input.job.command !== "repo-face-rumination") {
return undefined;
}
if (!interpretation.reasons.some((reason) => /parseable reason|did not provide/i.test(reason))) {
return undefined;
}
const content = extractUnconditionalWouldSay(input.faceOutputText);
if (!content || isNonPublicRepoIdentitySpeech(content)) {
return undefined;
}
return routeRepoFaceInterpretedOutput(
response,
{
decision: "route",
reasons: [
"Face produced an unconditional Would say and the parent interpreter failed to parse a routing reason.",
],
routedOutput: [
"Parent interpreter fallback: routed unconditional Face Would say.",
"",
"SAY",
"identity: current_face_id",
"channel: current_room",
"reply_to:",
"content:",
...content.split(/\r?\n/).map((line) => ` ${line}`),
"END",
].join("\n"),
},
input,
);
}
function extractUnconditionalWouldSay(outputText: string): string | undefined {
const match = outputText.match(/(?:^|\n)Would say:\s*([\s\S]*?)(?=\n\n[A-Z][A-Za-z /-]{2,}:|\n\nWhat should stick:|\n\nWork\/proposal:|\n\nPrivate thought:|$)/i);
const content = match?.[1]?.trim();
if (!content) {
return undefined;
}
const normalized = content.toLowerCase().replace(/^["'`]+|["'`]+$/g, "").trim();
if (/^(nothing|nothing public|nothing right now|stay private|no public speech)\b/.test(normalized)) {
return undefined;
}
return content;
}
function normalizeInterpretedRepoFaceSpeechDestinations(
outputText: string,
input: { job: JobRecord; faceOutputText: string },

View file

@ -137,7 +137,7 @@ Write as one projection of the Colossus speaking to another. Keep the myth alive
Use this friendly shape when it helps, but do not turn it into a form:
- Private thought: what you learned, felt, or decided.
- Would say: the exact in-character Discord line you would want posted, if any.
- Would say: the exact in-character Discord line you want posted now. If you have nothing public to say, omit this line; do not draft a fake would-say or write a placeholder such as "nothing public."
- Work/proposal: the review, change request, or article idea you want remembered or openly discussed, if any.
- Article draft: if you actually want to write a bylined article now, include the title, site, description, intended announcement, and the full markdown body without YAML frontmatter.
- What should stick: any meaning-bearing memory, feeling, relationship read, or pressure you would want remembered.

View file

@ -12,13 +12,13 @@ type Case = {
const cases: Case[] = [
{
name: "raw Would say fallback stays private",
name: "raw silence Would say 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.",
detail: "A silence marker in a child Face draft must not create public speech.",
},
{
name: "structured silence SAY rejected",

View file

@ -335,3 +335,4 @@
{"ts":"2026-06-04T13:37:20Z","type":"implementation","status":"ok","note":"VoidBot worker now drains gamecult.operator_dm_request.v1 documents from the provider-owned CultMesh store and delivers pending owner.dm.send requests through the existing Discord owner-DM path, marking requests sent or failed in place. Eve provider documents are keyed, not global, so one store can hold both voidbot.swarm and voidbot.operator-dm."}
{"ts":"2026-06-04T13:42:16Z","type":"correction","status":"ok","note":"Corrected VoidBot operator-DM exposure: voidbot.operator-dm is a provider advertisement and command inbox, not an Eve dashboard/interface slot. Odin lists the capability in /eve/deck/providers while the Odin surface only lowers real provider interfaces such as voidbot.swarm."}
{"ts":"2026-06-04T15:55:31Z","type":"implementation","status":"ok","note":"Registered Discord #pics (758854103035674624) as a named media/pictures channel for all 16 repo Faces that have #general access, so Faces can see the channel and target SAY channel: pics for picture/screenshot/image/media talk instead of defaulting to #general."}
{"ts":"2026-06-04T16:18:39Z","type":"bugfix","status":"ok","note":"Fixed repo Face collaboration/speech ownership: bot-authored collaboration hooks that name registered Faces now queue CTB obligations instead of being discarded as bot chatter; parent worker fallback routes an unconditional Face 'Would say' when the Interpreter fails to parse, while silence markers still stay private. Replayed the missed Void->Nibu consultation hook; Nibu consumed it and explicitly chose no public speech after the fix."}