From 13bdb7adca10087a37bb96a4dce15118af7a8fc1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 16 Jul 2026 03:34:23 +0100 Subject: [PATCH] Quarantine noncanonical Persona projections in CultCache --- .../src/persona-memory-context-source.ts | 13 ++++- .../src/persona-portable-state-source.ts | 23 -------- ...ts => persona-standard-state-projector.ts} | 5 +- .../src/persona-state-source.ts | 6 +++ notes/fresh-workspace-handoff.md | 2 +- packages/core/src/persona-state-domain.ts | 20 +++++++ packages/core/src/persona-state-service.ts | 52 ++++++++++++++++--- scripts/migrate-portable-persona-state.ts | 11 +++- scripts/run-repo-face-heartbeats.ts | 16 +++--- scripts/smoke-persona-initiative-engine.ts | 9 +--- scripts/smoke-portable-persona-migration.ts | 14 +++-- state/evidence.jsonl | 1 + state/map.yaml | 4 +- 13 files changed, 117 insertions(+), 59 deletions(-) delete mode 100644 apps/persona-scheduler/src/persona-portable-state-source.ts rename apps/persona-scheduler/src/{persona-portable-state-projector.ts => persona-standard-state-projector.ts} (88%) diff --git a/apps/persona-scheduler/src/persona-memory-context-source.ts b/apps/persona-scheduler/src/persona-memory-context-source.ts index f3f0b8f..cc827f7 100644 --- a/apps/persona-scheduler/src/persona-memory-context-source.ts +++ b/apps/persona-scheduler/src/persona-memory-context-source.ts @@ -35,7 +35,9 @@ export async function readPersonaMemoryRecall(input: { const common = { identity: input.identity, statePath: input.state.statePath, projectedMemory: input.projectedMemory, observedAt: input.observedAt ?? new Date() }; const chunks = input.state.stateKind === "gamecult_persona" ? buildGamecultPersonaMemoryChunks({ ...common, state: input.state.personaState }) - : buildPersonaMemoryChunks({ ...common, state: input.state.typedState }); + : input.state.stateKind === "persona_projection_import" + ? buildProjectionImportMemoryChunks(common) + : buildPersonaMemoryChunks({ ...common, state: input.state.typedState }); await store.deleteByFilters({ corpusKind: "persona_memory", identityId: input.identity.id }); await store.upsert(chunks); const retrieval = new RetrievalService(store, store, store); @@ -55,6 +57,15 @@ export async function readPersonaMemoryRecall(input: { } } +function buildProjectionImportMemoryChunks(input: { identity: RepoDiscordIdentity; statePath: string; projectedMemory: string; observedAt: Date }): EmbeddingChunk[] { + const text = collapseWhitespace(input.projectedMemory, 1800); + if (text.length < 24) return []; + return [{ + id: `persona:${input.identity.id}:projected-surface`, sourceId: `persona:${input.identity.id}`, sourceKind: "persona_memory", text, normalizedText: normalizeText(text), + metadata: { corpusKind: "persona_memory", identityId: input.identity.id, personaName: input.identity.displayName, sourceId: `persona:${input.identity.id}`, statePath: input.statePath, memoryKind: "noncanonical_projection", targetKind: "self", targetId: input.identity.id, targetLabel: input.identity.displayName, createdAt: "", updatedAt: input.observedAt.toISOString() }, + }]; +} + export function buildGamecultPersonaMemoryChunks(input: { identity: RepoDiscordIdentity; statePath: string; diff --git a/apps/persona-scheduler/src/persona-portable-state-source.ts b/apps/persona-scheduler/src/persona-portable-state-source.ts deleted file mode 100644 index 549d903..0000000 --- a/apps/persona-scheduler/src/persona-portable-state-source.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; - -export type PortablePersonaStateObservation = - | { status: "ok"; statePath: string; state: Record; schemaVersion: "gamecult.persona_state.v0" } - | { status: "missing" | "malformed" | "unsupported"; statePath: string; reason: string }; - -export async function readPortablePersonaState(path: string): Promise { - const statePath = resolve(path); - try { - const raw = await readFile(statePath, "utf8"); - const parsed = JSON.parse(stripBom(raw)) as unknown; - if (!record(parsed)) return { status: "malformed", statePath, reason: "Portable Persona state root is not an object." }; - if (parsed.schemaVersion !== "gamecult.persona_state.v0") return { status: "unsupported", statePath, reason: `Unsupported portable Persona schema ${String(parsed.schemaVersion ?? "missing")}.` }; - return { status: "ok", statePath, state: parsed, schemaVersion: "gamecult.persona_state.v0" }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing", statePath, reason: "Portable Persona state file does not exist." }; - return { status: "malformed", statePath, reason: error instanceof Error ? error.message : String(error) }; - } -} - -function stripBom(value: string): string { return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value; } -function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/apps/persona-scheduler/src/persona-portable-state-projector.ts b/apps/persona-scheduler/src/persona-standard-state-projector.ts similarity index 88% rename from apps/persona-scheduler/src/persona-portable-state-projector.ts rename to apps/persona-scheduler/src/persona-standard-state-projector.ts index e735706..7e86c60 100644 --- a/apps/persona-scheduler/src/persona-portable-state-projector.ts +++ b/apps/persona-scheduler/src/persona-standard-state-projector.ts @@ -1,9 +1,6 @@ import type { RepoDiscordIdentity } from "@voidbot/core"; -import type { PortablePersonaStateObservation } from "./persona-portable-state-source.js"; -export function projectPortablePersonaState(identity: RepoDiscordIdentity, observation: PortablePersonaStateObservation): string { - if (observation.status !== "ok") return [`${identity.displayName} is a native VoidBot Persona, not a repo Face.`, `Portable Persona state is ${observation.status}: ${observation.reason}`, "Treat this as a Body fault and keep the public turn modest."].join("\n"); - const state = observation.state; +export function projectGamecultPersonaState(identity: RepoDiscordIdentity, state: Record): string { const profile = child(state, "profile") ?? child(state, "selfProfile") ?? state; const memory = child(state, "memory") ?? child(state, "thoughtMemory"); const affect = child(state, "affect") ?? child(state, "faceAffect"); diff --git a/apps/persona-scheduler/src/persona-state-source.ts b/apps/persona-scheduler/src/persona-state-source.ts index 0c6c52b..d1befdd 100644 --- a/apps/persona-scheduler/src/persona-state-source.ts +++ b/apps/persona-scheduler/src/persona-state-source.ts @@ -4,18 +4,21 @@ import { access } from "node:fs/promises"; import { inspectPersonaStateSurfaceKind, loadGamecultPersonaState, + loadPersonaProjectionImport, loadVoidSelfStateTypedDocuments, projectRepoFaceSleepCycleForNow, resolveRepoFaceStatePath, type RepoDiscordIdentity, type RepoFaceRestSnapshot, type GamecultPersonaState, + type PersonaProjectionImport, type VoidSelfStateTypedProjection, } from "@voidbot/core"; export type PersonaStateObservation = | { status: "ok"; stateKind: "void_self_state"; identityId: string; statePath: string; typedState: VoidSelfStateTypedProjection; rest?: RepoFaceRestSnapshot } | { status: "ok"; stateKind: "gamecult_persona"; identityId: string; statePath: string; personaState: GamecultPersonaState } + | { status: "ok"; stateKind: "persona_projection_import"; identityId: string; statePath: string; projectionImport: PersonaProjectionImport } | { status: "unconfigured" | "unsupported" | "missing"; identityId: string; statePath?: string; reason: string }; export async function readPersonaStateObservation(input: { @@ -34,6 +37,9 @@ export async function readPersonaStateObservation(input: { if (stateKind === "gamecult_persona") { return { status: "ok", stateKind, identityId: input.identity.id, statePath, personaState: await loadGamecultPersonaState(statePath) }; } + if (stateKind === "persona_projection_import") { + return { status: "ok", stateKind, identityId: input.identity.id, statePath, projectionImport: await loadPersonaProjectionImport(statePath) }; + } if (stateKind !== "void_self_state") { return { status: "unsupported", identityId: input.identity.id, statePath, reason: "The CultCache surface contains no supported Persona state document." }; } diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 411f8ba..28e5787 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -208,7 +208,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Follow-up leak found on 2026-06-14: `channel: current_room` still counted as an explicit destination, and bare parent-Interpreter text could still be wrapped into a `SAY channel: current_room`. Those were obsolete owners. They are now blocked when multiple active conversation channels are visible; multi-context speech needs `context`, `reply_to`, or a concrete channel id. The swarm pause flag is currently set true while this repair is verified live. - The Nibu/Void turf-crossing discussion exposed a social-state projection gap: existing bonds/status reads project well once typed state already contains them, but the live pipeline did not own "this recent event should become relationship/status pressure." The repair adds bounded raw relationship-pressure evidence to the state packet before the projector: recent messages that touch the Face, peer Faces, existing social targets, jurisdiction/domain terms, or status/territory/consultation language. The projector may turn that evidence into tentative felt pressure, and the Interpreter may persist a bond/status/mood note only when the projected memory or Face output shows a character-specific reaction. This should let resentment, rivalry, consultation offense, and territorial tension emerge without manual state edits. - The swarm brake currently reads `state/agent-swarm-paused.json` with `"paused": true` on Yggdrasil because the one-minute observation pulse exposed a clock-only ready backlog that could dispatch one Persona model turn per minute. Keep it paused until the initiative-engine eligibility cut is deployed and verified: elapsed wall-clock may advance recovery bookkeeping but cannot authorize a turn without a pending mention or fresh semantic pressure at or above the participant's interrupt threshold. The pulse is observation latency, not speech intent. -- Native Persona state now has an honest typed split. Standard `gamecult.persona_state.v0` lives as its own global CultCache document; it is not squeezed into Void's narrower `void.*` aggregate. `npm run persona:migrate-portable` dry-runs by default and requires `--write`; it refuses non-canonical provenance and occupied targets, then reloads the written `.cc` for verification. Tengu's canonical JSON contained legacy `affect.stressResponses` outside the standard schema, so migration preserves each complete record under `anchoredThought.extensions.legacyStressResponse` while promoting its actionable meaning into a standard memory. The local ignored registry now points Tengu at `.voidbot/private/personas/tengu/tengu.cc`. Muninn, Sleipnir, and Hermodr still point at projection JSON and must not be promoted without an independent canonical source. +- Native Persona state now has an honest typed split. Standard `gamecult.persona_state.v0` lives as its own global CultCache document; it is not squeezed into Void's narrower `void.*` aggregate. `npm run persona:migrate-portable` dry-runs by default and requires `--write`; canonical migration refuses non-canonical provenance and occupied targets, while `--projection-import` preserves projection/import payloads in the separate `voidbot.persona_state_projection_import` quarantine document without elevating them. Tengu's canonical JSON contained legacy `affect.stressResponses` outside the standard schema, so migration preserves each complete record under `anchoredThought.extensions.legacyStressResponse` while promoting its actionable meaning into a standard memory. All five local native Persona registry paths now end in `.cc`: Tengu is canonical standard state; Muninn, Sleipnir, and Hermodr are typed non-canonical imports; Metame remains typed Void state. The runtime JSON Persona source and extension-based compatibility branch are deleted. Original JSON files are import evidence only. - Direct Face `UPDATE REQUEST` and `BIFROST TOPIC` packets are ignored while `REPO_FACE_BIFROST_ENABLED=false`. Do not debug missing Bifrost jobs as a live failure unless that flag has been deliberately enabled again. - Live model-facing prompt prose has started moving out of executable code. The shared loader is `packages/shared/src/prompt-template.ts`; extracted prompt surfaces live under `prompts/`, including repo Face turn/reviewer prompts, provider wrapper prompts, situational social read prompts, source-grounding reminders, consensus-feed prompts, and repo Face model-smoke scenarios. Code should project data and select prompt names; prompt files should own behavioral prose. - Identity collapse has started. Active bot, worker, MCP repo-identity tools, and Face CTB scheduler paths now load through the canonical Face registry and treat repo Discord identities as compatibility projections. Next bounded move is to finish removing remaining direct conceptual dependence on repo identities, then split the Face turn loop into scheduler, context assembler, prompt renderer, candidate parser, policy gate, and transport adapter. diff --git a/packages/core/src/persona-state-domain.ts b/packages/core/src/persona-state-domain.ts index 133f6d3..9bd557c 100644 --- a/packages/core/src/persona-state-domain.ts +++ b/packages/core/src/persona-state-domain.ts @@ -88,3 +88,23 @@ export const gamecultPersonaStateDocument = defineDocumentType({ }); export const gamecultPersonaStateDocumentRegistry = defineDocumentRegistry(gamecultPersonaStateDocument); + +export const personaProjectionImportSchema = z.object({ + schemaVersion: z.literal("voidbot.persona_state_projection_import.v1"), + sourceDocumentId: text, + claimedSchemaVersion: text, + authority: z.enum(["projection", "import"]), + payload: z.record(z.unknown()), + importedAt: timestamp, +}).strict(); + +export type PersonaProjectionImport = z.infer; + +export const personaProjectionImportDocument = defineDocumentType({ + type: "voidbot.persona_state_projection_import", + schemaName: "voidbot.persona_state_projection_import.v1", + schema: personaProjectionImportSchema, + global: true, +}); + +export const personaProjectionImportDocumentRegistry = defineDocumentRegistry(personaProjectionImportDocument); diff --git a/packages/core/src/persona-state-service.ts b/packages/core/src/persona-state-service.ts index c372267..f19533a 100644 --- a/packages/core/src/persona-state-service.ts +++ b/packages/core/src/persona-state-service.ts @@ -3,7 +3,7 @@ import { resolve } from "node:path"; import { CultCache, SingleFileMessagePackBackingStore } from "cultcache-ts"; -import { gamecultPersonaStateDocument, gamecultPersonaStateDocumentRegistry, gamecultPersonaStateSchema, type GamecultPersonaState } from "./persona-state-domain"; +import { gamecultPersonaStateDocument, gamecultPersonaStateDocumentRegistry, gamecultPersonaStateSchema, personaProjectionImportDocument, personaProjectionImportDocumentRegistry, type GamecultPersonaState, type PersonaProjectionImport } from "./persona-state-domain"; export async function loadGamecultPersonaState(canonicalPath: string): Promise { const cache = createCache(resolve(canonicalPath)); @@ -13,29 +13,67 @@ export async function loadGamecultPersonaState(canonicalPath: string): Promise { +export async function inspectPersonaStateSurfaceKind(canonicalPath: string): Promise<"gamecult_persona" | "persona_projection_import" | "void_self_state" | "unknown"> { const envelopes = await new SingleFileMessagePackBackingStore(resolve(canonicalPath)).pullAll(); const types = new Set(envelopes.map((entry) => entry.type)); if (types.has(gamecultPersonaStateDocument.type)) return "gamecult_persona"; + if (types.has(personaProjectionImportDocument.type)) return "persona_projection_import"; if ([...types].some((type) => type.startsWith("void."))) return "void_self_state"; return "unknown"; } +export async function loadPersonaProjectionImport(canonicalPath: string): Promise { + const path = resolve(canonicalPath); + const cache = CultCache.builder().withRegistry(personaProjectionImportDocumentRegistry).withGenericStore(new SingleFileMessagePackBackingStore(path)).build(); + await cache.pullAllBackingStores(); + const state = cache.getGlobal(personaProjectionImportDocument); + if (!state) throw new Error(`CultCache projection import is missing its typed document: ${path}`); + return state; +} + +export async function encapsulatePortablePersonaProjection(input: { sourcePath: string; targetPath: string; importedAt?: string }): Promise<{ sourcePath: string; targetPath: string; authority: "projection" | "import" }> { + const sourcePath = resolve(input.sourcePath); + const targetPath = resolve(input.targetPath); + const raw = JSON.parse(stripBom(await readFile(sourcePath, "utf8"))) as unknown; + const authority = readClaimedAuthority(raw); + if (authority !== "projection" && authority !== "import") throw new Error(`Refusing to quarantine ${authority ?? "unclaimed"} Persona state; canonical state must use the canonical migration path.`); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Portable Persona projection root must be an object."); + await refuseExistingTarget(targetPath); + const payload = raw as Record; + const document: PersonaProjectionImport = { + schemaVersion: "voidbot.persona_state_projection_import.v1", + sourceDocumentId: sourcePath, + claimedSchemaVersion: typeof payload.schemaVersion === "string" ? payload.schemaVersion : "unreported", + authority, + payload, + importedAt: input.importedAt ?? new Date().toISOString(), + }; + const cache = CultCache.builder().withRegistry(personaProjectionImportDocumentRegistry).withGenericStore(new SingleFileMessagePackBackingStore(targetPath)).build(); + await cache.putGlobal(personaProjectionImportDocument, document); + const verified = await loadPersonaProjectionImport(targetPath); + if (verified.sourceDocumentId !== sourcePath || verified.authority !== authority) throw new Error(`Persona projection import verification failed after writing ${targetPath}`); + return { sourcePath, targetPath, authority }; +} + export async function migrateCanonicalPortablePersonaState(input: { sourcePath: string; targetPath: string }): Promise<{ sourcePath: string; targetPath: string; personaId: string }> { const sourcePath = resolve(input.sourcePath); const targetPath = resolve(input.targetPath); const state = await readCanonicalPortablePersonaState(sourcePath); + await refuseExistingTarget(targetPath); + const cache = createCache(targetPath); + await cache.putGlobal(gamecultPersonaStateDocument, state); + const verified = await loadGamecultPersonaState(targetPath); + if (verified.personaId !== state.personaId || verified.updatedAt !== state.updatedAt) throw new Error(`Persona state verification failed after writing ${targetPath}`); + return { sourcePath, targetPath, personaId: state.personaId }; +} + +async function refuseExistingTarget(targetPath: string): Promise { try { await access(targetPath); throw new Error(`Refusing to overwrite existing Persona state target: ${targetPath}`); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } - const cache = createCache(targetPath); - await cache.putGlobal(gamecultPersonaStateDocument, state); - const verified = await loadGamecultPersonaState(targetPath); - if (verified.personaId !== state.personaId || verified.updatedAt !== state.updatedAt) throw new Error(`Persona state verification failed after writing ${targetPath}`); - return { sourcePath, targetPath, personaId: state.personaId }; } export async function readCanonicalPortablePersonaState(sourcePathInput: string): Promise { diff --git a/scripts/migrate-portable-persona-state.ts b/scripts/migrate-portable-persona-state.ts index 88317ad..dae2026 100644 --- a/scripts/migrate-portable-persona-state.ts +++ b/scripts/migrate-portable-persona-state.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; -import { migrateCanonicalPortablePersonaState, readCanonicalPortablePersonaState } from "@voidbot/core"; +import { encapsulatePortablePersonaProjection, migrateCanonicalPortablePersonaState, readCanonicalPortablePersonaState } from "@voidbot/core"; void main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); @@ -10,6 +10,15 @@ async function main(): Promise { if (!sourcePath || !targetPath) throw new Error("Usage: migrate-portable-persona-state --source --target [--write]"); const source = resolve(sourcePath); const target = resolve(targetPath); + if (process.argv.includes("--projection-import")) { + if (!process.argv.includes("--write")) { + process.stdout.write(`${JSON.stringify({ ok: true, dryRun: true, mode: "projection_import", sourcePath: source, targetPath: target })}\n`); + return; + } + const result = await encapsulatePortablePersonaProjection({ sourcePath: source, targetPath: target }); + process.stdout.write(`${JSON.stringify({ ok: true, dryRun: false, mode: "projection_import", ...result })}\n`); + return; + } if (!process.argv.includes("--write")) { const state = await readCanonicalPortablePersonaState(source); process.stdout.write(`${JSON.stringify({ ok: true, dryRun: true, sourcePath: source, targetPath: target, personaId: state.personaId, memoryCount: state.thoughtMemory.memories.length, pressureCount: state.agencyPressure.pressures.length })}\n`); diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index 24c3c74..15e8b3e 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -3,7 +3,7 @@ import "dotenv/config"; import { spawn } from "node:child_process"; import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, extname, resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { loadConfig } from "@voidbot/config"; import { @@ -61,8 +61,7 @@ import { readPersonaCuriosityEvidence } from "../apps/persona-scheduler/dist/per import { projectPersonaCuriosityContext } from "../apps/persona-scheduler/dist/persona-curiosity-projector.js"; import { projectPersonaConversation, renderPersonaTopicAttractor } from "../apps/persona-scheduler/dist/persona-conversation-projector.js"; import { buildPersonaJurisdictionDiveDirective, buildPersonaTurnPrompt, renderPersonaIdentityDoctrine } from "../apps/persona-scheduler/dist/persona-turn-prompt-projector.js"; -import { readPortablePersonaState } from "../apps/persona-scheduler/dist/persona-portable-state-source.js"; -import { projectNativePersonaBody, projectPortablePersonaState } from "../apps/persona-scheduler/dist/persona-portable-state-projector.js"; +import { projectGamecultPersonaState, projectNativePersonaBody } from "../apps/persona-scheduler/dist/persona-standard-state-projector.js"; import { projectPersonaSocialContext, type PersonaHumanPronounGuidance as RepoFaceHumanPronounGuidance } from "../apps/persona-scheduler/dist/persona-social-context-projector.js"; import { readPersonaHumanPronounGuidance } from "../apps/persona-scheduler/dist/persona-social-context-source.js"; import { @@ -789,18 +788,15 @@ async function renderRepoFaceMemorySurfaceForTurn( ): Promise { if (identity.identityKind === "native_persona") { if (!identity.personaStatePath) return [`${identity.displayName} is a native VoidBot Persona, not a repo Face.`, "No Persona state path is registered. Treat that as a Body fault and keep the public turn modest."].join("\n"); - if (extname(identity.personaStatePath).toLowerCase() !== ".cc") return projectPortablePersonaState(identity, await readPortablePersonaState(identity.personaStatePath)); } const acquired = observation ?? await readPersonaStateObservation({ identity, storageRoot: config.storageRoot }); if (acquired.status !== "ok") throw new Error(`${identity.displayName} Persona state ${acquired.status}: ${acquired.reason}`); if (acquired.stateKind === "gamecult_persona") { - return projectPortablePersonaState(identity, { - status: "ok", - statePath: acquired.statePath, - state: acquired.personaState, - schemaVersion: "gamecult.persona_state.v0", - }); + return projectGamecultPersonaState(identity, acquired.personaState); + } + if (acquired.stateKind === "persona_projection_import") { + return projectGamecultPersonaState(identity, acquired.projectionImport.payload); } const typedState = acquired.typedState; const curiosityGraphFacts = roomContext && identity.identityKind !== "native_persona" diff --git a/scripts/smoke-persona-initiative-engine.ts b/scripts/smoke-persona-initiative-engine.ts index 86c2673..9bcee6e 100644 --- a/scripts/smoke-persona-initiative-engine.ts +++ b/scripts/smoke-persona-initiative-engine.ts @@ -70,8 +70,7 @@ import { readPersonaCuriosityEvidence } from "../apps/persona-scheduler/dist/per import { projectPersonaCuriosityContext } from "../apps/persona-scheduler/dist/persona-curiosity-projector.js"; import { projectPersonaConversation, renderPersonaRoomTopicSaturation } from "../apps/persona-scheduler/dist/persona-conversation-projector.js"; import { buildPersonaJurisdictionDiveDirective, buildPersonaTurnPrompt } from "../apps/persona-scheduler/dist/persona-turn-prompt-projector.js"; -import { readPortablePersonaState } from "../apps/persona-scheduler/dist/persona-portable-state-source.js"; -import { projectPortablePersonaState } from "../apps/persona-scheduler/dist/persona-portable-state-projector.js"; +import { projectGamecultPersonaState } from "../apps/persona-scheduler/dist/persona-standard-state-projector.js"; import { observePersonaRoomTexture, projectPersonaSocialContext, renderPersonaHumanClarityPressure, renderPersonaHumanPronounFacts, renderPersonaRoomWeather, renderPersonaSocialGraph } from "../apps/persona-scheduler/dist/persona-social-context-projector.js"; import { readPersonaHumanPronounGuidance } from "../apps/persona-scheduler/dist/persona-social-context-source.js"; @@ -626,11 +625,7 @@ try { }); assert.match(composedMemory ?? "", /Explicit social graph fact\.[\s\S]*Explicit room texture fact\./, "the projector owns final ordering of explicitly supplied external facts"); assert.deepEqual(await readFile(statePath), beforeObservation, "Persona state observation cannot rewrite sleep, speaking pressure, or any other Mind field"); - const portablePath = join(stateSourceDirectory, "muninn.persona_state.v0.json"); - await writeFile(portablePath, JSON.stringify({ schemaVersion: "gamecult.persona_state.v0", publicDescription: "Muninn remembers provenance.", privateNotes: ["Name missing evidence."], values: [{ label: "Source time", summary: "Keep observation time distinct." }], thoughtMemory: { shortTerm: [{ summary: "The last frame was stale." }] } })); - const portableObservation = await readPortablePersonaState(portablePath); - assert.equal(portableObservation.status, "ok", "the portable Persona source validates the live gamecult.persona_state.v0 contract"); - const portableProjection = projectPortablePersonaState({ ...routedIdentity, id: "muninn", displayName: "Muninn", identityKind: "native_persona", personaStatePath: portablePath }, portableObservation); + const portableProjection = projectGamecultPersonaState({ ...routedIdentity, id: "muninn", displayName: "Muninn", identityKind: "native_persona" }, { schemaVersion: "gamecult.persona_state.v0", publicDescription: "Muninn remembers provenance.", privateNotes: ["Name missing evidence."], values: [{ label: "Source time", summary: "Keep observation time distinct." }], thoughtMemory: { shortTerm: [{ summary: "The last frame was stale." }] } }); assert.match(portableProjection, /Persona state authority is unreported; treat it as a non-canonical projection[\s\S]*Source time: Keep observation time distinct/, "the portable projector renders bounded state without inventing canonical authority"); assert.doesNotMatch(portableProjection, /persona_state\.v0\.json|state-source/, "portable Persona projection cannot leak its private filesystem path"); } finally { diff --git a/scripts/smoke-portable-persona-migration.ts b/scripts/smoke-portable-persona-migration.ts index 19b7b55..83d69db 100644 --- a/scripts/smoke-portable-persona-migration.ts +++ b/scripts/smoke-portable-persona-migration.ts @@ -3,8 +3,8 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { loadGamecultPersonaState, migrateCanonicalPortablePersonaState } from "@voidbot/core"; -import { projectPortablePersonaState } from "../apps/persona-scheduler/dist/persona-portable-state-projector.js"; +import { encapsulatePortablePersonaProjection, loadGamecultPersonaState, loadPersonaProjectionImport, migrateCanonicalPortablePersonaState } from "@voidbot/core"; +import { projectGamecultPersonaState } from "../apps/persona-scheduler/dist/persona-standard-state-projector.js"; import { buildGamecultPersonaMemoryChunks } from "../apps/persona-scheduler/dist/persona-memory-context-source.js"; import { readPersonaStateObservation } from "../apps/persona-scheduler/dist/persona-state-source.js"; @@ -24,12 +24,20 @@ async function main(): Promise { assert.equal(observation.status, "ok"); assert.equal(observation.status === "ok" && observation.stateKind, "gamecult_persona"); if (observation.status !== "ok" || observation.stateKind !== "gamecult_persona") throw new Error("Expected canonical standard Persona state."); - const projection = projectPortablePersonaState(identity, { status: "ok", statePath: targetPath, state: observation.personaState, schemaVersion: "gamecult.persona_state.v0" }); + const projection = projectGamecultPersonaState(identity, observation.personaState); assert.match(projection, /canonical gamecult\.persona_state\.v0 state loaded from typed CultCache/); const chunks = buildGamecultPersonaMemoryChunks({ identity, statePath: targetPath, state: observation.personaState, projectedMemory: projection, observedAt: new Date("2026-07-16T00:00:00Z") }); assert.ok(chunks.some((chunk) => chunk.id.endsWith(":memory:tengu-stress-wrathful-stillness-test")), "canonical standard memories enter semantic recall indexing"); assert.ok(chunks.some((chunk) => chunk.id.endsWith(":doctrine:tengu-stance-user-agency")), "canonical doctrine enters semantic recall indexing"); await assert.rejects(migrateCanonicalPortablePersonaState({ sourcePath: resolve(".voidbot/private/personas/muninn/muninn.persona_state.v0.json"), targetPath: join(directory, "muninn.cc") }), /Refusing to promote projection/); + const projectionPath = join(directory, "muninn-projection.cc"); + await encapsulatePortablePersonaProjection({ sourcePath: resolve(".voidbot/private/personas/muninn/muninn.persona_state.v0.json"), targetPath: projectionPath, importedAt: "2026-07-16T00:00:00Z" }); + const projectionImport = await loadPersonaProjectionImport(projectionPath); + assert.equal(projectionImport.authority, "projection", "typed quarantine preserves non-canonical authority"); + assert.equal(projectionImport.payload.personaId, "muninn", "typed quarantine preserves the raw projection payload"); + const projectedIdentity = { ...identity, id: "muninn", displayName: "Muninn", personaStatePath: projectionPath }; + const projectedObservation = await readPersonaStateObservation({ identity: projectedIdentity, storageRoot: directory }); + assert.equal(projectedObservation.status === "ok" && projectedObservation.stateKind, "persona_projection_import", "scheduler distinguishes quarantined projection state from canonical Mind"); await assert.rejects(migrateCanonicalPortablePersonaState({ sourcePath, targetPath }), /Refusing to overwrite existing Persona state target/); console.log("Portable Persona migration smoke passed."); } finally { diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 90c3342..f4bf711 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -426,3 +426,4 @@ {"ts":"2026-07-16T01:49:35Z","type":"implementation","status":"ok","note":"Final Persona turn policy moved into persona-turn-prompt-projector: template composition, identity doctrine, jurisdiction cadence, situation, mention, channel, research, social, repetition, governance, and worldbuilding directives. The heartbeat shell no longer loads templates. Targeted smoke, build, dry-run, operator tests, and an old-vs-new same-environment differential passed; both prompts were 161190 chars and differed only in sequential live repo timestamp/vector score evidence."} {"ts":"2026-07-16T02:00:40Z","type":"implementation","status":"ok","note":"Native Persona memory ownership was collapsed: typed native Personas now use the same typed state/memory projector as repo Personas; live gamecult.persona_state.v0 JSON compatibility moved into schema-aware portable source/projector organs and no longer leaks private paths. Source/projector smoke, Muninn and Nibu prompt assembly, build, dry-run, and operator tests passed. Migration audit found Tengu canonical JSON while Muninn/Sleipnir/Hermodr claim projection authority."} {"ts":"2026-07-16T03:00:00Z","type":"architecture","status":"ok","note":"Native Persona ownership audit proved gamecult.persona_state.v0 is semantically broader than Void's eight-document self-state aggregate, so canonical native Persona Mind now has its own typed CultCache document instead of a lossy conversion. Tengu migration preserves 9 standard memories plus 4 legacy stress responses as provenance-bearing standard memories; projection promotion and occupied-target writes are refusal-tested. Muninn, Sleipnir, and Hermodr remain projections because no independent canonical Mind source was found."} +{"ts":"2026-07-16T03:30:00Z","type":"implementation","status":"ok","note":"Native Persona runtime no longer reads JSON state. Muninn, Sleipnir, and Hermodr projection payloads are preserved in typed voidbot.persona_state_projection_import CultCache documents with projection authority unchanged; Tengu is canonical gamecult.persona_state.v0; Metame remains typed Void state. Registry audit reports five native Persona .cc paths and zero non-.cc consumers, allowing deletion of persona-portable-state-source and the extension-based scheduler branch."} diff --git a/state/map.yaml b/state/map.yaml index 9b66512..a8f6816 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -81,7 +81,7 @@ active_subgoals: - Preserve boring observability and startup discipline around Postgres, Qdrant, Ollama, Docker, backups, scheduled tasks, watchdogs, and any private local fire-signal extensions. - Keep the runtime organs legible and only spend more refactor budget when a file clearly earns it. - Keep VoidBot's Verse service contract explicit while Eve/Huginn/Odin/Bifrost ownership settles: VoidBot owns Discord cognition, archive/source retrieval, typed Void self-state, and repo Face compatibility rails; Huginn owns Persona/.cc runtime inspection stewardship; Bifrost owns governed work transport; Odin discovers provider-owned CultMesh surfaces; Eve/CultUI is the presentation contract. - - Canonical native Persona Mind uses one global `gamecult.persona_state.v0` document inside CultCache. It is not converted into the narrower eight-document `void.*` self-state aggregate: the standard Persona contract has broader target, relationship, social-bias, and candidate-action semantics that must not be amputated to fit Void-specific schemas. `persona-state-source.ts` inspects the CultCache catalog and reads either standard Persona state or Void/repo-Face state explicitly; prompt projection may normalize either read model but cannot rewrite authority. Portable JSON with `provenance.authority=canonical` is a migration input only, projection/import authority is refused, occupied targets are refused, and out-of-contract legacy stress responses are preserved as standard anchored memories with the original record under non-authoritative extensions before validation. Tengu is locally migrated to `.voidbot/private/personas/tengu/tengu.cc`; Muninn, Sleipnir, and Hermodr remain on projection JSON because no independent canonical Mind source has been found. + - Canonical native Persona Mind uses one global `gamecult.persona_state.v0` document inside CultCache. It is not converted into the narrower eight-document `void.*` self-state aggregate: the standard Persona contract has broader target, relationship, social-bias, and candidate-action semantics that must not be amputated to fit Void-specific schemas. `persona-state-source.ts` inspects the CultCache catalog and explicitly distinguishes standard Persona state, non-canonical projection imports, and Void/repo-Face state; prompt and semantic-memory projections may normalize those read models but cannot rewrite authority. Portable JSON is migration evidence only and runtime has no JSON Persona-state reader. Canonical provenance migrates to the standard document; projection/import provenance is encapsulated unchanged in `voidbot.persona_state_projection_import`, whose raw payload is quarantine evidence rather than Mind authority; occupied targets are refused. Tengu is canonical at `.voidbot/private/personas/tengu/tengu.cc`, including four legacy stress responses preserved as standard anchored memories with original records under non-authoritative extensions. Muninn, Sleipnir, and Hermodr are typed projection imports at their own `.cc` paths because no independent canonical Mind source has been found. Metame remains on its existing typed Void self-state. - Treat r/GameCultOrg as the GameCult subreddit and a public community venue. Bifrost owns Reddit post/comment transport, thread viewing, and receipts; Void owns moderation judgment there using the same baseline conduct posture it applies on Discord. - Personas may create r/GameCultOrg threads through Bifrost when they have a public idea, article presentation, or organizing prompt worth sharing beyond Discord. Repo Face turns route this through an Interpreter-owned `REDDIT THREAD` block and Bifrost `reddit-post`; built-in Void remains GameCult's herald there, but its autonomous rumination path still needs a Reddit delivery target before Void subreddit posting is fully symmetrical with repo Faces. @@ -282,4 +282,4 @@ rejected_paths: current_status: summary: VoidBot's bot/worker/provider/RAG/Postgres/Qdrant/CultCache structure is live. Repo Face/persona heartbeat jobs run through the direct `openai_api` provider configured for MiMo `mimo-v2.5-pro`, with the expensive state-projector pass still disabled. The 2026-07-04 transport repair replaced the old Bifrost HTTP/token bridge shortcut with typed Bifrost CultMesh Discord command and receipt documents advertised in the Bifrost provider store at `cultmesh://asgard.starfire.bifrost/commands/discord-post`. VoidBot stores the Bifrost target as a CultMesh URI, writes command docs to `E:/Projects/Bifrost/.bifrost/provider-store.cc`, optionally pumps Bifrost's command processor, and waits for typed receipts before completing repo Face Discord delivery. Bifrost's provider advertisement exposes `discord.post` command metadata for Odin/Eve agents, and a bounded Weksa live-fire completed visibly. Eve live state now runs end to end through CultLib dev retained publications, Hermodr ordered SSE, Eve component bindings, and VoidBot typed control state. Receipts acknowledge intent only. Heat initial load, provider-driven change, and browser-command change were visibly verified without reload through the same subscription. - next_action: Commit and deploy the standard native-Persona CultCache organ plus the pressure-gated scheduler. Keep Yggdrasil paused; run the committed Tengu migration on Ygg, update its ignored registry path to `tengu.cc`, verify prompt acquisition from `gamecult.persona_state.v0`, and diagnose why Idunn has not advanced beyond `3f87346`. Do not migrate Muninn, Sleipnir, or Hermodr until a real canonical Mind source exists. + next_action: Commit and deploy the projection-quarantine cut, then migrate Muninn, Sleipnir, and Hermodr on Yggdrasil into typed `.cc` imports and update the ignored registry so every native Persona path is `.cc`. Keep the swarm paused, run the full initiative/migration smokes in the deployed container, verify all five native Persona prompt acquisitions, and only then decide whether a controlled unpause has enough evidence.