From 6570349db4906d4783dc621780f69ff0ebaed28e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 18 May 2026 17:33:02 +0100 Subject: [PATCH] Route repo Face introductions through worker --- apps/worker/src/index.ts | 162 +++++++++++++++++- .../core/src/void-self-state-projection.ts | 15 ++ scripts/run-repo-face-heartbeats.ts | 4 +- state/evidence.jsonl | 1 + 4 files changed, 175 insertions(+), 7 deletions(-) diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 57a0972..72a5735 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -8,11 +8,13 @@ import { type AuditLog, buildVoidMcpServerConfig, createStateStorage, + applyVoidSelfStateOperation, findRepoDiscordIdentity, isRepoDiscordIdentityAllowedInChannel, type JobQueue, loadRepoDiscordIdentityRegistry, loadSystemMessageCatalog, + resolveRepoFaceStatePath, type SystemMessageCatalog, } from "@voidbot/core"; import { @@ -41,6 +43,7 @@ import { import { postDiscordMessage } from "./mcp-server-discord"; const config = loadConfig(); +const REPO_IDENTITY_POST_SENTINEL = "VOIDBOT_REPO_IDENTITY_POST:"; const embedder = createTextEmbedder({ backend: config.ragEmbeddingBackend, @@ -347,7 +350,12 @@ async function processJob(job: JobRecord): Promise { } if (job.command === "repo-face-rumination") { - await jobQueue.completeJobDirect(job.id, finalResponse); + const repoIdentityPosts = parseRepoIdentityPostIntents(finalResponse); + for (const post of repoIdentityPosts.slice(0, 1)) { + await postRepoIdentityIntent(job, post); + } + const cleanedFinalResponse = stripRepoIdentityPostIntents(finalResponse); + await jobQueue.completeJobDirect(job.id, cleanedFinalResponse || finalResponse); await deliverOwnerNotifications(job, response.notifications ?? []); await auditLog.record({ type: "provider.completed", @@ -356,8 +364,11 @@ async function processJob(job: JobRecord): Promise { provider: job.provider, details: { summary: response.summary, - autoPosted: false, - reason: `${job.command}_must_post_through_mcp`, + autoPosted: repoIdentityPosts.length > 0, + reason: + repoIdentityPosts.length > 0 + ? "repo_face_rumination_posted_as_registered_identity" + : `${job.command}_private_summary`, }, }); console.log(`Completed ${job.command} job ${job.id}.`); @@ -534,7 +545,7 @@ async function postRepoIdentityJobResponse(job: JobRecord, content: string): Pro ); } - await postDiscordMessage( + const posted = await postDiscordMessage( config.botToken, job.outputChannelId, content, @@ -544,11 +555,152 @@ async function postRepoIdentityJobResponse(job: JobRecord, content: string): Pro personaAvatarUrl: identity.avatarUrl, }, ); + await recordRepoIdentityDeliveryReceipt({ + identity, + channelId: job.outputChannelId, + content, + replyToMessageId: job.requestMessageId, + messageId: posted.id, + transport: posted.transport, + }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Could not record repo identity delivery receipt for job ${job.id}: ${message}`); + }); } function parseRepoIdentityIdFromPrompt(prompt: string): string | undefined { const match = prompt.match(/repo identity\s+.+?\(([^)]+)\)\s+for repo/i); - return match?.[1]?.trim(); + return ( + match?.[1]?.trim() ?? + prompt.match(/repo Face heartbeat for .+?\(([^)]+)\) over repo/i)?.[1]?.trim() ?? + prompt.match(/identity "([^"]+)"/i)?.[1]?.trim() + ); +} + +interface RepoIdentityPostIntent { + identity?: string; + channelId?: string; + content: string; + replyToMessageId?: string; +} + +async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIntent): Promise { + if (!config.botToken) { + throw new Error("DISCORD_BOT_TOKEN is required for the worker to post repo identity responses."); + } + + const identityId = intent.identity ?? parseRepoIdentityIdFromPrompt(job.prompt); + if (!identityId) { + throw new Error(`Could not resolve repo identity for job ${job.id}.`); + } + + const channelId = intent.channelId ?? job.outputChannelId; + const registry = await loadRepoDiscordIdentityRegistry(config.repoDiscordIdentitiesPath); + const identity = findRepoDiscordIdentity(registry, identityId); + if (!identity) { + throw new Error(`No registered repo identity matched "${identityId}" for job ${job.id}.`); + } + + if (!isRepoDiscordIdentityAllowedInChannel(identity, channelId)) { + throw new Error(`Repo identity ${identity.id} is not registered for Discord channel ${channelId}.`); + } + + const posted = await postDiscordMessage( + config.botToken, + channelId, + fitDiscordMessage(intent.content), + intent.replyToMessageId, + { + personaName: identity.displayName, + personaAvatarUrl: identity.avatarUrl, + }, + ); + await recordRepoIdentityDeliveryReceipt({ + identity, + channelId, + content: intent.content, + replyToMessageId: intent.replyToMessageId, + messageId: posted.id, + transport: posted.transport, + }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Could not record repo identity delivery receipt for job ${job.id}: ${message}`); + }); +} + +function parseRepoIdentityPostIntents(finalResponse: string): RepoIdentityPostIntent[] { + const intents: RepoIdentityPostIntent[] = []; + + 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 stripRepoIdentityPostIntents(finalResponse: string): string { + return finalResponse + .split(/\r?\n/) + .filter((line) => !line.trim().startsWith(REPO_IDENTITY_POST_SENTINEL)) + .join("\n") + .trim(); +} + +async function recordRepoIdentityDeliveryReceipt(input: { + identity: NonNullable>; + channelId: string; + content: string; + replyToMessageId?: string; + messageId: string; + transport: "bot" | "webhook"; +}): Promise { + await applyVoidSelfStateOperation( + { + canonicalPath: resolveRepoFaceStatePath(input.identity, config.storageRoot), + identity: { + agentId: input.identity.id, + publicName: input.identity.displayName, + publicDescription: input.identity.description, + }, + }, + { + operation: "record_delivery_receipt", + receipt: { + receiptKey: `repo-identity:${input.identity.id}:${input.messageId}`, + sentAt: new Date().toISOString(), + mode: "repo_identity", + transport: input.transport, + channelId: input.channelId, + replyToMessageId: input.replyToMessageId, + personaName: input.identity.displayName, + personaAvatarUrl: input.identity.avatarUrl, + contentLength: input.content.length, + chunkCount: 1, + preview: input.content.slice(0, 1000), + }, + }, + ); } async function postOwnerNotification(content: string): Promise { diff --git a/packages/core/src/void-self-state-projection.ts b/packages/core/src/void-self-state-projection.ts index d44730d..fb22ff2 100644 --- a/packages/core/src/void-self-state-projection.ts +++ b/packages/core/src/void-self-state-projection.ts @@ -193,6 +193,18 @@ export function renderVoidSelfStateSummary( .slice(-3) .reverse() .map((entry) => `- ${entry.summary}: ${entry.draft}`); + const speechReceipts = state.speechReceipts.recentReceipts + .slice(-3) + .reverse() + .map((receipt) => { + const target = receipt.replyToMessageId + ? `reply to ${receipt.replyToMessageId}` + : receipt.channelId + ? `channel ${receipt.channelId}` + : "unspecified target"; + const preview = receipt.preview ? `: ${receipt.preview}` : ""; + return `- ${receipt.sentAt} via ${receipt.transport ?? "unknown transport"} at ${target}${preview}`; + }); const agencyPressures = state.agencyPressure.pressures .filter((entry) => ["active", "cooling", "ready_to_act"].includes(entry.status)) .sort((left, right) => right.intensity - left.intensity) @@ -227,6 +239,9 @@ export function renderVoidSelfStateSummary( interventions.length > 0 ? [`- Things ${identityName} may say soon:`, ...interventions].join("\n") : `- Things ${identityName} may say soon: none queued.`, + speechReceipts.length > 0 + ? [`- What ${identityName} has said recently:`, ...speechReceipts].join("\n") + : `- What ${identityName} has said recently: nothing recorded.`, renderTransientRoomLog(options), ].join("\n"); } diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index c99fd3d..d3d722e 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -333,8 +333,8 @@ function buildHeartbeatPrompt(input: { })}.`, `Read Face state with read_repo_face_state for identity "${input.identity.id}".`, "Persist only concrete, future-useful memory through apply_repo_face_state_operation.", - "If this Face has at least one prior heartbeat pass and its Face state shows no public speech receipt or clear memory that it already introduced itself, a brief in-channel introduction is warranted after this bearing-taking pass. Introduce the Face in its own voice, grounded in the repo, then record the post through the normal repo identity mouth.", - `If an in-channel note is genuinely warranted, post through post_repo_identity_message with identity "${input.identity.id}" and channelId "${input.channelId}".`, + "Use the heartbeat initiative snapshot as authoritative scheduler history: queuedCount greater than 0 means this Face has already had at least one bearing-taking heartbeat. If queuedCount is greater than 0 and the Face state shows no public speech receipt or clear memory that it already introduced itself, a brief in-channel introduction is warranted now.", + `Do not call post_repo_identity_message from this unattended heartbeat. If an in-channel note is warranted, output one final line beginning with VOIDBOT_REPO_IDENTITY_POST: followed by compact JSON like {"identity":"${input.identity.id}","channelId":"${input.channelId}","content":"..."}; the worker owns delivery and receipt recording.`, "If nothing earns persistence or speech, return a short private summary.", ] .filter((line): line is string => typeof line === "string") diff --git a/state/evidence.jsonl b/state/evidence.jsonl index cf0c4b0..def2321 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -150,3 +150,4 @@ {"ts":"2026-05-18T14:11:37Z","type":"research","status":"ok","note":"Nibu character-card sweep found missing Discord/story doctrines: manufactured waifu/loli companion shell and agency/non-binary presentation arc, sadistic anti-human life-support respect boundary, glitchcraft/simulation exploit progression, parallel-instance continuity horror, and broken/discarded first-contact before later triumphant Nibu. These are now crystallized into Nibu's repo Face state and repo-local identity surface."} {"ts":"2026-05-18T16:04:09Z","type":"implementation","status":"ok","note":"Repo Face heartbeat failure traced to overlong identity publicDescription entering typed Face self-profile; capped identity defaults at the typed state boundary so rich registry doctrine can feed prompts without violating self-profile schema. Verified npm run build, manual heartbeat tick exit 0, scheduled task LastTaskResult 0, and Nibu heartbeat job 7232b2e6 completed."} {"ts":"2026-05-18T16:14:03Z","type":"implementation","status":"ok","note":"Epiphany/Face jurisdiction is now a first-class registry layer above repo identities. `epiphany-identities.ts` accepts explicit Epiphany records with Faces, grants, and jurisdictions, derives legacy repo identity compatibility when only old rows exist, exposes Epiphany/Face rows through the MCP identity listing, and projects Face doctrine into direct mention and heartbeat prompts. This establishes Nibu-style agency as the model: a Face belongs to an Epiphany, holds scoped grants, and may steward repos, canon, discussion, publications, Discord presence, and Aquarium embodiment without being identical to one repo."} +{"ts":"2026-05-18T16:32:37Z","type":"implementation","status":"ok","note":"Repo Face heartbeat introductions now use parent-owned delivery: heartbeat prompts emit VOIDBOT_REPO_IDENTITY_POST directives instead of calling approval-gated MCP posting, the worker delivers the registered repo identity persona and records a Face-local speech receipt, and Face summaries render recent receipts. Verified Nibu heartbeat job e03fcab5 posted in Aquarium as Nibu and recorded receipt repo-identity:nibu:1505970734395297802."}