Route repo Face introductions through worker
This commit is contained in:
parent
a7604a4e3b
commit
6570349db4
4 changed files with 175 additions and 7 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
Loading…
Reference in a new issue