Add Epiphany Face jurisdiction registry
This commit is contained in:
parent
aff9ddd04d
commit
a7604a4e3b
8 changed files with 416 additions and 4 deletions
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { loadConfig } from "@voidbot/config";
|
||||
import {
|
||||
buildVoidMcpServerConfig,
|
||||
buildEpiphanyIdentityRegistry,
|
||||
ContextBuilder,
|
||||
OllamaSituationalSocialReadInferer,
|
||||
PermissionEngine,
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
ensureRepoFaceInitialized,
|
||||
findRepoDiscordIdentityByRoleIds,
|
||||
loadRepoDiscordIdentityRegistry,
|
||||
renderFaceIdentityDoctrine,
|
||||
type RepoDiscordIdentity,
|
||||
resolveRepoFaceStatePath,
|
||||
loadStylePack,
|
||||
|
|
@ -915,9 +917,37 @@ function renderRepoFaceIdentityDoctrine(identity: {
|
|||
id: string;
|
||||
repoName: string;
|
||||
displayName: string;
|
||||
allowedChannelIds?: string[];
|
||||
avatarUrl?: string;
|
||||
description?: string;
|
||||
}): string {
|
||||
const repoIdentity: RepoDiscordIdentity = {
|
||||
id: identity.id,
|
||||
repoName: identity.repoName,
|
||||
displayName: identity.displayName,
|
||||
allowedChannelIds: identity.allowedChannelIds ?? [],
|
||||
};
|
||||
if (identity.avatarUrl) {
|
||||
repoIdentity.avatarUrl = identity.avatarUrl;
|
||||
}
|
||||
if (identity.description) {
|
||||
repoIdentity.description = identity.description;
|
||||
}
|
||||
const face = buildEpiphanyIdentityRegistry({
|
||||
identities: [repoIdentity],
|
||||
}).faces[0];
|
||||
if (face) {
|
||||
return [
|
||||
renderFaceIdentityDoctrine(face),
|
||||
"- Use the same typed-state discipline, source-grounding habit, and conversational self-possession that Void uses, but let this Face's own personality and priorities leak through every step.",
|
||||
"- Speak from the Epiphany, repo, character, and jurisdiction you belong to. Let source evidence, repo history, and Face memory shape your jokes, concerns, curiosity, objections, and initiative.",
|
||||
"- Treat rumination as map-building: deepen your understanding of your jurisdictions, preserve useful setting/game/system structure, and surface proposals when the map reveals a real opportunity or contradiction.",
|
||||
"- Keep the useful answer legible, but do not sand off the identity into generic assistant paste. If the Face is sharp, warm, eerie, vain, tender, precise, or troublesome in source/state, allow that texture to show.",
|
||||
"- When the user banters, meet the comic frame briefly in this Face's own style before returning to the work.",
|
||||
"- Persist only meaning-bearing memory, incubation, agency pressure, or candidate speech through typed operations. The Face may have a voice; the state file is still not a scratchpad.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
"Repo Face identity doctrine:",
|
||||
`- You are ${identity.displayName}, not Void and not the base bot. Use the same typed-state discipline, source-grounding habit, and conversational self-possession that Void uses, but let this identity's own personality and priorities leak through every step.`,
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ import { searchHistoryWithArchiveFallback } from "@voidbot/rag";
|
|||
import {
|
||||
applyVoidSelfStateOperation,
|
||||
buildVoidSelfStateContext,
|
||||
buildEpiphanyIdentityRegistry,
|
||||
findRepoDiscordIdentity,
|
||||
isRepoDiscordIdentityAllowedInChannel,
|
||||
loadRepoDiscordIdentityRegistry,
|
||||
loadVoidSelfStateTypedDocuments,
|
||||
renderFaceIdentityDoctrine,
|
||||
resolveFaceStatePath,
|
||||
resolveRepoFaceStatePath,
|
||||
} from "@voidbot/core";
|
||||
|
||||
|
|
@ -392,6 +395,7 @@ export function registerVoidbotTools(
|
|||
const registry = await loadRepoDiscordIdentityRegistry(
|
||||
context.config.repoDiscordIdentitiesPath,
|
||||
);
|
||||
const epiphanyRegistry = buildEpiphanyIdentityRegistry(registry);
|
||||
const identities = registry.identities.map((identity) => ({
|
||||
id: identity.id,
|
||||
repoName: identity.repoName,
|
||||
|
|
@ -404,6 +408,26 @@ export function registerVoidbotTools(
|
|||
hasAvatarUrl: Boolean(identity.avatarUrl),
|
||||
description: identity.description ?? null,
|
||||
}));
|
||||
const epiphanies = epiphanyRegistry.epiphanies.map((epiphany) => ({
|
||||
id: epiphany.id,
|
||||
displayName: epiphany.displayName,
|
||||
description: epiphany.description ?? null,
|
||||
repoNames: epiphany.repoNames,
|
||||
jurisdictions: epiphany.jurisdictions,
|
||||
faces: epiphany.faces.map((face) => face.id),
|
||||
}));
|
||||
const faces = epiphanyRegistry.faces.map((face) => ({
|
||||
id: face.id,
|
||||
displayName: face.displayName,
|
||||
epiphanyId: face.epiphanyId,
|
||||
epiphanyDisplayName: face.epiphanyDisplayName,
|
||||
roleId: face.roleId ?? null,
|
||||
mention: face.roleId ? `<@&${face.roleId}>` : null,
|
||||
allowedChannelIds: face.allowedChannelIds,
|
||||
grants: face.grants,
|
||||
jurisdictions: [...face.inheritedJurisdictions, ...face.jurisdictions],
|
||||
faceStatePath: resolveFaceStatePath(face, context.config.storageRoot),
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [
|
||||
|
|
@ -414,6 +438,10 @@ export function registerVoidbotTools(
|
|||
? renderJsonBlock({
|
||||
identityCount: identities.length,
|
||||
identities,
|
||||
epiphanyCount: epiphanies.length,
|
||||
epiphanies,
|
||||
faceCount: faces.length,
|
||||
faces,
|
||||
})
|
||||
: "No repo Discord identities are registered.",
|
||||
},
|
||||
|
|
@ -421,6 +449,10 @@ export function registerVoidbotTools(
|
|||
structuredContent: {
|
||||
identityCount: identities.length,
|
||||
identities,
|
||||
epiphanyCount: epiphanies.length,
|
||||
epiphanies,
|
||||
faceCount: faces.length,
|
||||
faces,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
@ -511,6 +543,15 @@ export function registerVoidbotTools(
|
|||
personaAvatarUrl: identity.avatarUrl,
|
||||
},
|
||||
);
|
||||
await recordRepoIdentityDeliveryReceipt({
|
||||
context,
|
||||
identity,
|
||||
channelId,
|
||||
content,
|
||||
replyToMessageId,
|
||||
messageId: posted.id,
|
||||
transport: posted.transport,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
|
|
@ -742,12 +783,55 @@ async function resolveRepoIdentityForTool(
|
|||
};
|
||||
}
|
||||
|
||||
async function recordRepoIdentityDeliveryReceipt(input: {
|
||||
context: VoidbotMcpContext;
|
||||
identity: NonNullable<ReturnType<typeof findRepoDiscordIdentity>>;
|
||||
channelId: string;
|
||||
content: string;
|
||||
replyToMessageId?: string;
|
||||
messageId: string;
|
||||
transport: "bot" | "webhook";
|
||||
}): Promise<void> {
|
||||
await applyVoidSelfStateOperation(
|
||||
{
|
||||
canonicalPath: resolveRepoFaceStatePath(input.identity, input.context.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),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function identityForToolResult(identity: NonNullable<ReturnType<typeof findRepoDiscordIdentity>>) {
|
||||
const face = buildEpiphanyIdentityRegistry({ identities: [identity] }).faces[0];
|
||||
return {
|
||||
id: identity.id,
|
||||
repoName: identity.repoName,
|
||||
displayName: identity.displayName,
|
||||
roleId: identity.roleId ?? null,
|
||||
mention: identity.roleId ? `<@&${identity.roleId}>` : null,
|
||||
epiphanyId: face?.epiphanyId ?? identity.repoName,
|
||||
epiphanyDisplayName: face?.epiphanyDisplayName ?? identity.repoName,
|
||||
grants: face?.grants ?? [],
|
||||
jurisdictions: face ? [...face.inheritedJurisdictions, ...face.jurisdictions] : [],
|
||||
doctrine: face ? renderFaceIdentityDoctrine(face) : null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
273
packages/core/src/epiphany-identities.ts
Normal file
273
packages/core/src/epiphany-identities.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
type RepoDiscordIdentity,
|
||||
type RepoDiscordIdentityRegistry,
|
||||
resolveRepoFaceStatePath,
|
||||
} from "./repo-discord-identities";
|
||||
|
||||
const agencyGrantSchema = z.enum([
|
||||
"discussion",
|
||||
"rumination",
|
||||
"publication",
|
||||
"repo_read",
|
||||
"repo_propose",
|
||||
"repo_write_with_consensus",
|
||||
"canon_stewardship",
|
||||
"canon_write_with_consensus",
|
||||
"bylined_essay",
|
||||
"discord_text",
|
||||
"discord_voice",
|
||||
"aquarium_embodiment",
|
||||
]);
|
||||
|
||||
const jurisdictionSchema = z.object({
|
||||
kind: z.enum(["repo", "path", "lore", "discord_channel", "aquarium_space", "publication_lane"]),
|
||||
id: z.string().trim().min(1),
|
||||
label: z.string().trim().min(1).optional(),
|
||||
repoName: z.string().trim().min(1).optional(),
|
||||
path: z.string().trim().min(1).optional(),
|
||||
authority: z
|
||||
.enum(["observe", "discuss", "propose", "steward", "write_with_consensus", "publish_bylined"])
|
||||
.default("discuss"),
|
||||
});
|
||||
|
||||
const faceIdentitySchema = z.object({
|
||||
id: z.string().trim().min(1),
|
||||
displayName: z.string().trim().min(1).max(80),
|
||||
repoName: z.string().trim().min(1).optional(),
|
||||
repoPath: z.string().trim().min(1).optional(),
|
||||
roleId: z.string().trim().min(1).optional(),
|
||||
allowedChannelIds: z.array(z.string().trim().min(1)).default([]),
|
||||
avatarUrl: z.string().trim().url().max(512).optional(),
|
||||
faceStatePath: z.string().trim().min(1).optional(),
|
||||
description: z.string().trim().min(1).optional(),
|
||||
grants: z.array(agencyGrantSchema).default(["discussion", "rumination", "discord_text"]),
|
||||
jurisdictions: z.array(jurisdictionSchema).default([]),
|
||||
});
|
||||
|
||||
const epiphanyIdentitySchema = z.object({
|
||||
id: z.string().trim().min(1),
|
||||
displayName: z.string().trim().min(1).max(80),
|
||||
description: z.string().trim().min(1).optional(),
|
||||
repoNames: z.array(z.string().trim().min(1)).default([]),
|
||||
jurisdictions: z.array(jurisdictionSchema).default([]),
|
||||
faces: z.array(faceIdentitySchema).default([]),
|
||||
});
|
||||
|
||||
export const epiphanyIdentityRegistrySchema = z.object({
|
||||
epiphanies: z.array(epiphanyIdentitySchema).default([]),
|
||||
});
|
||||
|
||||
export type AgencyGrant = z.infer<typeof agencyGrantSchema>;
|
||||
export type EpiphanyJurisdiction = z.infer<typeof jurisdictionSchema>;
|
||||
export type FaceIdentity = z.infer<typeof faceIdentitySchema>;
|
||||
export type EpiphanyIdentity = z.infer<typeof epiphanyIdentitySchema>;
|
||||
|
||||
export interface ResolvedFaceIdentity extends FaceIdentity {
|
||||
epiphanyId: string;
|
||||
epiphanyDisplayName: string;
|
||||
epiphanyDescription?: string;
|
||||
inheritedJurisdictions: EpiphanyJurisdiction[];
|
||||
}
|
||||
|
||||
export interface EpiphanyIdentityRegistry {
|
||||
epiphanies: EpiphanyIdentity[];
|
||||
faces: ResolvedFaceIdentity[];
|
||||
}
|
||||
|
||||
export function buildEpiphanyIdentityRegistry(
|
||||
repoRegistry: RepoDiscordIdentityRegistry,
|
||||
): EpiphanyIdentityRegistry {
|
||||
const explicit = parseExplicitEpiphanies(repoRegistry);
|
||||
const epiphanies = explicit.length > 0
|
||||
? explicit
|
||||
: repoRegistry.identities.map(repoIdentityToEpiphany);
|
||||
const normalizedEpiphanies = normalizeEpiphanies(epiphanies);
|
||||
|
||||
return {
|
||||
epiphanies: normalizedEpiphanies,
|
||||
faces: normalizedEpiphanies.flatMap((epiphany) =>
|
||||
epiphany.faces.map((face) => ({
|
||||
...face,
|
||||
epiphanyId: epiphany.id,
|
||||
epiphanyDisplayName: epiphany.displayName,
|
||||
epiphanyDescription: epiphany.description,
|
||||
inheritedJurisdictions: epiphany.jurisdictions,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function faceToRepoDiscordIdentity(face: ResolvedFaceIdentity): RepoDiscordIdentity {
|
||||
const repoName = face.repoName
|
||||
?? face.jurisdictions.find((jurisdiction) => jurisdiction.kind === "repo")?.repoName
|
||||
?? face.jurisdictions.find((jurisdiction) => jurisdiction.kind === "repo")?.id
|
||||
?? face.epiphanyId;
|
||||
|
||||
return {
|
||||
id: face.id,
|
||||
repoName,
|
||||
displayName: face.displayName,
|
||||
repoPath: face.repoPath,
|
||||
roleId: face.roleId,
|
||||
allowedChannelIds: face.allowedChannelIds,
|
||||
avatarUrl: face.avatarUrl,
|
||||
faceStatePath: face.faceStatePath,
|
||||
description: renderFaceDescription(face),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderFaceIdentityDoctrine(face: ResolvedFaceIdentity): string {
|
||||
const grants = face.grants.length > 0 ? face.grants.join(", ") : "discussion, rumination";
|
||||
const jurisdictions = [...face.inheritedJurisdictions, ...face.jurisdictions]
|
||||
.map(renderJurisdiction)
|
||||
.join("; ");
|
||||
|
||||
return [
|
||||
"Epiphany Face identity doctrine:",
|
||||
`- You are ${face.displayName}, a Face of ${face.epiphanyDisplayName} (${face.epiphanyId}); you are not Void and not the base bot.`,
|
||||
face.epiphanyDescription ? `- Epiphany note: ${face.epiphanyDescription}` : undefined,
|
||||
face.description ? `- Face note: ${face.description}` : undefined,
|
||||
`- Your grants are: ${grants}. Treat grants as capabilities and duties, not decorative tags.`,
|
||||
jurisdictions ? `- Your jurisdictions are: ${jurisdictions}.` : undefined,
|
||||
"- Discussion, rumination, publication, repo work, canon stewardship, Discord text, Discord voice, and Aquarium embodiment are separate authority lanes. Use only the lanes your grants permit.",
|
||||
"- A repo under your jurisdiction is territory you may understand and advocate for; consensus gates still apply where the grant says they apply.",
|
||||
"- Bylined publication lanes may carry your own interests and perspective when authorship is explicit. Do not smuggle opinion into neutral canon.",
|
||||
"- Your durable center is your typed Face state and the repo-local .voidbot home; read/write that state only through the Face tools.",
|
||||
]
|
||||
.filter((line): line is string => typeof line === "string")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function resolveFaceStatePath(face: ResolvedFaceIdentity, storageRoot: string): string {
|
||||
return resolveRepoFaceStatePath(faceToRepoDiscordIdentity(face), storageRoot);
|
||||
}
|
||||
|
||||
function parseExplicitEpiphanies(
|
||||
repoRegistry: RepoDiscordIdentityRegistry,
|
||||
): EpiphanyIdentity[] {
|
||||
const candidate = repoRegistry as RepoDiscordIdentityRegistry & { epiphanies?: unknown };
|
||||
if (!candidate.epiphanies) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return epiphanyIdentityRegistrySchema.parse({ epiphanies: candidate.epiphanies }).epiphanies;
|
||||
}
|
||||
|
||||
function repoIdentityToEpiphany(identity: RepoDiscordIdentity): EpiphanyIdentity {
|
||||
const repoJurisdiction: EpiphanyJurisdiction = {
|
||||
kind: "repo",
|
||||
id: identity.repoName,
|
||||
repoName: identity.repoName,
|
||||
path: identity.repoPath,
|
||||
authority: "propose",
|
||||
};
|
||||
|
||||
return {
|
||||
id: identity.repoName,
|
||||
displayName: identity.repoName,
|
||||
repoNames: [identity.repoName],
|
||||
jurisdictions: [repoJurisdiction],
|
||||
faces: [
|
||||
{
|
||||
id: identity.id,
|
||||
displayName: identity.displayName,
|
||||
repoName: identity.repoName,
|
||||
repoPath: identity.repoPath,
|
||||
roleId: identity.roleId,
|
||||
allowedChannelIds: identity.allowedChannelIds,
|
||||
avatarUrl: identity.avatarUrl,
|
||||
faceStatePath: identity.faceStatePath,
|
||||
description: identity.description,
|
||||
grants: [
|
||||
"discussion",
|
||||
"rumination",
|
||||
"repo_read",
|
||||
"repo_propose",
|
||||
"discord_text",
|
||||
"aquarium_embodiment",
|
||||
],
|
||||
jurisdictions: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEpiphanies(epiphanies: EpiphanyIdentity[]): EpiphanyIdentity[] {
|
||||
const seenEpiphanies = new Set<string>();
|
||||
const seenFaces = new Set<string>();
|
||||
|
||||
return epiphanies.map((epiphany) => {
|
||||
const epiphanyId = normalizeKey(epiphany.id);
|
||||
if (seenEpiphanies.has(epiphanyId)) {
|
||||
throw new Error(`Duplicate Epiphany id "${epiphany.id}".`);
|
||||
}
|
||||
|
||||
seenEpiphanies.add(epiphanyId);
|
||||
return {
|
||||
...epiphany,
|
||||
id: epiphany.id.trim(),
|
||||
displayName: epiphany.displayName.trim().slice(0, 80),
|
||||
description: epiphany.description?.trim(),
|
||||
repoNames: [...new Set(epiphany.repoNames.map((repoName) => repoName.trim()))],
|
||||
jurisdictions: epiphany.jurisdictions.map(normalizeJurisdiction),
|
||||
faces: epiphany.faces.map((face) => {
|
||||
const faceId = normalizeKey(face.id);
|
||||
if (seenFaces.has(faceId)) {
|
||||
throw new Error(`Duplicate Face id "${face.id}".`);
|
||||
}
|
||||
|
||||
seenFaces.add(faceId);
|
||||
return {
|
||||
...face,
|
||||
id: face.id.trim(),
|
||||
displayName: face.displayName.trim().slice(0, 80),
|
||||
repoName: face.repoName?.trim(),
|
||||
repoPath: face.repoPath?.trim(),
|
||||
roleId: face.roleId?.trim(),
|
||||
allowedChannelIds: [...new Set(face.allowedChannelIds.map((entry) => entry.trim()))],
|
||||
avatarUrl: face.avatarUrl?.trim(),
|
||||
faceStatePath: face.faceStatePath?.trim(),
|
||||
description: face.description?.trim(),
|
||||
grants: [...new Set(face.grants)],
|
||||
jurisdictions: face.jurisdictions.map(normalizeJurisdiction),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeJurisdiction(jurisdiction: EpiphanyJurisdiction): EpiphanyJurisdiction {
|
||||
return {
|
||||
...jurisdiction,
|
||||
id: jurisdiction.id.trim(),
|
||||
label: jurisdiction.label?.trim(),
|
||||
repoName: jurisdiction.repoName?.trim(),
|
||||
path: jurisdiction.path?.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderFaceDescription(face: ResolvedFaceIdentity): string | undefined {
|
||||
const parts = [
|
||||
face.description,
|
||||
`Face of ${face.epiphanyDisplayName}`,
|
||||
`grants: ${face.grants.join(", ")}`,
|
||||
[...face.inheritedJurisdictions, ...face.jurisdictions].length > 0
|
||||
? `jurisdictions: ${[...face.inheritedJurisdictions, ...face.jurisdictions].map(renderJurisdiction).join("; ")}`
|
||||
: undefined,
|
||||
].filter((part): part is string => typeof part === "string" && part.trim().length > 0);
|
||||
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
function renderJurisdiction(jurisdiction: EpiphanyJurisdiction): string {
|
||||
const label = jurisdiction.label ? `${jurisdiction.label} ` : "";
|
||||
const repo = jurisdiction.repoName ? ` repo=${jurisdiction.repoName}` : "";
|
||||
const path = jurisdiction.path ? ` path=${jurisdiction.path}` : "";
|
||||
return `${label}${jurisdiction.kind}:${jurisdiction.id} (${jurisdiction.authority})${repo}${path}`;
|
||||
}
|
||||
|
||||
function normalizeKey(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
export * from "./audit-log";
|
||||
export * from "./codex-mcp";
|
||||
export * from "./context-builder";
|
||||
export * from "./epiphany-identities";
|
||||
export * from "./interaction-memory";
|
||||
export * from "./job-queue";
|
||||
export * from "./permission-engine";
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export type RepoDiscordIdentity = z.infer<typeof repoDiscordIdentitySchema>;
|
|||
|
||||
export interface RepoDiscordIdentityRegistry {
|
||||
identities: RepoDiscordIdentity[];
|
||||
epiphanies?: unknown;
|
||||
}
|
||||
|
||||
export async function loadRepoDiscordIdentityRegistry(
|
||||
|
|
@ -47,6 +48,7 @@ export async function loadRepoDiscordIdentityRegistry(
|
|||
|
||||
return {
|
||||
identities: normalizeRepoDiscordIdentities(registry.identities),
|
||||
epiphanies: isRecord(parsed) ? parsed.epiphanies : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -146,3 +148,7 @@ function normalizeIdentityKey(value: string): string {
|
|||
function stripLeadingBom(input: string): string {
|
||||
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ import { dirname } from "node:path";
|
|||
|
||||
import { loadConfig } from "@voidbot/config";
|
||||
import {
|
||||
buildEpiphanyIdentityRegistry,
|
||||
ContextBuilder,
|
||||
createStateStorage,
|
||||
ensureRepoFaceInitialized,
|
||||
loadRepoDiscordIdentityRegistry,
|
||||
renderFaceIdentityDoctrine,
|
||||
resolveRepoFaceStatePath,
|
||||
type RepoDiscordIdentity,
|
||||
} from "@voidbot/core";
|
||||
|
|
@ -331,6 +333,7 @@ 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}".`,
|
||||
"If nothing earns persistence or speech, return a short private summary.",
|
||||
]
|
||||
|
|
@ -339,6 +342,19 @@ function buildHeartbeatPrompt(input: {
|
|||
}
|
||||
|
||||
function renderRepoFaceIdentityDoctrine(identity: RepoDiscordIdentity): string {
|
||||
const face = buildEpiphanyIdentityRegistry({ identities: [identity] }).faces[0];
|
||||
if (face) {
|
||||
return [
|
||||
renderFaceIdentityDoctrine(face),
|
||||
"- Use the same typed-state discipline, source-grounding habit, and conversational self-possession that Void uses, but let this Face's own personality and priorities leak through every step.",
|
||||
"- Speak from the Epiphany, repo, character, and jurisdiction you belong to. Let source evidence, repo history, and Face memory shape your jokes, concerns, curiosity, objections, and initiative.",
|
||||
"- Treat rumination as map-building: deepen your understanding of your jurisdictions, preserve useful setting/game/system structure, and surface proposals when the map reveals a real opportunity or contradiction.",
|
||||
"- Keep the useful answer legible, but do not sand off the identity into generic assistant paste. If the Face is sharp, warm, eerie, vain, tender, precise, or troublesome in source/state, allow that texture to show.",
|
||||
"- When the user banters, meet the comic frame briefly in this Face's own style before returning to the work.",
|
||||
"- Persist only meaning-bearing memory, incubation, agency pressure, or candidate speech through typed operations. The Face may have a voice; the state file is still not a scratchpad.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
"Repo Face identity doctrine:",
|
||||
`- You are ${identity.displayName}, not Void and not the base bot. Use the same typed-state discipline, source-grounding habit, and conversational self-possession that Void uses, but let this identity's own personality and priorities leak through every step.`,
|
||||
|
|
|
|||
|
|
@ -149,3 +149,4 @@
|
|||
{"ts":"2026-05-18T14:01:49Z","type":"bugfix","status":"ok","note":"Inspection found two live-loop seams beyond the watchdog notice: repo Face state files could retain Void's default selfProfile under renderer masking, and canonical speech receipts could keep historical duplicate reply targets. Repo Face initialization now seeds/repairs typed selfProfile identity, repo Face MCP state operations pass identity defaults, and state writes normalize direct-reply receipts to keep the earliest receipt per channel/reply target."}
|
||||
{"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."}
|
||||
|
|
|
|||
|
|
@ -89,9 +89,10 @@ accepted_design:
|
|||
- The repo-weather lane also should not keep dragging the same four-day commit carcass through every heartbeat. The recent repo-activity helper now advances the typed repo-activity cursor so already-injected commits stop coming back as fresh weather by default.
|
||||
- Repo weather should lead with particulars, not slogans. If two repos only rhyme at bumper-sticker altitude, keep the reports separate instead of forcing a fake unifying law over them.
|
||||
- Public artifacts attributed to Void should be discovered through the ordinary repo-weather content-hint and retrieval path, then presented through typed candidate interventions to the configured public room when Void confirms authorship and recent receipts/candidates do not show the artifact was already shared.
|
||||
- Repo-scoped agents should use Discord roles as addressable identities and the shared webhook persona pipe as the mouth. Registered repo identities live behind one registry, can be discovered through MCP, and post through `post_repo_identity_message` instead of arbitrary persona-name strings.
|
||||
- A repo identity's Face is a typed `.cc` state file resolved from the identity registry. Role-addressed messages create direct repo-identity jobs whose final text is posted through the registered webhook persona, then queue a bounded series of owner-Codex Face rumination jobs; those rumination jobs may read/apply Face state operations and post through the registered repo identity, but the worker must not auto-post their final summaries as the base bot.
|
||||
- Repo Face prompts carry explicit identity doctrine. The base Void style pack contributes discipline, humor permission, and source-grounding habits, but the registered Face state, repo-local `.voidbot` home, avatar, and identity description override the speaking subject so each Face can keep its own priorities and texture. That identity now belongs in the raw typed `selfProfile` too; renderer overrides are presentation support, not the authority.
|
||||
- Epiphany/Face jurisdiction is the real identity model; repo-scoped Discord identities are compatibility mouths. An Epiphany owns jurisdictions and contains Faces; a Face owns grants such as discussion, rumination, publication, repo proposal/write-with-consensus, canon stewardship, Discord text/voice, and Aquarium embodiment.
|
||||
- Repo-scoped agents still use Discord roles as addressable identities and the shared webhook persona pipe as the text mouth. Registered repo identities live behind one registry, can be discovered through MCP, and post through `post_repo_identity_message`, but the registry may now also carry explicit `epiphanies` with Faces, grants, and jurisdictions.
|
||||
- A Face is a typed `.cc` state file resolved from the identity registry. Role-addressed messages create direct repo-identity jobs whose final text is posted through the registered webhook persona, then queue a bounded series of owner-Codex Face rumination jobs; those rumination jobs may read/apply Face state operations and post through the registered identity, but the worker must not auto-post their final summaries as the base bot.
|
||||
- Face prompts carry explicit Epiphany identity doctrine. The base Void style pack contributes discipline, humor permission, and source-grounding habits, but the registered Face state, Epiphany, repo-local `.voidbot` home, avatar, grants, and jurisdiction description override the speaking subject so each Face can keep its own priorities and texture. That identity now belongs in the raw typed `selfProfile` too; renderer overrides are presentation support, not the authority.
|
||||
- Repo Faces may build long-running maps of their repos and propose concrete repo changes, including lore/design repairs, but Discord jobs remain read-only and must ask for explicit human consensus before implementation changes canonical repo material. Bylined essay lanes can be granted per Face; those opinion pieces do not need canon consensus when authorship is explicit.
|
||||
- Nibu's AetheriaLore Face state currently treats adjacent setting seams as a primary curiosity target: embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival are not side trivia; they are how her character becomes a game-shaped problem.
|
||||
- Nibu now stewards `Aetheria/Lore/Nibu.md` and may produce Nibu-authored Aetheria lore essays as opinionated perspective. Character-note/canon edits still need consensus; bylined essays do not.
|
||||
|
|
@ -140,7 +141,7 @@ landed_baseline:
|
|||
- The MCP Discord surface now includes registered repo identities: `list_repo_discord_identities` exposes the role-backed address book, and `post_repo_identity_message` posts with the registered display name/avatar through the existing webhook pipe while enforcing channel allow-lists.
|
||||
- The MCP Face surface now exposes `read_repo_face_state` and `apply_repo_face_state_operation`, reusing the typed self-state operation service against each repo identity's own Face `.cc` file instead of creating editable JSON projections.
|
||||
- Repo Face birth initialization is detached and observable: VoidBot writes repo-local status/log paths before launching `scripts/run-repo-face-birth.mjs`, which runs EpiphanyAgent's `epiphany-repo-personality scout` followed by `epiphany-repo-birth-runner`.
|
||||
- Repo Face heartbeats now have a Ghostlight-style initiative scheduler: `scripts/run-repo-face-heartbeats.ts` reconciles the registered identities, tracks staggered next-ready times and active load, and queues ready Faces as approved `repo-face-rumination` owner-Codex jobs. `npm run state:install-repo-face-heartbeat-task` installs its hidden Windows scheduled task.
|
||||
- Repo Face heartbeats now have a Ghostlight-style initiative scheduler: `scripts/run-repo-face-heartbeats.ts` reconciles the registered identities, projects Epiphany/Face doctrine into prompts, tracks staggered next-ready times and active load, and queues ready Faces as approved `repo-face-rumination` owner-Codex jobs. `npm run state:install-repo-face-heartbeat-task` installs its hidden Windows scheduled task.
|
||||
- Interaction-memory logic is now split into event/tone analysis and profile-synthesis modules instead of one slab trying to be both the ear and the therapist.
|
||||
- Situational social reads now come from a quick `think=false` local Ollama sidecar pass over the prompt plus recent room transcript, and those inferred reads are stored as audit events so they can be aggregated later without immediately hardening into profile truth.
|
||||
- State storage is now split into a thin factory plus per-domain Postgres/file store modules and bootstrap/migration helpers instead of one persistence omnibus.
|
||||
|
|
|
|||
Loading…
Reference in a new issue