Route Persona state tools through Huginn

This commit is contained in:
Meta Vens 2026-06-01 22:15:39 +01:00
commit eb799c505e
4 changed files with 164 additions and 90 deletions

View file

@ -423,6 +423,15 @@ Current MCP tools:
- `search_sources`
- `get_source_context`
- `list_indexed_repos`
- `huginn_read_persona_state`
- `huginn_apply_persona_state_operation`
`read_repo_face_state` and `apply_repo_face_state_operation` remain
compatibility aliases, but Huginn owns Persona-state inspection and mutation
stewardship. VoidBot is only the legacy MCP carrier. The runtime owner is
CultCacheTS/Huginn, whose next proper surface is a CultMesh provider publishing
read-only Eve DSL for `.cc` files. Consuming runtimes lower that DSL into their
own UI.
- `notify_owner`
Notes:
@ -635,7 +644,7 @@ npm run moderation:recent-repo-activity -- --hours 96 --max-commits 3
That gives the loop a compact digest of recent commit motion across every tracked source repo, so Void can notice the broader experiment zoo instead of only sniffing whatever one thread happened to say out loud.
The moderator state is now typed CultCache state, not an editable Ghostlight-shaped JSON projection. The typed documents own profile, moderation cursor/open cases, speech receipts, thought memory/incubation, scheduled runtime, agency pressure, candidate interventions, and Face affect. Repo Face reads also emit `gamecult.persona_state.v0` as a portable projection for shared Persona interop. The old JSON projection, state template, and context exporter are gone.
The moderator state is now typed CultCache state, not an editable Ghostlight-shaped JSON projection. The typed documents own profile, moderation cursor/open cases, speech receipts, thought memory/incubation, scheduled runtime, agency pressure, candidate interventions, and Face affect. Repo Face reads also emit `gamecult.persona_state.v0` as a portable projection for shared Persona interop. Huginn owns the Persona/.cc inspection runtime; VoidBot's MCP state tools are a compatibility carrier until CultCacheTS publishes the same read-only inspection through CultMesh as Eve DSL. The old JSON projection, state template, and context exporter are gone.
The typed rumination runner can still think about GameCult projects, archived Discord seams, indexed repos, recent cross-repo commit motion, and lore, but durable changes must come back as typed operation payloads. The child does not edit state.
The mood-drift task owns the sleep transition. Rumination writes short-term memory only; when Void is napping, mood drift invokes `scripts/run-void-memory-maintenance.ps1` once per nap unless the pass already completed for that nap. Memory maintenance reads typed self-state, loads `prompts/void-memory-maintenance.md`, and applies only memory/incubation/candidate typed operation proposals. Short-term memories do not survive sleep unless promoted into durable memory or merged into incubation; the runner fails a real sleep pass that leaves short-term residue behind. Use `-StateFilePath` for fixture stores, `--skip-memory-maintenance` on `simulate-void-mood.mjs` for smoke runs that must not invoke Codex, and `npm run moderation:memory-maintenance:sleep-fixture` to exercise the non-skip model branch with a fake Codex child. Dream residue, identity crystallization, value pressure, and other agency behavior still have to come back through that operation boundary. No compatibility séance.
Long-term memory is durable, not frozen. Sleep can promote short-term residue into durable memory, and later maintenance can explicitly revise, retire, or crystallize durable memories instead of silently overwriting Void's past thoughts. Use `npm run moderation:memory-lifecycle:fixture` to verify promotion, revision, retirement, and crystallization into an identity memory plus self-profile value. The live language is anchors, not courtroom evidence: a thought needs something that made it real, whether that is room context, repo motion, source/lore retrieval, a prior memory, or self-state pressure.

View file

@ -707,72 +707,39 @@ export function registerVoidbotTools(
);
registerIfAllowed(
"read_repo_face_state",
"huginn_read_persona_state",
{
title: "Read Repo Face State",
title: "Huginn Read Persona State",
description:
"Read the typed persistent Face state for a registered repo identity. Face state uses the same typed operation machinery as Void, but the state file belongs to the repo identity.",
"Huginn-owned Persona state inspection for a registered repo identity. Reads the repo Face typed .cc state, emits the gamecult.persona_state.v0 projection, and leaves canonical authority with the source state file. Huginn is the runtime steward; VoidBot is only the legacy MCP carrier.",
inputSchema: repoFaceStateInputSchema,
annotations: READ_ONLY_ANNOTATIONS,
},
async (input: RepoFaceStateArgs): Promise<CallToolResult> => {
const resolved = await resolveRepoIdentityForTool(context, input.identity);
if (!resolved.identity) {
return resolved.error;
}
const typedState = await loadVoidSelfStateTypedDocuments({
canonicalPath: resolved.faceStatePath,
identity: {
agentId: resolved.identity.id,
publicName: resolved.identity.displayName,
publicDescription: resolved.identity.description,
},
});
const rendered = buildVoidSelfStateContext(typedState, {
sourcePath: resolved.faceStatePath,
identity: {
agentId: resolved.identity.id,
publicName: resolved.identity.displayName,
publicDescription: resolved.identity.description,
},
});
const personaState = buildGameCultPersonaStateFromVoidSelfState(typedState, {
sourceDocumentId: resolved.faceStatePath,
identity: resolved.identity,
});
return {
content: [
{
type: "text",
text: renderJsonBlock({
identity: identityForToolResult(resolved.identity, resolved.face),
faceStatePath: resolved.faceStatePath,
summary: rendered.summary,
personaState,
typedState,
}),
},
],
structuredContent: {
identity: identityForToolResult(resolved.identity, resolved.face),
faceStatePath: resolved.faceStatePath,
summary: rendered.summary,
personaState,
typedState,
},
};
return readPersonaStateForTool(context, input);
},
);
registerIfAllowed(
"apply_repo_face_state_operation",
"read_repo_face_state",
{
title: "Apply Repo Face State Operation",
title: "Read Repo Face State",
description:
"Apply one typed state operation to a registered repo identity's Face state. Use this for Face memory, incubation, agency pressure, candidate interventions, and receipts; do not edit the Face state file directly.",
"Compatibility alias for huginn_read_persona_state. Prefer the Huginn-owned tool when inspecting Persona state; VoidBot only hosts the MCP runtime.",
inputSchema: repoFaceStateInputSchema,
annotations: READ_ONLY_ANNOTATIONS,
},
async (input: RepoFaceStateArgs): Promise<CallToolResult> => {
return readPersonaStateForTool(context, input);
},
);
registerIfAllowed(
"huginn_apply_persona_state_operation",
{
title: "Huginn Apply Persona State Operation",
description:
"Huginn-stewarded mutation path for registered Persona/Face state. Applies one typed state operation to the source repo Face .cc file; use this for memory, incubation, agency pressure, candidate actions/interventions, and receipts. Do not edit the state file directly.",
inputSchema: applyRepoFaceStateOperationInputSchema,
annotations: {
readOnlyHint: false,
@ -782,43 +749,26 @@ export function registerVoidbotTools(
},
},
async (input: ApplyRepoFaceStateOperationArgs): Promise<CallToolResult> => {
const resolved = await resolveRepoIdentityForTool(context, input.identity);
return applyPersonaStateOperationForTool(context, input);
},
);
if (!resolved.identity) {
return resolved.error;
}
const result = await applyVoidSelfStateOperation(
{
canonicalPath: resolved.faceStatePath,
identity: {
agentId: resolved.identity.id,
publicName: resolved.identity.displayName,
publicDescription: resolved.identity.description,
},
},
input.operation,
);
return {
content: [
{
type: "text",
text: renderJsonBlock({
applied: true,
identity: identityForToolResult(resolved.identity, resolved.face),
faceStatePath: resolved.faceStatePath,
result,
}),
},
],
structuredContent: {
applied: true,
identity: identityForToolResult(resolved.identity, resolved.face),
faceStatePath: resolved.faceStatePath,
result,
},
};
registerIfAllowed(
"apply_repo_face_state_operation",
{
title: "Apply Repo Face State Operation",
description:
"Compatibility alias for huginn_apply_persona_state_operation. Prefer the Huginn-owned tool for Persona-state operations; the typed source state remains the authority.",
inputSchema: applyRepoFaceStateOperationInputSchema,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
},
async (input: ApplyRepoFaceStateOperationArgs): Promise<CallToolResult> => {
return applyPersonaStateOperationForTool(context, input);
},
);
@ -1199,6 +1149,116 @@ function decodeHtml(value: string): string {
.replace(/&nbsp;/g, " ");
}
async function readPersonaStateForTool(
context: VoidbotMcpContext,
input: RepoFaceStateArgs,
): Promise<CallToolResult> {
const resolved = await resolveRepoIdentityForTool(context, input.identity);
if (!resolved.identity) {
return resolved.error;
}
const typedState = await loadVoidSelfStateTypedDocuments({
canonicalPath: resolved.faceStatePath,
identity: {
agentId: resolved.identity.id,
publicName: resolved.identity.displayName,
publicDescription: resolved.identity.description,
},
});
const rendered = buildVoidSelfStateContext(typedState, {
sourcePath: resolved.faceStatePath,
identity: {
agentId: resolved.identity.id,
publicName: resolved.identity.displayName,
publicDescription: resolved.identity.description,
},
});
const personaState = buildGameCultPersonaStateFromVoidSelfState(typedState, {
sourceDocumentId: resolved.faceStatePath,
identity: resolved.identity,
});
const payload = {
steward: personaStateStewardForTool(),
host: personaStateHostForTool(),
identity: identityForToolResult(resolved.identity, resolved.face),
faceStatePath: resolved.faceStatePath,
summary: rendered.summary,
personaState,
typedState,
};
return {
content: [
{
type: "text",
text: renderJsonBlock(payload),
},
],
structuredContent: payload,
};
}
async function applyPersonaStateOperationForTool(
context: VoidbotMcpContext,
input: ApplyRepoFaceStateOperationArgs,
): Promise<CallToolResult> {
const resolved = await resolveRepoIdentityForTool(context, input.identity);
if (!resolved.identity) {
return resolved.error;
}
const result = await applyVoidSelfStateOperation(
{
canonicalPath: resolved.faceStatePath,
identity: {
agentId: resolved.identity.id,
publicName: resolved.identity.displayName,
publicDescription: resolved.identity.description,
},
},
input.operation,
);
const payload = {
applied: true,
steward: personaStateStewardForTool(),
host: personaStateHostForTool(),
identity: identityForToolResult(resolved.identity, resolved.face),
faceStatePath: resolved.faceStatePath,
result,
};
return {
content: [
{
type: "text",
text: renderJsonBlock(payload),
},
],
structuredContent: payload,
};
}
function personaStateStewardForTool() {
return {
id: "huginn",
displayName: "Huginn",
repoName: "CultCacheTS",
role: "persona_state_runtime_steward",
contract: "gamecult.persona_state.v0",
cultMeshProviderId: "cultcache.huginn.inspector",
};
}
function personaStateHostForTool() {
return {
service: "voidbot",
role: "legacy_mcp_carrier",
};
}
async function resolveRepoIdentityForTool(
context: VoidbotMcpContext,
identitySelector: string,

View file

@ -32,6 +32,7 @@
"state:install-mood-task": "powershell -ExecutionPolicy Bypass -File scripts/install-void-mood-drift-task.ps1",
"state:install-repo-face-heartbeat-task": "powershell -ExecutionPolicy Bypass -File scripts/install-repo-face-heartbeat-task.ps1",
"agents:inspect-requests": "tsx scripts/inspect-agent-state-requests.ts",
"huginn:inspect-requests": "tsx scripts/inspect-agent-state-requests.ts",
"agents:inspect-model-outputs": "node scripts/inspect-model-output-logs.mjs",
"agents:assemble-character-prompt": "tsx scripts/run-repo-face-heartbeats.ts --assemble-prompt",
"agents:repo-face-memory-maintenance": "tsx scripts/run-repo-face-heartbeats.ts --maintenance-only",
@ -60,6 +61,7 @@
"moderation:agency-pressure:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-agency-pressure-fixture.ps1",
"moderation:affect-state:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-affect-state-fixture.ps1",
"agents:repo-face-affect-mcp-fixture": "node scripts/smoke-repo-face-affect-mcp-fixture.mjs",
"huginn:persona-mcp-fixture": "node scripts/smoke-repo-face-affect-mcp-fixture.mjs",
"agents:repo-face-artifact-links:fixture": "tsx scripts/smoke-repo-face-artifact-links.ts",
"moderation:rumination:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-fixture.ps1",
"moderation:rumination:speech-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-speech-fixture.ps1",

View file

@ -50,12 +50,12 @@ try {
assertRuntimeInfo(runtimeResult);
const result = await client.callTool({
name: "read_repo_face_state",
name: "huginn_read_persona_state",
arguments: { identity },
});
if (result.isError) {
throw new Error(renderToolText(result) || `read_repo_face_state returned isError for ${identity}.`);
throw new Error(renderToolText(result) || `huginn_read_persona_state returned isError for ${identity}.`);
}
const structured = result.structuredContent;
@ -67,7 +67,10 @@ try {
throw new Error(`read_repo_face_state did not expose typedState.faceAffect for ${identity}.`);
}
if (!personaState || typeof personaState !== "object") {
throw new Error(`read_repo_face_state did not expose personaState for ${identity}.`);
throw new Error(`huginn_read_persona_state did not expose personaState for ${identity}.`);
}
if (structured?.steward?.id !== "huginn") {
throw new Error(`huginn_read_persona_state did not identify Huginn as steward for ${identity}.`);
}
if (personaState.schemaVersion !== "gamecult.persona_state.v0") {
throw new Error(`personaState has wrong schemaVersion for ${identity}: ${personaState.schemaVersion ?? "(missing)"}.`);