Add native VoidBot persona support

This commit is contained in:
Meta Vens 2026-06-02 12:58:25 +01:00
commit 1d8b0e6819
7 changed files with 255 additions and 31 deletions

View file

@ -807,7 +807,8 @@ async function ensureRepoIdentityRoles(options: {
registryPath: string;
identities: RepoDiscordIdentity[];
}): Promise<{ identities: RepoDiscordIdentity[] }> {
const identitiesMissingRoles = options.identities.filter((identity) => !identity.roleId);
const identitiesMissingRoles = options.identities.filter((identity) =>
identity.identityKind !== "native_persona" && !identity.roleId);
if (identitiesMissingRoles.length === 0) {
return { identities: options.identities };

View file

@ -1306,12 +1306,13 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn
return false;
}
const { identity, channelId, content, replyToMessageId } = resolved;
const sourceDocuments = await sourceArchiveRepository.listByRepo(identity.repoName).catch(() => []);
const linkedContent = resolveRepoIdentityPostArtifactLinks({
repoName: identity.repoName,
content,
documents: sourceDocuments,
});
const linkedContent = identity.identityKind === "native_persona"
? content
: resolveRepoIdentityPostArtifactLinks({
repoName: identity.repoName,
content,
documents: await sourceArchiveRepository.listByRepo(identity.repoName).catch(() => []),
});
if (isOwnerDmChannelAlias(channelId)) {
const dmChannelId = await openOwnerDmChannel();
const postedMessages = await postDiscordBotMessageChunks(
@ -1578,6 +1579,7 @@ async function detectRepoFaceRecentSiblingSpeechDuplicate(
const registry = await loadRegisteredFaceRepoRegistry();
const siblingIdentities = registry.identities.filter((identity) =>
identity.id !== post.identity.id &&
identity.identityKind !== "native_persona" &&
isRepoDiscordIdentityAllowedInChannel(identity, post.channelId)
);
if (siblingIdentities.length === 0) {
@ -3402,15 +3404,17 @@ async function recordRepoIdentityDeliveryReceipt(input: {
},
);
try {
await applyRepoFacePostFatigueAfterSpeech({
identity: input.identity,
storageRoot: config.storageRoot,
heartbeatStatePath: config.repoFaceHeartbeats.statePath,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Could not apply repo Face post fatigue for ${input.identity.id}: ${message}`);
if (input.identity.identityKind !== "native_persona") {
try {
await applyRepoFacePostFatigueAfterSpeech({
identity: input.identity,
storageRoot: config.storageRoot,
heartbeatStatePath: config.repoFaceHeartbeats.statePath,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Could not apply repo Face post fatigue for ${input.identity.id}: ${message}`);
}
}
}

View file

@ -36,6 +36,7 @@ const jurisdictionSchema = z.object({
const faceIdentitySchema = z.object({
id: z.string().trim().min(1),
identityKind: z.enum(["repo_face", "native_persona"]).default("repo_face"),
displayName: z.string().trim().min(1).max(80),
repoName: z.string().trim().min(1).optional(),
repoPath: z.string().trim().min(1).optional(),
@ -50,7 +51,9 @@ const faceIdentitySchema = z.object({
posture: z.string().trim().min(1).optional(),
})).default([]),
avatarUrl: z.string().trim().url().max(512).optional(),
avatarPath: z.string().trim().min(1).optional(),
faceStatePath: z.string().trim().min(1).optional(),
personaStatePath: 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([]),
@ -117,6 +120,7 @@ export function faceToRepoDiscordIdentity(face: ResolvedFaceIdentity): RepoDisco
return {
id: face.id,
identityKind: face.identityKind,
repoName,
displayName: face.displayName,
repoPath: face.repoPath,
@ -124,7 +128,9 @@ export function faceToRepoDiscordIdentity(face: ResolvedFaceIdentity): RepoDisco
allowedChannelIds: face.allowedChannelIds,
channelPermissions: face.channelPermissions,
avatarUrl: face.avatarUrl,
avatarPath: face.avatarPath,
faceStatePath: face.faceStatePath,
personaStatePath: face.personaStatePath,
description: renderFaceDescription(face),
};
}
@ -178,6 +184,7 @@ function repoIdentityToEpiphany(identity: RepoDiscordIdentity): EpiphanyIdentity
faces: [
{
id: identity.id,
identityKind: identity.identityKind,
displayName: identity.displayName,
repoName: identity.repoName,
repoPath: identity.repoPath,
@ -185,7 +192,9 @@ function repoIdentityToEpiphany(identity: RepoDiscordIdentity): EpiphanyIdentity
allowedChannelIds: getRepoDiscordIdentityAllowedChannelIds(identity),
channelPermissions: identity.channelPermissions,
avatarUrl: identity.avatarUrl,
avatarPath: identity.avatarPath,
faceStatePath: identity.faceStatePath,
personaStatePath: identity.personaStatePath,
description: identity.description,
grants: [
"discussion",
@ -229,6 +238,7 @@ function normalizeEpiphanies(epiphanies: EpiphanyIdentity[]): EpiphanyIdentity[]
return {
...face,
id: face.id.trim(),
identityKind: face.identityKind,
displayName: face.displayName.trim().slice(0, 80),
repoName: face.repoName?.trim(),
repoPath: face.repoPath?.trim(),
@ -242,7 +252,9 @@ function normalizeEpiphanies(epiphanies: EpiphanyIdentity[]): EpiphanyIdentity[]
posture: permission.posture?.trim(),
})),
avatarUrl: face.avatarUrl?.trim(),
avatarPath: face.avatarPath?.trim(),
faceStatePath: face.faceStatePath?.trim(),
personaStatePath: face.personaStatePath?.trim(),
description: face.description?.trim(),
grants: [...new Set(face.grants)],
jurisdictions: face.jurisdictions.map(normalizeJurisdiction),

View file

@ -5,6 +5,7 @@ import { z } from "zod";
const repoDiscordIdentitySchema = z.object({
id: z.string().trim().min(1),
identityKind: z.enum(["repo_face", "native_persona"]).default("repo_face"),
repoName: z.string().trim().min(1),
displayName: z.string().trim().min(1).max(80),
repoPath: z.string().trim().min(1).optional(),
@ -19,7 +20,9 @@ const repoDiscordIdentitySchema = z.object({
posture: z.string().trim().min(1).optional(),
})).default([]),
avatarUrl: z.string().trim().url().max(512).optional(),
avatarPath: z.string().trim().min(1).optional(),
faceStatePath: z.string().trim().min(1).optional(),
personaStatePath: z.string().trim().min(1).optional(),
description: z.string().trim().min(1).optional(),
});
@ -144,6 +147,10 @@ export function resolveRepoFaceStatePath(
return resolve(identity.faceStatePath);
}
if (identity.personaStatePath) {
return resolve(identity.personaStatePath);
}
return resolve(storageRoot, "private", "repo-faces", `${sanitizePathSegment(identity.id)}.cc`);
}
@ -199,6 +206,7 @@ function normalizeRepoDiscordIdentities(
return {
...identity,
id: identity.id.trim(),
identityKind: identity.identityKind,
repoName: identity.repoName.trim(),
displayName: identity.displayName.trim().slice(0, 80),
repoPath: identity.repoPath?.trim(),
@ -206,7 +214,9 @@ function normalizeRepoDiscordIdentities(
allowedChannelIds: [...new Set(identity.allowedChannelIds.map((entry) => entry.trim()))],
channelPermissions: normalizeChannelPermissions(identity.channelPermissions),
avatarUrl: identity.avatarUrl?.trim(),
avatarPath: identity.avatarPath?.trim(),
faceStatePath: identity.faceStatePath?.trim(),
personaStatePath: identity.personaStatePath?.trim(),
description: identity.description?.trim(),
};
});

View file

@ -2,7 +2,7 @@
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@ -288,8 +288,10 @@ function projectParticipant(participant, pendingMentions, initiativeClock, ident
repoName: String(participant.repoName ?? metadata.repoName ?? "unknown"),
repoPath: stringOrNull(metadata.repoPath),
faceStatePath: stringOrNull(metadata.faceStatePath),
personaStatePath: stringOrNull(metadata.personaStatePath),
description: stringOrNull(metadata.description),
avatarUrl: metadata.avatarUrl ?? null,
avatarPath: metadata.avatarPath ?? null,
channelPermissions: Array.isArray(metadata.channelPermissions)
? metadata.channelPermissions.slice(0, 12).map((entry) => ({
label: stringOrNull(entry?.label),
@ -364,9 +366,12 @@ async function readIdentityMetadata(registryPathValue) {
displayName: stringOrNull(identity?.displayName),
repoName: stringOrNull(identity?.repoName),
repoPath: stringOrNull(identity?.repoPath),
identityKind: stringOrNull(identity?.identityKind),
faceStatePath: stringOrNull(identity?.faceStatePath),
personaStatePath: stringOrNull(identity?.personaStatePath),
description: stringOrNull(identity?.description),
avatarUrl: stringOrNull(identity?.avatarUrl),
avatarPath: stringOrNull(identity?.avatarPath),
channelPermissions: Array.isArray(identity?.channelPermissions) ? identity.channelPermissions : [],
});
}
@ -393,6 +398,11 @@ async function readFaceStates(identityMetadata) {
}
for (const [id, identity] of identityMetadata.entries()) {
if (identity.identityKind === "native_persona") {
states.set(id, readPersonaStateSummary(identity));
continue;
}
if (!identity.faceStatePath) {
states.set(id, {
readable: false,
@ -441,6 +451,67 @@ async function readFaceStates(identityMetadata) {
return states;
}
function readPersonaStateSummary(identity) {
if (!identity.personaStatePath) {
return {
readable: false,
path: null,
error: "No Persona state path registered.",
tree: [],
};
}
try {
const raw = readFileSync(resolve(identity.personaStatePath), "utf8");
const state = JSON.parse(stripBom(raw));
return {
readable: true,
kind: "native_persona",
path: identity.personaStatePath,
summary: truncate(state.publicDescription ?? state.presentation?.voiceSummary ?? "", 1200),
counts: {
memory: state.thoughtMemory?.memories?.length ?? 0,
pressures: state.agencyPressure?.pressures?.length ?? 0,
needs: state.affect?.needs?.length ?? 0,
statusReads: state.affect?.statusReads?.length ?? 0,
doctrine: state.affect?.doctrineStances?.length ?? 0,
},
tree: buildPersonaStateTree(state),
};
} catch (error) {
return {
readable: false,
kind: "native_persona",
path: identity.personaStatePath,
error: error instanceof Error ? error.message : String(error),
tree: [],
};
}
}
function buildPersonaStateTree(state) {
return [
node("Persona", "persona", [
leaf("Public Name", state.publicName),
leaf("Description", state.publicDescription),
leaf("Voice", state.presentation?.voiceSummary),
collectionNode("Values", "values", state.values, valueNode),
objectNode("Activation", "activationProfile", state.activationProfile),
collectionNode("Private Notes", "privateNotes", state.privateNotes, valueNode),
]),
node("Thought Memory", "thoughtMemory", [
collectionNode("Short Term", "thoughtMemory.shortTerm", state.thoughtMemory?.shortTerm, memoryNode),
collectionNode("Durable Memories", "thoughtMemory.memories", state.thoughtMemory?.memories, memoryNode),
collectionNode("Incubation", "thoughtMemory.incubation", state.thoughtMemory?.incubation, memoryNode),
]),
node("Affect", "affect", [
collectionNode("Needs", "affect.needs", state.affect?.needs, valueNode),
collectionNode("Status Reads", "affect.statusReads", state.affect?.statusReads, valueNode),
collectionNode("Doctrine", "affect.doctrineStances", state.affect?.doctrineStances, valueNode),
]),
];
}
function projectRestState(typedState) {
const sleepCycle = typedState?.scheduledRuntime?.sleepCycle;
if (!sleepCycle || typeof sleepCycle !== "object") {

View file

@ -47,7 +47,7 @@ const MAX_FACE_IMAGE_ATTACHMENT_BYTES = 12 * 1024 * 1024;
interface FaceHeartbeatParticipant {
identityId: string;
participantKind: "repo_face" | "system_agent";
participantKind: "repo_face" | "native_persona" | "system_agent";
turnKind: "repo_face_rumination" | "void_moderation";
repoName: string;
displayName: string;
@ -553,7 +553,7 @@ function buildParticipantSpecs(identities: RepoDiscordIdentity[]): ParticipantSp
},
...identities.map((identity) => ({
id: identity.id,
participantKind: "repo_face" as const,
participantKind: identity.identityKind === "native_persona" ? "native_persona" as const : "repo_face" as const,
turnKind: "repo_face_rumination" as const,
repoName: identity.repoName,
displayName: identity.displayName,
@ -614,15 +614,17 @@ async function queueRepoFaceTurn(input: {
}
const contextBuilder = new ContextBuilder();
await ensureRepoFaceInitialized({
identity,
storageRoot: input.config.storageRoot,
sourceRepoRoot: input.config.sourceRepoRoot,
epiphanyAgentRoot: input.config.epiphanyAgentRoot,
workspaceRoot: process.cwd(),
birthMode: input.config.repoFaceBirthMode,
birthExecutor: input.config.repoFaceBirthExecutor,
});
if (identity.identityKind !== "native_persona") {
await ensureRepoFaceInitialized({
identity,
storageRoot: input.config.storageRoot,
sourceRepoRoot: input.config.sourceRepoRoot,
epiphanyAgentRoot: input.config.epiphanyAgentRoot,
workspaceRoot: process.cwd(),
birthMode: input.config.repoFaceBirthMode,
birthExecutor: input.config.repoFaceBirthExecutor,
});
}
const recentMessages = await fetchRecentDiscordMessages({
botToken: input.config.botToken,
channelId,
@ -637,6 +639,7 @@ async function queueRepoFaceTurn(input: {
bifrostDiscordChannelId: input.config.bifrostDiscordChannelId,
});
const bifrostDigest = input.config.repoFaceBifrostEnabled
&& identity.identityKind !== "native_persona"
? await fetchBifrostGovernanceDigest({
bifrostRoot: input.config.bifrostRoot,
repoName: identity.repoName,
@ -655,7 +658,9 @@ async function queueRepoFaceTurn(input: {
roomContext,
humanPronounGuidance,
);
const repoActivitySurface = renderRepoFaceRepoActivitySurface(identity, input.config);
const repoActivitySurface = identity.identityKind === "native_persona"
? renderNativePersonaBodySurface(identity)
: renderRepoFaceRepoActivitySurface(identity, input.config);
const globalAgentDoctrine = await loadGlobalAgentDoctrine();
const conversationMemorySurface = renderRepoFaceConversationTranscript({
identity,
@ -747,6 +752,10 @@ async function loadRepoFaceRuntimeSnapshots(
const now = new Date();
for (const identity of identities) {
if (identity.identityKind === "native_persona") {
continue;
}
try {
const statePath = resolveRepoFaceStatePath(identity, storageRoot);
const typedState = await loadVoidSelfStateTypedDocuments({
@ -2528,6 +2537,10 @@ async function renderRepoFaceMemorySurfaceForTurn(
},
humanPronounGuidance?: RepoFaceHumanPronounGuidance[],
): Promise<string> {
if (identity.identityKind === "native_persona") {
return renderNativePersonaMemorySurface(identity);
}
const statePath = resolveRepoFaceStatePath(identity, config.storageRoot);
const typedState = await loadVoidSelfStateTypedDocuments({ canonicalPath: statePath });
const curiosityGraphFacts = roomContext
@ -2552,6 +2565,108 @@ async function renderRepoFaceMemorySurfaceForTurn(
});
}
async function renderNativePersonaMemorySurface(identity: RepoDiscordIdentity): Promise<string> {
const statePath = identity.personaStatePath ?? resolveRepoFaceStatePath(identity, ".voidbot");
const raw = await readFile(resolve(statePath), "utf8");
const state = JSON.parse(stripLeadingBom(raw)) as {
publicName?: string;
publicDescription?: string;
presentation?: {
pronouns?: string;
voiceSummary?: string;
homeContext?: { label?: string; id?: string; kind?: string };
};
privateNotes?: string[];
values?: Array<{ label?: string; priority?: number; summary?: string }>;
thoughtMemory?: {
memories?: Array<{ id?: string; status?: string; summary?: string; claim?: string; question?: string; tension?: string; actionImplication?: string; tags?: string[] }>;
shortTerm?: Array<{ summary?: string; claim?: string; tension?: string; actionImplication?: string }>;
incubation?: Array<{ summary?: string; question?: string; tension?: string; actionImplication?: string }>;
};
agencyPressure?: {
pressures?: Array<{ status?: string; summary?: string; tension?: string; actionImplication?: string; intensity?: number }>;
};
affect?: {
needs?: Array<{ status?: string; summary?: string; tension?: string; actionImplication?: string; intensity?: number }>;
statusReads?: Array<{ statusKind?: string; summary?: string; confidence?: number; intensity?: number }>;
moodDimensions?: Array<{ name?: string; value?: number }>;
socialBiases?: Array<{ name?: string; value?: number; summary?: string; behavioralPull?: string }>;
doctrineStances?: Array<{ stanceKind?: string; principle?: string; summary?: string; actionImplication?: string; intensity?: number }>;
};
};
const lines: string[] = [
`${state.publicName ?? identity.displayName} is a native VoidBot Persona, not a repo Face.`,
state.publicDescription ? `Public identity: ${state.publicDescription}` : undefined,
state.presentation?.pronouns ? `Pronouns: ${state.presentation.pronouns}.` : undefined,
state.presentation?.voiceSummary ? `Voice: ${state.presentation.voiceSummary}` : undefined,
state.presentation?.homeContext ? `Home context: ${state.presentation.homeContext.label ?? state.presentation.homeContext.id ?? state.presentation.homeContext.kind}.` : undefined,
identity.avatarPath ? `Avatar asset: ${identity.avatarPath}.` : undefined,
"Authority boundary: no repo jurisdiction, no repo proposal grant, no Discord role authority, no cultural or supernatural authority claim. Challenge is conversational and consent-preserving.",
].filter((line): line is string => typeof line === "string" && line.trim().length > 0);
lines.push("", "Private standing notes:");
lines.push(...formatNativePersonaBullets(state.privateNotes, 8));
lines.push("", "Values, strongest first:");
lines.push(...(state.values ?? [])
.slice()
.sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))
.slice(0, 8)
.map((value) => `- ${value.label ?? "value"}${typeof value.priority === "number" ? ` (${value.priority.toFixed(2)})` : ""}: ${value.summary ?? ""}`.trim()));
lines.push("", "Personal memory, in this Persona's own mythic perspective:");
lines.push(...formatNativePersonaMemory(state.thoughtMemory?.memories, 10));
lines.push("", "Active pressures:");
lines.push(...formatNativePersonaPressures(state.agencyPressure?.pressures, 6));
lines.push("", "Needs and status reads:");
lines.push(...formatNativePersonaPressures(state.affect?.needs, 5));
lines.push(...(state.affect?.statusReads ?? []).slice(0, 5).map((read) =>
`- ${read.statusKind ?? "status"}: ${read.summary ?? "unspecified"}${typeof read.intensity === "number" ? ` (intensity ${read.intensity.toFixed(2)})` : ""}`));
lines.push("", "Mood and social bias:");
lines.push(...(state.affect?.moodDimensions ?? []).slice(0, 6).map((mood) =>
`- ${mood.name ?? "mood"}: ${typeof mood.value === "number" ? mood.value.toFixed(2) : "?"}`));
lines.push(...(state.affect?.socialBiases ?? []).slice(0, 5).map((bias) =>
`- ${bias.name ?? "bias"}: ${bias.summary ?? ""} ${bias.behavioralPull ?? ""}`.trim()));
lines.push("", "Doctrine stances:");
lines.push(...(state.affect?.doctrineStances ?? []).slice(0, 8).map((stance) =>
`- ${stance.stanceKind ?? "stance"}: ${stance.principle ?? stance.summary ?? "unnamed stance"} -> ${stance.actionImplication ?? stance.summary ?? "let it affect speech"}`));
return rejectLeakyMemorySurface(lines.join("\n"));
}
function formatNativePersonaBullets(values: string[] | undefined, max: number): string[] {
const lines = (values ?? []).slice(0, max).map((value) => `- ${value}`);
return lines.length > 0 ? lines : ["- No standing private notes were attached."];
}
function formatNativePersonaMemory(
memories: Array<{ id?: string; status?: string; summary?: string; claim?: string; question?: string; tension?: string; actionImplication?: string; tags?: string[] }> | undefined,
max: number,
): string[] {
const lines = (memories ?? [])
.filter((memory) => memory.status !== "retired")
.slice(0, max)
.map((memory) => [
`- ${memory.summary ?? memory.id ?? "memory"}`,
memory.claim ? ` Claim: ${memory.claim}` : undefined,
memory.question ? ` Question: ${memory.question}` : undefined,
memory.tension ? ` Tension: ${memory.tension}` : undefined,
memory.actionImplication ? ` Action: ${memory.actionImplication}` : undefined,
memory.tags?.length ? ` Tags: ${memory.tags.join(", ")}` : undefined,
].filter(Boolean).join("\n"));
return lines.length > 0 ? lines : ["- No personal memories were attached."];
}
function formatNativePersonaPressures(
pressures: Array<{ status?: string; summary?: string; tension?: string; actionImplication?: string; intensity?: number }> | undefined,
max: number,
): string[] {
const lines = (pressures ?? [])
.filter((pressure) => pressure.status !== "retired")
.slice(0, max)
.map((pressure) =>
`- ${pressure.summary ?? "pressure"}${typeof pressure.intensity === "number" ? ` (${pressure.intensity.toFixed(2)})` : ""}: ${pressure.tension ?? ""} ${pressure.actionImplication ?? ""}`.trim());
return lines.length > 0 ? lines : ["- No active pressures were attached."];
}
function renderRepoFaceStatePacket(
identity: RepoDiscordIdentity,
state: VoidSelfStateTypedProjection,
@ -4510,7 +4625,7 @@ function buildInspectionParticipant(
): FaceHeartbeatParticipant {
return {
identityId: identity.id,
participantKind: "repo_face",
participantKind: identity.identityKind === "native_persona" ? "native_persona" : "repo_face",
turnKind: "repo_face_rumination",
repoName: identity.repoName,
displayName: identity.displayName,
@ -4521,7 +4636,7 @@ function buildInspectionParticipant(
status: "active",
groups: [
"all",
"kind:repo_face",
`kind:${identity.identityKind === "native_persona" ? "native_persona" : "repo_face"}`,
"turn:repo_face_rumination",
`identity:${normalizeKey(identity.id)}`,
`repo:${normalizeKey(identity.repoName)}`,
@ -4538,6 +4653,16 @@ function buildInspectionParticipant(
};
}
function renderNativePersonaBodySurface(identity: RepoDiscordIdentity): string {
return [
`- ${identity.displayName} is a native VoidBot Persona, not a repo Face.`,
`- Local Body: persona state ${identity.personaStatePath ?? resolveRepoFaceStatePath(identity, ".voidbot")}.`,
identity.avatarPath ? `- Avatar asset: ${identity.avatarPath}.` : undefined,
"- No repo jurisdiction, repo proposal authority, repo birth initialization, or Discord role registration is implied.",
"- Treat the Persona state, avatar, voice, allowed channels, and conversation surface as the Body for this turn.",
].filter((line): line is string => typeof line === "string").join("\n");
}
function buildChannelPlan(
identity: RepoDiscordIdentity,
defaultChannelId?: string,

View file

@ -374,3 +374,4 @@
{"ts":"2026-05-29T15:07:19Z","type":"implementation","status":"ok","note":"VoidBot now mirrors gamecult.persona_state.v0 under schemas/cultnet and read_repo_face_state emits a PersonaState projection beside the native typed .cc state; native VoidBot documents remain the authority, provenance marks PersonaState as projection, and the MCP smoke verifies Nibu's projection."}
{"ts":"2026-06-01T21:17:38Z","type":"implementation","status":"ok","note":"Void repo-weather rumination now projects activeRepos/weatherDigest separately from cursor-fresh commits and the prompt treats repo weather as a standing public changelog/herald job; fixture and typecheck passed."}
{"ts":"2026-06-02T10:56:39Z","type":"implementation","status":"ok","note":"Expanded voidbot.swarm Eve/CultMesh surface for Nightwing TUI: publisher now requests continuous-ops layout, exposes 14-turn CTB rail, next-Faces pane, and watchdog/orchestrator pane; Odin now merges provider layout intent into persisted interface tiles so stale layout cannot keep the tile anemic. Verified Odin accepted CultMesh source with preferredHeight 58, w=2, h=5, and pane ids upcoming-faces-pane/voidbot-watchdog-pane."}
{"ts":"2026-06-02T11:58:03Z","type":"implementation","status":"ok","note":"Added native VoidBot Persona support for Tengu: identity registry now preserves identityKind/native_persona plus personaStatePath/avatarPath, bot role creation skips native personas, heartbeat scheduler skips repo birth/Bifrost/repo .cc runtime loading for native personas, native Persona JSON is projected into Face-turn memory, and the swarm dashboard renders native Persona state. Installed local Tengu state/avatar under .voidbot/private/personas/tengu, seeded Bushido/Kurama/Sarutahiko/yamabushi/contained-wrath/weak-sect Dao-of-Awakening myth memories, registered him locally, pulled first CTB slot ready, and republished CultMesh dashboard."}