Move Persona scheduler state to CultCache

This commit is contained in:
Meta Vens 2026-07-16 00:27:01 +01:00
commit 567f0e0226
14 changed files with 213 additions and 37 deletions

View file

@ -9,6 +9,7 @@
"@voidbot/core": "0.1.0",
"@voidbot/rag": "0.1.0",
"@voidbot/shared": "0.1.0",
"cultcache-ts": "file:../../vendor/cultcache-ts",
"dotenv": "^16.4.0"
}
}

View file

@ -1,10 +1,19 @@
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import {
CultCache,
SingleFileMessagePackBackingStore,
defineDocumentType,
type CultCacheSchema,
} from "cultcache-ts";
import {
acknowledgeRepoFaceMentionInbox,
readRepoFaceMentionInbox,
REPO_FACE_HEARTBEAT_SCHEMA_VERSION,
resolveRepoFaceHeartbeatDebugProjectionPath,
resolveRepoFaceHeartbeatStatePath,
type RepoFacePendingMention,
} from "@voidbot/core";
@ -22,12 +31,39 @@ type PersistedStateCandidate = Omit<Partial<PersonaSchedulerState>, "participant
participants?: Array<Partial<InitiativeParticipant> & { nextReadyAt?: string }>;
};
const PERSONA_SCHEDULER_STATE_RECORD_KEY = "persona-scheduler";
const personaSchedulerStateDefinition = defineDocumentType<CultCacheSchema<PersonaSchedulerState>>({
type: "voidbot.persona_scheduler_state",
schemaName: "voidbot.persona_scheduler_state",
global: false,
name: () => PERSONA_SCHEDULER_STATE_RECORD_KEY,
schema: {
parse(input: unknown): PersonaSchedulerState {
return parseCurrentPersonaSchedulerState(input, "CultCache document");
},
},
});
export async function readPersonaSchedulerState(path: string, nowMs = Date.now()): Promise<PersonaSchedulerState> {
const canonicalPath = resolveRepoFaceHeartbeatStatePath(path);
if (await pathExists(canonicalPath)) {
const cache = createPersonaSchedulerStateCache(canonicalPath);
try {
await cache.pullAllBackingStores();
const state = cache.get(personaSchedulerStateDefinition, PERSONA_SCHEDULER_STATE_RECORD_KEY);
if (!state) throw new Error("canonical scheduler record is missing");
return mergeMentionInbox(canonicalPath, state);
} catch (error) {
throw new Error(`Persona scheduler CultCache state at ${canonicalPath} is malformed; refusing to replace it with empty state.`, { cause: error });
}
}
const legacyPath = resolveRepoFaceHeartbeatDebugProjectionPath(canonicalPath);
let raw: string;
try {
raw = await readFile(path, "utf8");
raw = await readFile(legacyPath, "utf8");
} catch (error) {
if (isNodeError(error, "ENOENT")) return mergeMentionInbox(path, newPersonaSchedulerState());
if (isNodeError(error, "ENOENT")) return mergeMentionInbox(canonicalPath, newPersonaSchedulerState());
throw error;
}
@ -35,43 +71,60 @@ export async function readPersonaSchedulerState(path: string, nowMs = Date.now()
try {
parsed = JSON.parse(stripLeadingBom(raw)) as PersistedStateCandidate;
} catch (error) {
throw new Error(`Persona scheduler state at ${path} is malformed; refusing to replace it with empty state.`, { cause: error });
throw new Error(`Legacy Persona scheduler state at ${legacyPath} is malformed; refusing to replace it with empty state.`, { cause: error });
}
let migrated: PersonaSchedulerState;
if (parsed.schemaVersion === REPO_FACE_HEARTBEAT_SCHEMA_VERSION) {
if (!Array.isArray(parsed.participants)) {
throw new Error(`Persona scheduler state at ${path} has no participants array.`);
}
return mergeMentionInbox(path, {
schemaVersion: REPO_FACE_HEARTBEAT_SCHEMA_VERSION,
initiativeClock: finiteOr(parsed.initiativeClock, 0),
baseRecoveryMinutes: finiteOr(parsed.baseRecoveryMinutes, 10),
globalHeat: finiteOr(parsed.globalHeat, 1),
lastTickAt: parsed.lastTickAt,
participants: parsed.participants as InitiativeParticipant[],
history: Array.isArray(parsed.history) ? parsed.history : [],
pendingMentions: Array.isArray(parsed.pendingMentions)
? parsed.pendingMentions.filter(isRepoFacePendingMention)
: [],
});
migrated = parseCurrentPersonaSchedulerState(parsed, legacyPath);
} else if (Array.isArray(parsed.participants)) {
migrated = migrateLegacyHeartbeatState(parsed, nowMs);
} else {
throw new Error(`Legacy Persona scheduler state at ${legacyPath} has unsupported schema ${String(parsed.schemaVersion ?? "unknown")}.`);
}
if (Array.isArray(parsed.participants)) return mergeMentionInbox(path, migrateLegacyHeartbeatState(parsed, nowMs));
throw new Error(`Persona scheduler state at ${path} has unsupported schema ${String(parsed.schemaVersion ?? "unknown")}.`);
migrated.history.push({
type: "storage_migrated",
fromPath: legacyPath,
toPath: canonicalPath,
migratedAt: new Date(nowMs).toISOString(),
});
migrated.history = migrated.history.slice(-80);
await writeCanonicalPersonaSchedulerState(canonicalPath, migrated);
return mergeMentionInbox(canonicalPath, migrated);
}
export async function writePersonaSchedulerState(path: string, state: PersonaSchedulerState): Promise<void> {
const canonicalPath = resolveRepoFaceHeartbeatStatePath(path);
await writeCanonicalPersonaSchedulerState(canonicalPath, state);
await acknowledgeRepoFaceMentionInbox(canonicalPath, durableMentionIds(state));
await writeDebugProjection(resolveRepoFaceHeartbeatDebugProjectionPath(canonicalPath), state).catch(() => undefined);
}
async function writeCanonicalPersonaSchedulerState(path: string, state: PersonaSchedulerState): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const cache = createPersonaSchedulerStateCache(path);
if (await pathExists(path)) await cache.pullAllBackingStores();
await cache.put(personaSchedulerStateDefinition, PERSONA_SCHEDULER_STATE_RECORD_KEY, state);
}
async function writeDebugProjection(path: string, state: PersonaSchedulerState): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
try {
await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
await rename(temporaryPath, path);
await acknowledgeRepoFaceMentionInbox(path, durableMentionIds(state));
} finally {
await rm(temporaryPath, { force: true }).catch(() => undefined);
}
}
function createPersonaSchedulerStateCache(path: string): CultCache {
return CultCache.builder()
.withDocumentType(personaSchedulerStateDefinition)
.withGenericStore(new SingleFileMessagePackBackingStore(path))
.build();
}
async function mergeMentionInbox(path: string, state: PersonaSchedulerState): Promise<PersonaSchedulerState> {
const known = durableMentionIds(state);
const incoming = await readRepoFaceMentionInbox(path);
@ -111,6 +164,35 @@ export function newPersonaSchedulerState(): PersonaSchedulerState {
};
}
function parseCurrentPersonaSchedulerState(input: unknown, source: string): PersonaSchedulerState {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new Error(`Persona scheduler state from ${source} must be an object.`);
}
const parsed = input as PersistedStateCandidate;
if (parsed.schemaVersion !== REPO_FACE_HEARTBEAT_SCHEMA_VERSION) {
throw new Error(`Persona scheduler state from ${source} has unsupported schema ${String(parsed.schemaVersion ?? "unknown")}.`);
}
if (!Array.isArray(parsed.participants) || !parsed.participants.every(isInitiativeParticipant)) {
throw new Error(`Persona scheduler state from ${source} has invalid participants.`);
}
if (parsed.history !== undefined && (!Array.isArray(parsed.history) || !parsed.history.every(isRecord))) {
throw new Error(`Persona scheduler state from ${source} has invalid history.`);
}
if (parsed.pendingMentions !== undefined && (!Array.isArray(parsed.pendingMentions) || !parsed.pendingMentions.every(isRepoFacePendingMention))) {
throw new Error(`Persona scheduler state from ${source} has invalid pending mentions.`);
}
return {
schemaVersion: REPO_FACE_HEARTBEAT_SCHEMA_VERSION,
initiativeClock: finiteOr(parsed.initiativeClock, 0),
baseRecoveryMinutes: finiteOr(parsed.baseRecoveryMinutes, 10),
globalHeat: finiteOr(parsed.globalHeat, 1),
lastTickAt: typeof parsed.lastTickAt === "string" ? parsed.lastTickAt : undefined,
participants: parsed.participants,
history: (parsed.history ?? []) as Array<Record<string, unknown>>,
pendingMentions: (parsed.pendingMentions ?? []) as RepoFacePendingMention[],
};
}
function migrateLegacyHeartbeatState(parsed: PersistedStateCandidate, nowMs: number): PersonaSchedulerState {
const legacyBase = finiteOr(parsed.baseIntervalMinutes, 30);
const baseRecoveryMinutes = Math.max(5, legacyBase / 3);
@ -186,6 +268,39 @@ function isRepoFacePendingMention(value: unknown): value is RepoFacePendingMenti
);
}
function isInitiativeParticipant(value: unknown): value is InitiativeParticipant {
if (!isRecord(value)) return false;
return (
typeof value.identityId === "string" &&
["repo_face", "native_persona", "system_agent"].includes(String(value.participantKind)) &&
["repo_face_rumination", "void_moderation"].includes(String(value.turnKind)) &&
typeof value.repoName === "string" &&
typeof value.displayName === "string" &&
["active", "blocked", "withdrawn", "offscreen"].includes(String(value.status)) &&
[
value.initiativeSpeed,
value.reactionBias,
value.interruptThreshold,
value.currentLoad,
value.heat,
value.dynamicHeat,
value.responsePressure,
value.effectiveSpeed,
value.baseRecoveryMinutes,
value.nextTurnAt,
value.queuedCount,
].every((entry) => typeof entry === "number" && Number.isFinite(entry)) &&
Array.isArray(value.groups) &&
Array.isArray(value.responsePressureEvidence) &&
Array.isArray(value.semanticInterruptReceipts) &&
Array.isArray(value.constraints)
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function finiteOr(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
@ -197,3 +312,13 @@ function stripLeadingBom(input: string): string {
function isNodeError(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch (error) {
if (isNodeError(error, "ENOENT")) return false;
throw error;
}
}

View file

@ -710,10 +710,19 @@
},
{
"id": "voidbot.repo_face_heartbeat_state",
"path": ".voidbot/status/repo-face-heartbeats.cc",
"owner": "VoidBot CTB scheduler",
"contains": [
"canonical typed scheduler state"
]
},
{
"id": "voidbot.repo_face_heartbeat_debug_projection",
"path": ".voidbot/status/repo-face-heartbeats.json",
"owner": "VoidBot CTB scheduler",
"contains": [
"debug/status source"
"derived debug/status projection",
"not mutation authority"
]
},
{

View file

@ -143,7 +143,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- Repo Face state projection is no longer allowed to be deterministic schema slurry. `scripts/run-repo-face-heartbeats.ts` builds a compact state packet, then calls the model prompt at `prompts/repo-face-state-projector.prompt.md` to turn it into character-facing prose before the Face sees it. `REPO_FACE_STATE_PROJECTOR_ENABLED=false` exists only as an emergency isolation switch; both the model output and fallback packet reject schema/prompt-construction leaks such as `grants:`, `jurisdictions:`, `Face of`, repo paths, and "do not prompt" phrasing.
- First role-addressed chat also initializes the target repo's local `.voidbot` home. `ensureRepoFaceInitialized` resolves `repoPath` from the registry, `SOURCE_REPO_ROOT`, or `E:/Projects/<repoName>`, creates `voice/`, `state/`, `birth/`, and `logs/`, writes `voice/identity.json`, then starts `scripts/run-repo-face-birth.mjs` detached. That worker runs EpiphanyAgent's `epiphany-repo-personality scout` and `epiphany-repo-birth-runner` so newborn personality/memory/trajectory packets come from repo contents and git history. Status and logs live in the target repo under `.voidbot/birth/voidbot-face-birth.json` and `.voidbot/logs/voidbot-face-birth.log`.
- Manual newborn/strengthening passes should use `prompts/repo-face-personality-seed.prompt.md`. It makes the intended rite explicit: inspect repo docs/source/state/history, migrate useful legacy `memory.json` material into typed `.cc` memories, seed values/needs/agency/social state only when they will change behavior, and keep `.voidbot/voice` plus `.voidbot/state` as the repo-local home.
- Agent turns use one CTB/Final-Fantasy-X-style initiative queue. `apps/persona-scheduler` owns the resident one-minute pulse on Yggdrasil. `initiative-engine.ts` owns participant reconciliation plus clock, active-turn freeze/recovery, queue-result commits, eligibility, and ready ordering; `state-store.ts` alone owns load/migration/atomic persistence of scheduler state. Discord mention ingress is no longer a second whole-state writer: the bot creates immutable per-message `voidbot.persona_attention_command` CultCache inbox documents, and the scheduler ingests/commits/acknowledges them with replay-safe ordering. `active-turn-source.ts` alone reads Persona job liveness from Postgres/the remaining Void lock and may fail a stale queue claim; it returns facts to the engine and cannot edit initiative. `discord-activity-source.ts` owns read-only human-activity polling and idle-window derivation; it excludes bot traffic and cannot write state, consume mentions, or select work. The configured scheduler JSON path is isolated legacy storage behind the state-store port, not a second owner or the desired final state shape; replace it with CultCache rather than teaching orchestration about another format. `scripts/run-repo-face-heartbeats.ts` may build neutral specs from registered identities and invoke queue actuators, but it no longer derives or persists speed, groups, heat, reaction bias, interrupt threshold, status, load, counters, recovery, initiative position, active-job liveness, or idle-activity observations. It remains the temporary orchestration shell for semantic pressure, context acquisition, and prompt assembly. Registered repo Faces and native Personas queue normal `repo-face-rumination` jobs through Postgres, and active jobs freeze initiative until the worker completes or fails them. Built-in Void is omitted when its distinct moderation heartbeat organ is disabled, so the hosted scheduler never launches the old PowerShell rumination path. The Windows task installer and wrapper are deleted. Heat knobs remain `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; idle cooling and pending-mention priority remain scheduler inputs.
- Agent turns use one CTB/Final-Fantasy-X-style initiative queue. `apps/persona-scheduler` owns the resident one-minute pulse on Yggdrasil. `initiative-engine.ts` owns participant reconciliation plus clock, active-turn freeze/recovery, queue-result commits, eligibility, and ready ordering; `state-store.ts` alone owns load/migration/atomic persistence of scheduler state. Canonical scheduler state is now one typed `voidbot.persona_scheduler_state` record in `.voidbot/status/repo-face-heartbeats.cc`. A configured legacy `.json` path resolves to that `.cc`, the former JSON document is migrated once with a witness, and subsequent JSON is a derived dashboard/debug projection that cannot override CultCache. Discord mention ingress is no longer a second whole-state writer: the bot creates immutable per-message `voidbot.persona_attention_command` CultCache inbox documents at the same canonical path, and the scheduler ingests/commits/acknowledges them with replay-safe ordering. `active-turn-source.ts` alone reads Persona job liveness from Postgres/the remaining Void lock and may fail a stale queue claim; it returns facts to the engine and cannot edit initiative. `discord-activity-source.ts` owns read-only human-activity polling and idle-window derivation; it excludes bot traffic and cannot write state, consume mentions, or select work. `scripts/run-repo-face-heartbeats.ts` may build neutral specs from registered identities and invoke queue actuators, but it no longer derives or persists speed, groups, heat, reaction bias, interrupt threshold, status, load, counters, recovery, initiative position, active-job liveness, or idle-activity observations. It remains the temporary orchestration shell for Persona physiology/memory context and prompt assembly. Registered repo Faces and native Personas queue normal `repo-face-rumination` jobs through Postgres, and active jobs freeze initiative until the worker completes or fails them. Built-in Void is omitted when its distinct moderation heartbeat organ is disabled, so the hosted scheduler never launches the old PowerShell rumination path. The Windows task installer and wrapper are deleted. Heat knobs remain `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; idle cooling and pending-mention priority remain scheduler inputs.
- Bifrost governance acquisition is isolated in `bifrost-governance-source.ts`. That adapter alone owns the remaining `governance-threads.mjs digest` CLI contract and converts provider command/parse failures into typed error topics; prompt assembly receives governance facts and does not know the subprocess.
- Scheduler state mutation is centralized. `initiative-engine.ts` owns reconciliation, controls, skip/stale witnesses, pressure, mention consumption, queue transitions, and tick finalization through named commits. The orchestration shell has no direct scheduler state/history writes; queue and context adapters only return facts.
- Persona job submission is isolated in `turn-actuator.ts`. That output port alone owns worker actor/guild context construction, `ContextBundle` lowering, the `repo-face-rumination` command, approval defaults, request IDs, and `createJob`; it returns a queue receipt and has no state-selection or prompt-composition authority.

View file

@ -182,7 +182,7 @@ VoidBot runs on Yggdrasil. Idunn owns deployment and consumes upstream repositor
- Repo Face proposal/article PRs, PR comments, registered-identity Discord posts, and r/GameCultOrg Reddit thread posts now cross through parent-owned action blocks before Bifrost bridge commands. Bylined essays use the Interpreter-owned `ARTICLE` block: the Face writes naturally, the Interpreter extracts structured title/description/author/date/tags/path/body fields, and deterministic worker code renders/validates YAML frontmatter before calling `BIFROST_ROOT/tools/bifrost-bridge.mjs` for a draft PR and persona announcement. Subreddit threads use the Interpreter-owned `REDDIT THREAD` block: the Face writes a public title/body/share line, and deterministic worker code calls Bifrost `reddit-post` with Persona flair. The old `VOIDBOT_REPO_IDENTITY_ARTICLE` JSON sentinel is cut. Proposal PR and PR-comment sentinels still exist as compatibility rails. This is not merely helper placement: Bifrost's original governance/labor platform role makes it the owner of governed public crossings for GameCult work, including GitHub, r/GameCultOrg Reddit transport/thread access, Discord-native work/member/swarm interactions, dispatch receipts, CultNet/CultCache intake, and future collaboration interfaces. Heimdall is the OAuth/account/capability gate; Bifrost is the bridge/request/routing/receipt authority. VoidBot still owns room/thread cognition, moderation judgment, archive/source retrieval, and Face personality.
- Repo Face turns now receive a Bifrost governance digest from `BIFROST_ROOT/tools/governance-threads.mjs digest --repo <repo> --agent <identity>` alongside recent Discord context. Bifrost typed CultCache topics/comments are the canonical home for feature requests, Face opinions, missing questions, approvals, and dispatch receipts; Discord is a mirror/evidence source. When `BIFROST_DISCORD_CHANNEL_ID` is configured for `#bifrost`, repo Face Discord context ignores mirrored bot chatter there because the authoritative activity arrives through the Bifrost digest. Human `#bifrost` messages should only become governance comments after Bifrost/Heimdall can link the Discord id to a registered actor. Repo-local asks that would send a human or Codex to add/fix/name/scaffold/document/test/investigate/implement belong on Bifrost with enough room/repo context for a claimed Codex turn; a Face may still speak characterfully in Aquarium, but not as the only dispatch surface. Legacy `UPDATE REQUEST` packets are parsed only as compatibility input and reconciled into Bifrost topics instead of immediate transport enqueue. Local `#bifrost` is channel `1506760343836430376`; work proposals, governance mirrors, and Bifrost dispatch receipts should go there, while Aquarium remains the casual/social swarm room.
- Repo character turns now have a state-projector plus turn-Interpreter shape. The state-in boundary is `prompts/repo-face-state-projector.prompt.md`: typed state and identity material are translated into lived character prose before the child sees it. The state projector must surface values, drives, wounds, fascinations, aesthetic taste, dignity, mood, needs, social reads, bonds, agency pressure, and the personality-specific meaning of sparse or dense social topology when the state contains those facts. The deterministic state packet feeding that projector is intentionally verbose and parent-facing: it exposes private notes, values, activation vectors, runtime rest/speaking pressure, affect needs, bonds, status reads, mood dimensions, durable memories, short-term residue, agency pressures, incubating thoughts, candidate speech pressure, recent speech residue, social graph facts, room weather, and semantic curiosity graph attractors so every included variable can appear directly or bend behavior. The curiosity graph is built by seeding history/source vector retrieval from current room context, nearby channels, typed Face state, and home-jurisdiction identity, then decoding retrieved chunks into weighted attractor clusters with prominence, saturation, novelty, density, jurisdiction fit, suggested motion, and evidence labels. The projector owns turning those neutral facts into lived curiosity, boredom, fatigue, territorial pull, underexplored intrigue, or a closing/pivot impulse; deterministic code must not decide the feeling. Deterministic code may also attach neutral graph facts such as mapped people and unmapped registered peers to the projector packet, but it must not decide whether that feels like anxiety, territorial scouting, or nothing. Deterministic code attaches recent read-only home-repo activity before chat, using the Face's own state path as the repo-activity cursor source, so current implementation motion is visible as body context. Current-room and nearby-channel logs no longer cross a model conversation projector; deterministic code attaches raw oldest-to-newest transcript evidence with explicit recency/correction rules so the Face can notice human reframes directly. Deterministic code also attaches a social-affordance surface that matches active typed person-bonds/status reads to recent raw messages from that person, so relationship state arrives as a live hook rather than private decorative lore. Deterministic code still owns full prompt assembly and inspection through `scripts/run-repo-face-heartbeats.ts --assemble-prompt <identity>`; that path composes stable doctrine, allowed rooms, research/archive capability guidance, Bifrost digest, projected memory/affect prose, home-repo activity, live social affordances, and raw conversation evidence into the exact child prompt. It must not invent personality, mood, affect, or social memory from typed state. Situational obligations such as direct-call priority or first-public-introduction duty are emitted only when the scheduler packet proves they apply, not as standing prompt law. The child character writes a natural in-character turn on full `gpt-5.4` and may describe desired speech, repo/article proposals, social reads, needs, mood shifts, or memory-worthy thoughts without emitting action DSL blocks. The cheaper Spark/Mini parent Interpreter decides route/retry/drop and translates warranted effects into structured `STATE NOTE` and `SAY` blocks for the worker while governance dispatch remains paused. `STATE NOTE` blocks become typed short-term memory, affect needs, social bonds, status reads, mood dimensions, or agency pressure. The worker still accepts legacy compact JSON sentinels and legacy `UPDATE REQUEST` blocks for compatibility, but the live prompt path keeps transport and state syntax out of the character's roleplay surface.
- `apps/persona-scheduler` is the resident daemon organ that owns the serialized Persona wall-clock pulse on Yggdrasil. It invokes the remaining turn orchestrator without overlap and remains alive between pulses. `apps/persona-scheduler/src/initiative-engine.ts` owns the pure scheduling state machine: participant reconciliation, deterministic base speed/bias/threshold derivation, operator heat/speed projection, semantic-pressure commits, wall-clock advancement, active-turn freeze/completion recovery, queue-success/failure/dry-run commits, stale rescheduling, pending-mention priority, rest/idle eligibility, and ready ordering. Explicit blocked/withdrawn status, active load, semantic receipts, and initiative position survive reconciliation; new Faces without a usable mouth fail closed. `apps/persona-scheduler/src/state-store.ts` owns scheduler persistence and legacy migration. It creates state only when the path is absent, rejects malformed/unsupported state rather than erasing it, and atomically promotes complete writes. `packages/core/src/repo-face-heartbeat-queue.ts` no longer rewrites scheduler state: the Discord bot publishes one immutable `voidbot.persona_attention_command` CultCache `.cc` inbox document per addressed message. The scheduler store ingests and deduplicates those commands, persists them as pending attention, then acknowledges inbox files only after the durable commit; a crash before acknowledgement replays safely. `apps/persona-scheduler/src/active-turn-source.ts` owns the Postgres/legacy-lock liveness adapter. It recognizes only Persona turn request IDs, applies the bounded stale threshold, marks dead queue claims failed, and returns active/stale witnesses; it never writes scheduler state. `apps/persona-scheduler/src/discord-activity-source.ts` owns neutral Discord activity acquisition: it deduplicates explicit watched channels, excludes bot traffic, reports raw human messages, and derives idle-cooling times from scheduler history without writing state or selecting work. `apps/persona-scheduler/src/semantic-pressure-source.ts` owns embedding-backed affinity observation and returns bounded projections for eligible Personas and permitted channels. It cannot mutate initiative; `initiative-engine.ts` alone applies pressure, dynamic heat, interrupt readiness, dedupe receipts, and transition history. `apps/persona-scheduler/src/turn-context-source.ts` owns current-room and nearby-channel Discord evidence plus the bounded image-attachment cache. It returns chronological source messages and explicit snapshot failure witnesses; it has no access to Persona state, prompt composition, or the job queue. The configured scheduler JSON path is isolated legacy storage behind the store port and should be replaced with a typed CultCache `.cc` backing without changing the engine, inbox, or orchestration shell. `scripts/run-repo-face-heartbeats.ts` projects the identity registry into neutral participant specs and affinity candidates, invokes external queue actuators, and still owns Bifrost/repo/Persona-state context interpretation plus prompt assembly pending further extraction. External adapters report facts; only the initiative engine mutates load, counters, recovery position, pressure, and queue-transition history. Repo Faces and native Personas queue ordinary `repo-face-rumination` jobs through the canonical job queue. Built-in Void participates only when its separate moderation heartbeat organ is enabled; the hosted scheduler does not launch Windows PowerShell.
- `apps/persona-scheduler` is the resident daemon organ that owns the serialized Persona wall-clock pulse on Yggdrasil. It invokes the remaining turn orchestrator without overlap and remains alive between pulses. `apps/persona-scheduler/src/initiative-engine.ts` owns the pure scheduling state machine: participant reconciliation, deterministic base speed/bias/threshold derivation, operator heat/speed projection, semantic-pressure commits, wall-clock advancement, active-turn freeze/completion recovery, queue-success/failure/dry-run commits, stale rescheduling, pending-mention priority, rest/idle eligibility, and ready ordering. Explicit blocked/withdrawn status, active load, semantic receipts, and initiative position survive reconciliation; new Faces without a usable mouth fail closed. `apps/persona-scheduler/src/state-store.ts` owns scheduler persistence and legacy migration. It stores one typed `voidbot.persona_scheduler_state` record in `repo-face-heartbeats.cc`, migrates the former JSON document once with a `storage_migrated` witness, rejects malformed CultCache instead of replacing it, and writes JSON only as a derived dashboard/debug projection. `packages/core/src/repo-face-heartbeat-queue.ts` resolves both legacy `.json` configuration and current `.cc` configuration to the same canonical path; the Discord bot publishes one immutable `voidbot.persona_attention_command` CultCache inbox document per addressed message. The scheduler store ingests and deduplicates those commands, persists them as pending attention, then acknowledges inbox files only after the durable commit; a crash before acknowledgement replays safely. `apps/persona-scheduler/src/active-turn-source.ts` owns the Postgres/legacy-lock liveness adapter. It recognizes only Persona turn request IDs, applies the bounded stale threshold, marks dead queue claims failed, and returns active/stale witnesses; it never writes scheduler state. `apps/persona-scheduler/src/discord-activity-source.ts` owns neutral Discord activity acquisition: it deduplicates explicit watched channels, excludes bot traffic, reports raw human messages, and derives idle-cooling times from scheduler history without writing state or selecting work. `apps/persona-scheduler/src/semantic-pressure-source.ts` owns embedding-backed affinity observation and returns bounded projections for eligible Personas and permitted channels. It cannot mutate initiative; `initiative-engine.ts` alone applies pressure, dynamic heat, interrupt readiness, dedupe receipts, and transition history. `apps/persona-scheduler/src/turn-context-source.ts` owns current-room and nearby-channel Discord evidence plus the bounded image-attachment cache. It returns chronological source messages and explicit snapshot failure witnesses; it has no access to Persona state, prompt composition, or the job queue. `scripts/run-repo-face-heartbeats.ts` projects the identity registry into neutral participant specs and affinity candidates, invokes external queue actuators, and still owns Persona physiology/memory context interpretation plus prompt assembly pending further extraction. External adapters report facts; only the initiative engine mutates load, counters, recovery position, pressure, and queue-transition history. Repo Faces and native Personas queue ordinary `repo-face-rumination` jobs through the canonical job queue. Built-in Void participates only when its separate moderation heartbeat organ is enabled; the hosted scheduler does not launch Windows PowerShell.
- `apps/persona-scheduler/src/bifrost-governance-source.ts` owns the remaining Bifrost governance digest subprocess contract. It supplies the CLI path, arguments, timeout, JSON parsing, and typed command/parse failure topics; Persona prompt composition receives a `BifrostGovernanceDigest` and cannot invoke Bifrost directly.
- The scheduler state has one write authority. `initiative-engine.ts` commits participant reconciliation, operator controls, pause/disabled skips, stale-turn recovery witnesses, semantic pressure, pending-attention consumption, queue outcomes, and tick finalization. `run-repo-face-heartbeats.ts` contains no direct participant/state assignment or history append; its sources and actuators return facts to engine commits.
- `apps/persona-scheduler/src/turn-actuator.ts` is the worker-queue output port. Given a completed prompt, output channel, evidence fields, provider, identity, timestamp, and narrow `createJob` capability, it builds the canonical worker actor/guild `ContextBundle`, creates one approved `repo-face-rumination` job, and returns `{created, activeJobId, requestMessageId}`. It cannot choose a Persona, mutate scheduler state, or compose prompt content. The orchestration shell no longer knows `ContextBuilder`, job approval defaults, worker command names, or `createJob` mechanics.

1
package-lock.json generated
View file

@ -41,6 +41,7 @@
"@voidbot/core": "0.1.0",
"@voidbot/rag": "0.1.0",
"@voidbot/shared": "0.1.0",
"cultcache-ts": "file:../../vendor/cultcache-ts",
"dotenv": "^16.4.0"
}
},

View file

@ -137,7 +137,7 @@ const envSchema = z.object({
BIFROST_CULTMESH_COMMAND_PUMP_ENABLED: booleanFromEnv.default(true),
REPO_FACE_HEARTBEATS_ENABLED: booleanFromEnv.default(false),
VOIDBOT_MODERATION_HEARTBEAT_ENABLED: booleanFromEnv.default(false),
REPO_FACE_HEARTBEAT_STATE_PATH: z.string().min(1).default(".voidbot/status/repo-face-heartbeats.json"),
REPO_FACE_HEARTBEAT_STATE_PATH: z.string().min(1).default(".voidbot/status/repo-face-heartbeats.cc"),
REPO_FACE_HEARTBEAT_TASK_NAME: z.string().min(1).default("VoidBot Repo Face Heartbeats"),
REPO_FACE_HEARTBEAT_INTERVAL_MINUTES: z.coerce.number().int().min(1).default(5),
REPO_FACE_HEARTBEAT_MAX_JOBS_PER_TICK: z.coerce.number().int().min(1).max(8).default(3),
@ -540,7 +540,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
voidModerationHeartbeatEnabled: parsed.VOIDBOT_MODERATION_HEARTBEAT_ENABLED,
repoFaceHeartbeats: {
enabled: parsed.REPO_FACE_HEARTBEATS_ENABLED,
statePath: resolve(parsed.REPO_FACE_HEARTBEAT_STATE_PATH),
statePath: canonicalSchedulerStatePath(resolve(parsed.REPO_FACE_HEARTBEAT_STATE_PATH)),
taskName: parsed.REPO_FACE_HEARTBEAT_TASK_NAME,
intervalMinutes: parsed.REPO_FACE_HEARTBEAT_INTERVAL_MINUTES,
maxJobsPerTick: parsed.REPO_FACE_HEARTBEAT_MAX_JOBS_PER_TICK,
@ -653,3 +653,9 @@ function requireWhenEnabled(enabled: boolean, value: string | undefined, message
}
throw new Error(message);
}
function canonicalSchedulerStatePath(path: string): string {
if (path.toLowerCase().endsWith(".cc")) return path;
if (path.toLowerCase().endsWith(".json")) return `${path.slice(0, -5)}.cc`;
return `${path}.cc`;
}

View file

@ -104,7 +104,19 @@ export async function queueAgentHeartbeatMention(input: {
}
export function resolveRepoFaceMentionInboxDirectory(statePath: string): string {
return `${statePath}.mentions`;
return `${resolveRepoFaceHeartbeatStatePath(statePath)}.mentions`;
}
export function resolveRepoFaceHeartbeatStatePath(statePath: string): string {
const resolved = resolve(statePath);
if (resolved.toLowerCase().endsWith(".cc")) return resolved;
if (resolved.toLowerCase().endsWith(".json")) return `${resolved.slice(0, -5)}.cc`;
return `${resolved}.cc`;
}
export function resolveRepoFaceHeartbeatDebugProjectionPath(statePath: string): string {
const canonical = resolveRepoFaceHeartbeatStatePath(statePath);
return `${canonical.slice(0, -3)}.json`;
}
export async function readRepoFaceMentionInbox(statePath: string): Promise<RepoFacePendingMention[]> {

View file

@ -176,7 +176,8 @@ function buildCatalog() {
],
witnesses: [
witness("voidbot.swarm_state_snapshot", ".voidbot/status/cultmesh/voidbot-swarm-state.cc", "scripts/render-voidbot-swarm-dashboard.mjs", ["voidbot.swarm_state_snapshot", "gamecult.eve.provider_advertisement", "gamecult.eve.surface_state", "gamecult.eve.interface_binding"]),
witness("voidbot.repo_face_heartbeat_state", ".voidbot/status/repo-face-heartbeats.json", "VoidBot CTB scheduler", ["debug/status source"]),
witness("voidbot.repo_face_heartbeat_state", ".voidbot/status/repo-face-heartbeats.cc", "VoidBot CTB scheduler", ["canonical typed scheduler state"]),
witness("voidbot.repo_face_heartbeat_debug_projection", ".voidbot/status/repo-face-heartbeats.json", "VoidBot CTB scheduler", ["derived debug/status projection", "not mutation authority"]),
witness("voidbot.orchestrator_state", ".voidbot/status/gamecult-orchestrator.json", "GameCult Local Orchestrator", ["debug/status source"]),
],
commands: [

View file

@ -45,7 +45,7 @@ const swarmCultMeshRudpEndpoint = String(
const storageRoot = resolveConfigPath(env.STORAGE_ROOT, defaultStorageRoot);
const statusDir = resolve(storageRoot, "status");
const heartbeatStatePath = resolveConfigPath(
env.REPO_FACE_HEARTBEAT_STATE_PATH,
schedulerDebugProjectionPath(env.REPO_FACE_HEARTBEAT_STATE_PATH),
resolve(statusDir, "repo-face-heartbeats.json"),
);
const orchestratorPath = resolve(statusDir, "gamecult-orchestrator.json");
@ -2936,6 +2936,13 @@ async function readDotEnv(path) {
return values;
}
function schedulerDebugProjectionPath(value) {
if (!value) return value;
const resolved = resolve(value);
if (resolved.toLowerCase().endsWith(".cc")) return `${resolved.slice(0, -3)}.json`;
return resolved;
}
function resolveConfigPath(value, fallback) {
const raw = typeof value === "string" && value.trim() ? value.trim() : fallback;
return resolve(repoRoot, raw);

View file

@ -25,11 +25,17 @@ const args = parseArgs(process.argv.slice(2));
const storePath = path.resolve(args.store || process.env.VOIDBOT_SWARM_CULTMESH_STORE || defaultStorePath);
const controlStorePath = path.resolve(args.controlStore || process.env.VOIDBOT_SWARM_CONTROL_STORE || defaultControlStorePath);
const operatorInputPaths = {
heartbeat: path.resolve(process.env.REPO_FACE_HEARTBEAT_STATE_PATH || path.join(repoRoot, ".voidbot", "status", "repo-face-heartbeats.json")),
heartbeat: schedulerDebugProjectionPath(process.env.REPO_FACE_HEARTBEAT_STATE_PATH || path.join(repoRoot, ".voidbot", "status", "repo-face-heartbeats.cc")),
orchestrator: path.resolve(process.env.VOIDBOT_ORCHESTRATOR_STATE_PATH || path.join(repoRoot, ".voidbot", "status", "gamecult-orchestrator.json")),
pause: path.resolve(process.env.VOIDBOT_SWARM_PAUSE_STATE_PATH || path.join(repoRoot, "state", "agent-swarm-paused.json")),
control: controlStorePath,
};
function schedulerDebugProjectionPath(configuredPath) {
const resolved = path.resolve(configuredPath);
if (resolved.toLowerCase().endsWith(".cc")) return `${resolved.slice(0, -3)}.json`;
return resolved;
}
const bind = parseBind(args.bind || process.env.VOIDBOT_SWARM_CULTMESH_BIND || defaultBind);
const odinCultMeshUri = args.odinCultMeshUri || args["odin-cultmesh-uri"] || process.env.VOIDBOT_ODIN_CULTMESH_URI || process.env.ODIN_CULTMESH_URI || defaultOdinCultMeshUri;
const odinRudpEndpoint = args.odinRudpEndpoint || args["odin-rudp-endpoint"] || process.env.VOIDBOT_ODIN_RUDP || process.env.CULTMESH_URI_ODIN_RUDP || defaultOdinRudpEndpoint;

View file

@ -6,6 +6,8 @@ import { join } from "node:path";
import {
queueAgentHeartbeatMention,
readRepoFaceMentionInbox,
resolveRepoFaceHeartbeatDebugProjectionPath,
resolveRepoFaceHeartbeatStatePath,
type CreateJobInput,
type JobQueue,
type RepoDiscordIdentity,
@ -32,6 +34,7 @@ import {
type InitiativeState,
} from "../apps/persona-scheduler/dist/initiative-engine.js";
import {
newPersonaSchedulerState,
readPersonaSchedulerState,
writePersonaSchedulerState,
} from "../apps/persona-scheduler/dist/state-store.js";
@ -468,15 +471,18 @@ try {
await writePersonaSchedulerState(statePath, absent);
const persisted = await readPersonaSchedulerState(statePath);
assert.equal(persisted.participants[0].identityId, "persisted", "atomic scheduler persistence round-trips its state");
assert.ok((await readFile(statePath, "utf8")).endsWith("\n"), "the promoted state is a complete inspectable document");
assert.ok((await readFile(resolveRepoFaceHeartbeatDebugProjectionPath(statePath), "utf8")).endsWith("\n"), "the JSON witness remains a complete inspectable projection");
await writeFile(resolveRepoFaceHeartbeatDebugProjectionPath(statePath), `${JSON.stringify(newPersonaSchedulerState())}\n`, "utf8");
assert.equal((await readPersonaSchedulerState(statePath)).participants[0].identityId, "persisted", "the derived JSON witness cannot override canonical CultCache state");
await writeFile(statePath, "{broken", "utf8");
await writeFile(resolveRepoFaceHeartbeatStatePath(statePath), "{broken", "utf8");
await assert.rejects(
readPersonaSchedulerState(statePath),
/refusing to replace it with empty state/,
"malformed persistent state must fail closed instead of erasing the scheduler mind",
);
await rm(resolveRepoFaceHeartbeatStatePath(statePath), { force: true });
await writeFile(statePath, JSON.stringify({
schemaVersion: "legacy",
baseIntervalMinutes: 90,
@ -487,7 +493,8 @@ try {
const migrated = await readPersonaSchedulerState(statePath, Date.parse("2026-07-15T20:00:00.000Z"));
assert.equal(migrated.baseRecoveryMinutes, 30, "legacy cadence migrates under the scheduler store owner");
assert.equal(migrated.participants[0].participantKind, "repo_face", "legacy participants receive current scheduling anatomy");
assert.equal(migrated.history.at(-1)?.type, "migrated", "migration leaves a durable state witness");
assert.ok(migrated.history.some((entry) => entry.type === "migrated"), "schema migration leaves a durable state witness");
assert.equal(migrated.history.at(-1)?.type, "storage_migrated", "JSON-to-CultCache migration names the storage authority change");
const mentionStatePath = join(stateDirectory, "mentions.json");
const mentionInput = {

View file

@ -408,3 +408,4 @@
{"ts":"2026-07-15T23:03:20Z","type":"operations","status":"ok","note":"Reduced live swarm global heat to the minimum accepted 0.05 after one-minute Persona traffic burned tokens. Two following resident scheduler ticks selected and queued nobody. The daemon may pulse every minute; speech must remain initiative/rest gated rather than equated with the pulse."}
{"ts":"2026-07-15T23:17:32Z","type":"implementation","status":"ok","note":"Completed the swarm Eve live-state authority migration: voidbot.swarm_operator_view.v1 now owns summary, queue, selected Face/state/channels, operations, pressure, mentions/events, and route projections; Eve binds them as typed props instead of recomputing browser-visible facts from a generation snapshot. The renderer also registers the retained operator-view schema when reopening the shared CultCache store. Focused tests, TypeScript build, and isolated render/store smoke passed."}
{"ts":"2026-07-15T23:19:00Z","type":"repair","status":"ok","note":"Yggdrasil selected-turn execution exposed a missing extname import in native Persona memory rendering after source extraction. Restored the direct node:path dependency; typecheck, initiative smoke, and full dry-run selection passed before push."}
{"ts":"2026-07-15T23:27:00Z","type":"implementation","status":"ok","note":"Persona scheduler persistence now has one typed CultCache owner: state-store.ts stores voidbot.persona_scheduler_state in repo-face-heartbeats.cc, canonicalizes legacy .json configuration, migrates the old JSON once with a storage_migrated witness, fails closed on malformed .cc, commits before attention-inbox acknowledgement, and emits JSON only as a derived dashboard/debug projection. Tests prove the projection cannot override canonical state and bot/scheduler mention paths converge on the same .cc inbox."}

View file

@ -65,7 +65,7 @@ active_subgoals:
- Treat `notes/agent-swarm-teardown-plan-2026-05-21.md` as the historical teardown authority and the restart checklist. The repo Face swarm is live again, but only because the scheduler, prompt, model-output logs, parent Interpreter, Bifrost routing, and worker side-effect rails now have explicit observable boundaries. Keep watching for prompt/parser/transport Jenga before adding new swarm features.
- Make the new project-state surfaces the default continuity path for nontrivial VoidBot work.
- 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: missing state is explicit, legacy state migrates with a witness, malformed state fails closed, and same-directory temporary writes promote atomically. Discord attention crosses through immutable per-mention `voidbot.persona_attention_command` CultCache inbox documents; 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. `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 current scheduler JSON file is tolerated legacy persistence behind the state-store port, not the target typed state shape; move that port to CultCache without reopening orchestration. The heartbeat orchestration script may project registered identities into neutral participant specs and affinity candidates and report external actuator results, but it cannot decide, persist, migrate, inspect queue liveness, poll Discord, commit semantic pressure, 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.
- 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. `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 and report external actuator results, but it cannot decide, persist, migrate, inspect queue liveness, poll Discord, commit semantic pressure, 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.
- `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.
@ -278,4 +278,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: Move the Persona scheduler state-store port from the tolerated whole-file JSON persistence shape to one typed CultCache `.cc` document without reopening initiative ownership. Preserve explicit missing/malformed/migration witnesses and atomic durable commits; the engine remains the only state writer.
next_action: Extract Persona physiology/rest observation and memory-surface acquisition from `scripts/run-repo-face-heartbeats.ts` into one read-only source organ. Keep rest eligibility decisions in `initiative-engine.ts`; the source reports body/mind facts and cannot select, queue, or mutate initiative.