Route repo Face introductions through worker

This commit is contained in:
Meta Vens 2026-05-18 17:33:02 +01:00
commit 6570349db4
4 changed files with 175 additions and 7 deletions

View file

@ -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<void> {
}
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<void> {
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<void> {
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<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 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<ReturnType<typeof findRepoDiscordIdentity>>;
channelId: string;
content: string;
replyToMessageId?: string;
messageId: string;
transport: "bot" | "webhook";
}): Promise<void> {
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<void> {

View file

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

View file

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

View file

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