From 4fe801a0741950eb4dc05f8e08a7f7fde2097873 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 16 Jul 2026 03:46:18 +0100 Subject: [PATCH] Extract Persona text projection actuator --- .../src/persona-text-projection-actuator.ts | 81 ++++++++ notes/fresh-workspace-handoff.md | 1 + scripts/run-repo-face-heartbeats.ts | 188 +----------------- scripts/smoke-persona-initiative-engine.ts | 8 + state/evidence.jsonl | 1 + state/map.yaml | 3 +- 6 files changed, 96 insertions(+), 186 deletions(-) create mode 100644 apps/persona-scheduler/src/persona-text-projection-actuator.ts diff --git a/apps/persona-scheduler/src/persona-text-projection-actuator.ts b/apps/persona-scheduler/src/persona-text-projection-actuator.ts new file mode 100644 index 0000000..e6b1e20 --- /dev/null +++ b/apps/persona-scheduler/src/persona-text-projection-actuator.ts @@ -0,0 +1,81 @@ +import { spawn } from "node:child_process"; +import { appendFile, mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import type { loadConfig } from "@voidbot/config"; + +type VoidBotConfig = ReturnType; + +export function projectPersonaText(input: { prompt: string; config: VoidBotConfig; command: string; jobId: string; timeoutMs: number }): Promise { + const models = [...input.config.repoFaceHeartbeats.codexModels, input.config.repoFaceHeartbeats.codexModel, input.config.codexModel] + .filter((model, index, all): model is string => Boolean(model) && all.indexOf(model) === index); + return projectWithModels({ ...input, models, attemptedErrors: [] }); +} + +function projectWithModels(input: { prompt: string; config: VoidBotConfig; command: string; jobId: string; timeoutMs: number; models: string[]; attemptedErrors: string[] }): Promise { + return new Promise((resolveProjection, rejectProjection) => { + const startedAt = new Date().toISOString(); + const startedMs = Date.now(); + const model = input.models[0] ?? input.config.codexModel; + const reasoningEffort = input.config.repoFaceHeartbeats.codexModelReasoningEffort ?? "low"; + const args = [...input.config.codexExecArgs, "exec", "-m", model, "-c", 'approval_policy="never"', "-c", `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`, "--json", "--skip-git-repo-check", "-s", "read-only", "-"]; + const child = spawn(input.config.codexExecutable, args, { cwd: process.cwd(), env: process.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", rejectProjection); + child.stdin.end(input.prompt); + const timer = setTimeout(() => { child.kill(); }, input.timeoutMs); + child.on("close", (code, signal) => { + clearTimeout(timer); + const finishedAt = new Date().toISOString(); + void appendProjectionLog({ config: input.config, jobId: input.jobId, command: input.command, model, prompt: input.prompt, startedAt, finishedAt, durationMs: Date.now() - startedMs, exitCode: code, signal, stdout, stderr }).catch(() => undefined); + if (code !== 0) { + const attemptedErrors = [...input.attemptedErrors, `${model}: ${code ?? signal ?? "unknown"} ${`${stdout}\n${stderr}`.trim().slice(-2400)}`]; + if (input.models.length > 1 && isRetryablePersonaProjectionFailure({ stdout, stderr })) { + projectWithModels({ ...input, models: input.models.slice(1), attemptedErrors }).then(resolveProjection, rejectProjection); + return; + } + rejectProjection(new Error(`Repo Face ${input.command} failed: ${attemptedErrors.join("\n---\n")}`)); + return; + } + const text = extractLastPersonaProjectionMessage(stdout).trim(); + if (!text) { + rejectProjection(new Error("Repo Face state projector returned no visible agent message.")); + return; + } + resolveProjection(text); + }); + }); +} + +export function isRetryablePersonaProjectionFailure(input: { stdout: string; stderr: string }): boolean { + const text = `${input.stdout}\n${input.stderr}`.toLowerCase(); + return /quota|rate limit|rate-limit|usage limit|capacity|too many requests|(?:http|status|code|error)\s*429|429\s*(?:too many requests|rate)|insufficient_quota|model.*unavailable|model.*access|limit exceeded|tool .*not supported|unsupported.*tool/.test(text); +} + +async function appendProjectionLog(input: { config: VoidBotConfig; jobId: string; command: string; model: string; prompt: string; startedAt: string; finishedAt: string; durationMs: number; exitCode: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }): Promise { + const logPath = resolve(input.config.storageRoot, "logs", "model-outputs.jsonl"); + const record = { + schemaVersion: 1, loggedAt: new Date().toISOString(), jobId: input.jobId, command: input.command, turn: 1, model: input.model, + promptMarker: input.prompt.match(//)?.[1] ?? null, promptLength: input.prompt.length, + startedAt: input.startedAt, finishedAt: input.finishedAt, durationMs: input.durationMs, exitCode: input.exitCode, signal: input.signal, + timedOut: input.signal === "SIGTERM", handoffReason: null, usage: null, + finalMessage: extractLastPersonaProjectionMessage(input.stdout).trim() || null, + stdoutTail: input.stdout.slice(-4000), stderrTail: input.stderr.slice(-4000), toolCalls: [], commandExecutions: [], artifactRefs: {}, + }; + await mkdir(dirname(logPath), { recursive: true }); + await appendFile(logPath, `${JSON.stringify(record)}\n`, "utf8"); +} + +export function extractLastPersonaProjectionMessage(stdout: string): string { + const messages = stdout.split(/\r?\n/).filter((line) => line.trim()).map((line) => { + try { return JSON.parse(line) as { type?: string; item?: { type?: string; text?: string } }; } catch { return undefined; } + }).filter((event): event is { type?: string; item?: { type?: string; text?: string } } => Boolean(event)) + .filter((event) => event.type === "item.completed" && event.item?.type === "agent_message") + .map((event) => event.item?.text?.trim() ?? "").filter(Boolean); + return messages.at(-1) ?? stdout.trim(); +} diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index c1b6ae4..1124e44 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -211,6 +211,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - 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. - Yggdrasil matches that state as of deployed revision `13bdb7a`: all five native Persona registry paths are `.cc`, migrated files are owned by `voidbot:voidbot`, the full initiative and portable-migration smokes pass in the deployed scheduler container, and prompt assembly succeeds for Muninn, Sleipnir, Hermodr, Tengu, and メタめ with the correct authority language. Idunn's upstream comparison/deployment path is healthy and evaluates VoidBot on its bounded cadence. The swarm remains paused after these checks; do not interpret migration success as permission to resume autonomous traffic. - Deterministic state-packet assembly now lives in `persona-state-packet-projector.ts`, which takes typed state, registry/room evidence, pronoun guidance, curiosity facts, and an explicit observation time. The shell no longer owns section ordering or social-context composition. The downstream regex vocabulary cleaner was deleted: it was a symptom compensator rewriting ordinary words such as `witness` and `canary` after projection. A smoke proves literal source language survives the projector unchanged. +- Optional model-backed state projection now crosses `persona-text-projection-actuator.ts`. That actuator alone owns Codex args/process lifetime, ordered fallback models, timeout, output decoding, retry classification, and model-output JSONL telemetry. Focused smoke proves it chooses the last visible agent message, retries capacity/429 failures, and refuses to classify prompt-contract failures as model availability problems. The heartbeat shell lost another 182 lines and now only supplies the completed projection prompt plus runtime config. - 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/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index 0c224c7..ebb9557 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -1,7 +1,7 @@ import "dotenv/config"; import { spawn } from "node:child_process"; -import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; @@ -64,6 +64,7 @@ import { buildPersonaJurisdictionDiveDirective, buildPersonaTurnPrompt, renderPe import { projectGamecultPersonaState, projectNativePersonaBody } from "../apps/persona-scheduler/dist/persona-standard-state-projector.js"; import type { PersonaHumanPronounGuidance as RepoFaceHumanPronounGuidance } from "../apps/persona-scheduler/dist/persona-social-context-projector.js"; import { projectPersonaStatePacket } from "../apps/persona-scheduler/dist/persona-state-packet-projector.js"; +import { projectPersonaText } from "../apps/persona-scheduler/dist/persona-text-projection-actuator.js"; import { readPersonaHumanPronounGuidance } from "../apps/persona-scheduler/dist/persona-social-context-source.js"; import { readDiscordActivitySnapshot, @@ -826,7 +827,7 @@ async function renderRepoFaceMemorySurfaceForTurn( characterIdentity: renderPersonaIdentityDoctrine(identity), statePacket, modelProjectionEnabled: true, - projectText: (prompt) => runCodexTextProjection({ + projectText: (prompt) => projectPersonaText({ prompt, config, command: "repo-face-state-projector", @@ -989,189 +990,6 @@ function collectPromptImageAttachments(messages: SourceMessage[]): PromptImageAt return images.slice(0, 8); } -function runCodexTextProjection(input: { - prompt: string; - config: ReturnType; - command: string; - jobId: string; - timeoutMs: number; -}): Promise { - const models = [ - ...input.config.repoFaceHeartbeats.codexModels, - input.config.repoFaceHeartbeats.codexModel, - input.config.codexModel, - ].filter((model, index, all): model is string => Boolean(model) && all.indexOf(model) === index); - - return runCodexTextProjectionWithModels({ - ...input, - models, - attemptedErrors: [], - }); -} - -function runCodexTextProjectionWithModels(input: { - prompt: string; - config: ReturnType; - command: string; - jobId: string; - timeoutMs: number; - models: string[]; - attemptedErrors: string[]; -}): Promise { - return new Promise((resolveProjection, rejectProjection) => { - const startedAt = new Date().toISOString(); - const startedMs = Date.now(); - const model = input.models[0] ?? input.config.codexModel; - const reasoningEffort = input.config.repoFaceHeartbeats.codexModelReasoningEffort ?? "low"; - const args = [ - ...input.config.codexExecArgs, - "exec", - "-m", - model, - "-c", - 'approval_policy="never"', - "-c", - `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`, - "--json", - "--skip-git-repo-check", - "-s", - "read-only", - "-", - ]; - const child = spawn(input.config.codexExecutable, args, { - cwd: process.cwd(), - env: process.env, - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - child.on("error", rejectProjection); - child.stdin.end(input.prompt); - const timer = setTimeout(() => { - child.kill(); - }, input.timeoutMs); - child.on("close", (code, signal) => { - clearTimeout(timer); - const finishedAt = new Date().toISOString(); - const durationMs = Date.now() - startedMs; - void appendProjectionModelOutputLog({ - config: input.config, - jobId: input.jobId, - command: input.command, - model, - prompt: input.prompt, - startedAt, - finishedAt, - durationMs, - exitCode: code, - signal, - stdout, - stderr, - }).catch(() => undefined); - if (code !== 0) { - const diagnostics = `${stdout}\n${stderr}`.trim().slice(-2400); - const attemptedErrors = [ - ...input.attemptedErrors, - `${model}: ${code ?? signal ?? "unknown"} ${diagnostics}`, - ]; - if (input.models.length > 1 && isRetryableProjectionModelFailure({ stdout, stderr })) { - runCodexTextProjectionWithModels({ - ...input, - models: input.models.slice(1), - attemptedErrors, - }).then(resolveProjection, rejectProjection); - return; - } - rejectProjection(new Error(`Repo Face ${input.command} failed: ${attemptedErrors.join("\n---\n")}`)); - return; - } - const text = extractLastCodexAgentMessage(stdout).trim(); - if (!text) { - rejectProjection(new Error("Repo Face state projector returned no visible agent message.")); - return; - } - resolveProjection(text); - }); - }); -} - -function isRetryableProjectionModelFailure(input: { stdout: string; stderr: string }): boolean { - const text = `${input.stdout}\n${input.stderr}`.toLowerCase(); - return /quota|rate limit|rate-limit|usage limit|capacity|too many requests|(?:http|status|code|error)\s*429|429\s*(?:too many requests|rate)|insufficient_quota|model.*unavailable|model.*access|limit exceeded|tool .*not supported|unsupported.*tool/.test(text); -} - -async function appendProjectionModelOutputLog(input: { - config: ReturnType; - jobId: string; - command: string; - model: string; - prompt: string; - startedAt: string; - finishedAt: string; - durationMs: number; - exitCode: number | null; - signal: NodeJS.Signals | null; - stdout: string; - stderr: string; -}): Promise { - const logPath = resolve(input.config.storageRoot, "logs", "model-outputs.jsonl"); - const finalMessage = extractLastCodexAgentMessage(input.stdout).trim() || null; - const record = { - schemaVersion: 1, - loggedAt: new Date().toISOString(), - jobId: input.jobId, - command: input.command, - turn: 1, - model: input.model, - promptMarker: input.prompt.match(//)?.[1] ?? null, - promptLength: input.prompt.length, - startedAt: input.startedAt, - finishedAt: input.finishedAt, - durationMs: input.durationMs, - exitCode: input.exitCode, - signal: input.signal, - timedOut: input.signal === "SIGTERM", - handoffReason: null, - usage: null, - finalMessage, - stdoutTail: input.stdout.slice(-4000), - stderrTail: input.stderr.slice(-4000), - toolCalls: [], - commandExecutions: [], - artifactRefs: {}, - }; - await mkdir(dirname(logPath), { recursive: true }); - await appendFile(logPath, `${JSON.stringify(record)}\n`, "utf8"); -} - -function extractLastCodexAgentMessage(stdout: string): string { - const messages = stdout - .split(/\r?\n/) - .filter((line) => line.trim().length > 0) - .map((line) => { - try { - return JSON.parse(line) as { type?: string; item?: { type?: string; text?: string } }; - } catch { - return undefined; - } - }) - .filter((event): event is { type?: string; item?: { type?: string; text?: string } } => Boolean(event)) - .filter((event) => event.type === "item.completed" && event.item?.type === "agent_message") - .map((event) => event.item?.text?.trim() ?? "") - .filter((message) => message.length > 0); - - return messages.at(-1) ?? stdout.trim(); -} - async function readOptionalMemorySurface(path: string | undefined): Promise { if (!path) { return undefined; diff --git a/scripts/smoke-persona-initiative-engine.ts b/scripts/smoke-persona-initiative-engine.ts index 614569a..bc026dd 100644 --- a/scripts/smoke-persona-initiative-engine.ts +++ b/scripts/smoke-persona-initiative-engine.ts @@ -72,6 +72,7 @@ import { projectPersonaConversation, renderPersonaRoomTopicSaturation } from ".. import { buildPersonaJurisdictionDiveDirective, buildPersonaTurnPrompt } from "../apps/persona-scheduler/dist/persona-turn-prompt-projector.js"; import { projectGamecultPersonaState } from "../apps/persona-scheduler/dist/persona-standard-state-projector.js"; import { projectPersonaStatePacket } from "../apps/persona-scheduler/dist/persona-state-packet-projector.js"; +import { extractLastPersonaProjectionMessage, isRetryablePersonaProjectionFailure } from "../apps/persona-scheduler/dist/persona-text-projection-actuator.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"; @@ -109,6 +110,13 @@ const state: InitiativeState = { history: [], }; +assert.equal(extractLastPersonaProjectionMessage([ + JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "first" } }), + JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "final projection" } }), +].join("\n")), "final projection", "text projection actuator owns Codex event decoding"); +assert.equal(isRetryablePersonaProjectionFailure({ stdout: "", stderr: "HTTP 429 too many requests" }), true, "capacity failures may advance to the next configured projection model"); +assert.equal(isRetryablePersonaProjectionFailure({ stdout: "", stderr: "prompt contract rejected" }), false, "semantic or contract failures cannot hide behind model fallback"); + advanceInitiativeClockFromWallClock(state, new Date("2026-07-15T20:02:30.000Z")); assert.equal(state.initiativeClock, 12.5, "wall time advances the scheduler clock once"); applyPendingMentionPriority(state); diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 068997a..f0d428c 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -429,3 +429,4 @@ {"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."} {"ts":"2026-07-16T03:40:00Z","type":"verification","status":"ok","note":"Idunn deployed 13bdb7a on Yggdrasil. Persisted registry has five native Persona .cc paths and zero JSON paths; Muninn/Sleipnir/Hermodr projection-import stores plus Tengu canonical store are voidbot-owned. Deployed initiative and migration smokes pass, and live prompt assembly for all five native Personas reports projection authority for the three quarantined imports, canonical gamecult.persona_state.v0 for Tengu, and the existing typed Void path for Metame. Swarm stayed paused with zero queued turns."} {"ts":"2026-07-16T03:50:00Z","type":"implementation","status":"ok","note":"Deterministic Persona state-packet assembly moved from the heartbeat shell into persona-state-packet-projector.ts with explicit typed inputs and observation time. The old regex vocabulary cleaner plus dead narrative helpers were deleted; focused smoke proves literal source language containing 'witness artifact' is not rewritten downstream."} +{"ts":"2026-07-16T04:00:00Z","type":"implementation","status":"ok","note":"Codex-backed Persona text projection moved into persona-text-projection-actuator.ts, which owns model ordering, process arguments/lifetime, timeouts, output decoding, retry classification, and telemetry. Focused smoke proves last-agent-message extraction plus retry only for capacity/tool availability, and the heartbeat shell fell from 1250 to 1068 lines."} diff --git a/state/map.yaml b/state/map.yaml index 29c6183..6a62726 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -70,6 +70,7 @@ active_subgoals: - Keep the rebuilt moderation/self-state foundation on canonical CultCache `.cc` typed operations, never an editable JSON working projection. - The resident `apps/persona-scheduler` process inside `voidbot.service` owns repo Face and native Persona turn pulses on Yggdrasil. Its `initiative-engine.ts` owns participant reconciliation, operator heat/speed projection, semantic-pressure commits, wall-clock advancement, active-turn freeze/completion recovery, queue-result commits, stale rescheduling, mention priority, rest/idle eligibility, and ready-order selection as a pure state machine. `state-store.ts` is the sole persistence/migration port: canonical state is one typed `voidbot.persona_scheduler_state` record in `repo-face-heartbeats.cc`; the old JSON shape is accepted only as a one-time migration source and emitted afterward only as a debug/dashboard projection. Missing state is explicit, malformed CultCache fails closed, and the backing store promotes atomic MessagePack writes. Discord attention crosses through immutable per-mention `voidbot.persona_attention_command` CultCache inbox documents resolved from that same canonical path; the bot creates commands, while the scheduler alone ingests, deduplicates, commits, and acknowledges them after durable state. The bot can no longer read-modify-write the whole heartbeat state. `active-turn-source.ts` is the narrow Postgres/legacy-lock adapter: it reports active job witnesses and marks truly stale queue claims failed, but it cannot mutate initiative. `persona-state-source.ts` owns read-only typed Persona-state acquisition and wall-clock physiology projection. It reports typed mind and current rest facts without creating a missing state surface or writing sleep/speaking-pressure updates; `initiative-engine.ts` alone decides whether those rest facts admit a turn. `persona-memory-context-source.ts` consumes that observation, refreshes only the derived per-Persona vector corpus, runs semantic recall, and returns structured hits or an explicit unavailable observation; it cannot reload or mutate canonical Persona state, render prompt prose, select, or queue. `persona-memory-projector.ts` owns the deterministic-versus-model projection choice, projector prompt contract, minimum-output invariant, and schema/path leak rejection through an injected text-model port. It cannot acquire state, retrieve vectors, or queue turns. `discord-activity-source.ts` owns read-only Discord human-activity observation and idle-window derivation from explicit watched channels plus scheduler history; it cannot mutate initiative, consume attention, or select work. `semantic-pressure-source.ts` embeds eligible Persona affinity cards against recent permitted human messages and returns bounded pressure projections; only `initiative-engine.ts` may turn those facts into dynamic heat, readiness, interrupt receipts, or history. `turn-context-source.ts` owns raw current-room and nearby-channel Discord acquisition plus its bounded derived attachment cache; it returns chronological evidence and explicit snapshot failures without reading Persona state, composing prompts, or queuing work. The heartbeat orchestration script may project registered identities into neutral participant specs and affinity candidates, build the still-unextracted deterministic state packet, render source observations into prompt surfaces, and report external actuator results, but it cannot decide, persist, migrate, inspect queue liveness, poll Discord, operate vector stores, own model-projection policy, commit semantic pressure, mutate Persona physiology, or overwrite initiative fields. The Windows `GameCult Local Orchestrator` no longer owns Persona scheduling, and its repo-Face task installer/wrapper have been deleted. Void moderation, mood, watchdog, and Bifrost dispatch remain separate organs pending their own daemon migration; they must not be smuggled back into the Persona scheduler. - `persona-state-packet-projector.ts` owns pure assembly of typed memory sections, social context, topic attraction, curiosity facts, pressure sections, and human-clarity pressure into the deterministic Persona state packet. The orchestration shell supplies observations and one explicit observation time. It cannot rewrite projected language: the old regex vocabulary cleaner and its dead narrative helpers were deleted rather than moved. + - `persona-text-projection-actuator.ts` owns Codex process execution for optional model-backed Persona memory projection: ordered model fallback, read-only arguments, timeout, stdout/stderr capture, final-message decoding, retry classification, and model-output telemetry. It receives a complete prompt and returns text; it cannot acquire Persona/Discord state, select initiative, or queue work. Capacity/tool-availability failures may try the next configured model; prompt/contract failures do not. - `bifrost-governance-source.ts` owns the remaining Bifrost digest CLI adapter and converts command or parse failures into typed error topics. Persona prompt composition receives governance facts without knowing Bifrost process mechanics; `scripts/run-repo-face-heartbeats.ts` may not invoke the Bifrost CLI directly. - `initiative-engine.ts` is the only writer of scheduler state fields and history. Reconciliation, controls, pause/disabled skips, stale-turn recovery witnesses, semantic pressure, attention consumption, queue transitions, and tick finalization all cross named engine commit primitives. Queue/context actuators return results and cannot patch participants or history directly. - `turn-actuator.ts` owns lowering an assembled Persona prompt and evidence bundle into the canonical worker `ContextBundle` and approved `repo-face-rumination` job. It owns the worker actor, command, request ID, approval defaults, and `createJob` call, then returns only a queue receipt. The orchestration shell chooses no queue mechanics and the actuator cannot read Persona or initiative state. @@ -283,4 +284,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: Keep the Yggdrasil swarm paused. Audit the remaining `scripts/run-repo-face-heartbeats.ts` shell for dead helpers and mixed authorities after the state-packet extraction; select the next largest coherent owner (model projection execution/logging or prompt-assembly coordination), cut obsolete compensators first, then extract one bounded organ with a focused smoke. Do not unpause without an explicit controlled-live-fire decision. + next_action: Keep the Yggdrasil swarm paused. Extract prompt-assembly coordination from `scripts/run-repo-face-heartbeats.ts`: current-room/snapshot/governance acquisition should remain source calls, while memory/repo-activity/semantic-recall/conversation/global-doctrine projection and final prompt composition should cross one typed assembler boundary. Do not move source acquisition or queue authority into that organ, and do not unpause without an explicit controlled-live-fire decision.