From 1f25371c0e317d643fbb2422dab374bb817a3801 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 15 Jul 2026 22:23:42 +0100 Subject: [PATCH] Extract Persona initiative scheduling organ --- .../src/initiative-engine.ts | 103 ++++++++++++++ notes/fresh-workspace-handoff.md | 2 +- notes/voidbot-current-system-map.md | 2 +- package.json | 1 + scripts/run-repo-face-heartbeats.ts | 133 +----------------- scripts/smoke-persona-initiative-engine.ts | 57 ++++++++ state/map.yaml | 4 +- 7 files changed, 172 insertions(+), 130 deletions(-) create mode 100644 apps/persona-scheduler/src/initiative-engine.ts create mode 100644 scripts/smoke-persona-initiative-engine.ts diff --git a/apps/persona-scheduler/src/initiative-engine.ts b/apps/persona-scheduler/src/initiative-engine.ts new file mode 100644 index 0000000..fa7aebb --- /dev/null +++ b/apps/persona-scheduler/src/initiative-engine.ts @@ -0,0 +1,103 @@ +export interface InitiativeParticipant { + identityId: string; + participantKind: "repo_face" | "native_persona" | "system_agent"; + status: "active" | "blocked" | "withdrawn" | "offscreen"; + currentLoad: number; + nextTurnAt: number; + baseRecoveryMinutes: number; + reactionBias: number; + effectiveSpeed: number; +} + +export interface PendingMention { + identityId: string; +} + +export interface InitiativeState { + initiativeClock: number; + lastTickAt?: string; + participants: TParticipant[]; + pendingMentions: PendingMention[]; + history: Array>; +} + +export interface RestSnapshot { + isNapping: boolean; +} + +export interface IdleCoolingPolicy { + enabled: boolean; + active: boolean; + nextUnpromptedTurnAllowedAt?: string; +} + +export function advanceInitiativeClockFromWallClock(state: InitiativeState, now: Date): void { + const lastTickMs = Date.parse(state.lastTickAt ?? ""); + if (!Number.isFinite(lastTickMs)) return; + const elapsedMinutes = (now.getTime() - lastTickMs) / 60_000; + if (elapsedMinutes <= 0) return; + state.initiativeClock = round3(state.initiativeClock + Math.min(elapsedMinutes, 60)); +} + +export function rescheduleStaleOverdueParticipants(state: InitiativeState): void { + const active = state.participants.filter((participant) => participant.status === "active"); + const count = Math.max(active.length, 1); + let rescheduledCount = 0; + active.forEach((participant, index) => { + const staleThreshold = Math.max(participant.baseRecoveryMinutes, 15); + if (participant.nextTurnAt >= state.initiativeClock - staleThreshold) return; + participant.nextTurnAt = round3(state.initiativeClock + (participant.baseRecoveryMinutes / count) * index); + rescheduledCount += 1; + }); + if (rescheduledCount > 0) { + state.history.push({ type: "wall_clock_resync", rescheduledCount, initiativeClock: state.initiativeClock }); + } +} + +export function applyPendingMentionPriority(state: InitiativeState): void { + const counts = countPendingMentionsByIdentity(state.pendingMentions); + for (const participant of state.participants) { + if (participant.status === "active" && participant.currentLoad < 1 && (counts.get(participant.identityId) ?? 0) > 0) { + participant.nextTurnAt = Math.min(participant.nextTurnAt, state.initiativeClock); + } + } +} + +export function countPendingMentionsByIdentity(pendingMentions: PendingMention[]): Map { + const counts = new Map(); + for (const mention of pendingMentions) { + counts.set(mention.identityId, (counts.get(mention.identityId) ?? 0) + 1); + } + return counts; +} + +export function selectReadyParticipants( + state: InitiativeState, + maxJobs: number, + completedThisTick: Set, + restStates: Map, + idleCooling: IdleCoolingPolicy, + nowMs = Date.now(), +): TParticipant[] { + const mentionCounts = countPendingMentionsByIdentity(state.pendingMentions); + const ready = state.participants + .filter((participant) => participant.status === "active" && participant.currentLoad < 1) + .filter((participant) => !completedThisTick.has(participant.identityId)) + .filter((participant) => participant.participantKind !== "repo_face" || (mentionCounts.get(participant.identityId) ?? 0) > 0 || restStates.get(participant.identityId)?.isNapping !== true) + .filter((participant) => participant.nextTurnAt <= state.initiativeClock) + .sort((left, right) => { + const mentionDelta = (mentionCounts.get(right.identityId) ?? 0) - (mentionCounts.get(left.identityId) ?? 0); + return mentionDelta || left.nextTurnAt - right.nextTurnAt || right.reactionBias - left.reactionBias || right.effectiveSpeed - left.effectiveSpeed || left.identityId.localeCompare(right.identityId); + }); + + if (!idleCooling.enabled || !idleCooling.active) return ready.slice(0, maxJobs); + const mentioned = ready.filter((participant) => (mentionCounts.get(participant.identityId) ?? 0) > 0); + const unprompted = ready.filter((participant) => (mentionCounts.get(participant.identityId) ?? 0) === 0); + const allowsUnprompted = !idleCooling.nextUnpromptedTurnAllowedAt || Date.parse(idleCooling.nextUnpromptedTurnAllowedAt) <= nowMs; + const cooled = allowsUnprompted && mentioned.length < maxJobs ? unprompted.slice(0, 1) : []; + return [...mentioned, ...cooled].slice(0, maxJobs); +} + +function round3(value: number): number { + return Math.round(value * 1000) / 1000; +} diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 167541b..0a6d4cb 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -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/`, 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; `scripts/run-repo-face-heartbeats.ts` still contains the oversized selection/context/queue engine being extracted. 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, and `apps/persona-scheduler/src/initiative-engine.ts` now owns its pure clock/eligibility/ready-order state machine. `scripts/run-repo-face-heartbeats.ts` remains the temporary orchestration shell for participant reconciliation, context acquisition, prompt assembly, and Postgres queue writes; do not put scheduling decisions back into it. 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. - Dynamic heat is live as derived semantic response pressure. The pulse batches canonical Face identity-card and recent permitted-channel human-message embeddings through the existing RAG embedder, applies recency decay/noisy-OR aggregation, awakens at most three scheduler-eligible Faces, and stores inspectable message evidence. Pressure increases `dynamicHeat` for the next scheduled recovery calculation; crossing a Persona's interrupt threshold can pull that Persona ready once per evidence message. Direct mentions remain obligations, and model/Discord failures fail cool without bypassing rest, active jobs, or scheduler capacity. - Repo Face heartbeat prompt assembly now has one model projection boundary before the child Face sees context: `repo-face-state-projector.prompt.md` turns typed Face state into character-facing memory/affect/social prose. Recent room messages, nearby channel snapshots, and pending mentions are now deterministic raw transcript sections, oldest-to-newest, with explicit recency/correction rules. The deleted conversation projector was turning evidence into mushy consensus; do not revive it. The Face model must read human corrections directly. A separate deterministic social-affordance surface connects active typed person-bonds/status reads to recent raw messages by that person, so relationship state becomes a live hook instead of private flavor text. Model ownership is split: Face turns default to full `gpt-5.4`; state projection and parent Interpreter passes use the cheaper Spark/Mini support chain. - Repo Face prompts now include read-only recent home-repo activity before Discord transcript. `scripts/run-repo-face-heartbeats.ts` calls `scripts/export-recent-repo-activity.mjs --repos --state-path --read-only --hours 96 --max-commits 5`, so Faces see current body/jurisdiction motion before reacting to room pressure. This is the fix for Mimir missing live camera-runtime work, not a downstream compensator telling it to apologize harder. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 367e06a..610a9e8 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -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 --agent ` 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 `; 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 current turn-selection engine without overlap and remains alive between pulses. `scripts/run-repo-face-heartbeats.ts` still contains the broader CTB/Final-Fantasy-X-style initiative, context assembly, and queueing engine; that monolith is the next extraction target, not the process owner. The scheduler stores per-agent speed, reaction bias, load, groups, heat, effective speed, next-turn time, active turn markers, and recent queue history at `REPO_FACE_HEARTBEAT_STATE_PATH`. 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. Initiative freezes while an owner-Codex job is active, recovery starts after completion, and the active-job scan marks ancient approved/running jobs failed through the queue before selection. +- `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` now owns the pure scheduling state machine: wall-clock advancement, stale rescheduling, pending-mention priority, rest/idle eligibility, and deterministic ready ordering. `scripts/run-repo-face-heartbeats.ts` still owns participant reconciliation, external pressure/context acquisition, prompt assembly, and queue writes pending further extraction; it no longer owns a duplicate ready-selection implementation. The scheduler stores per-agent speed, reaction bias, load, groups, heat, effective speed, next-turn time, active turn markers, and recent queue history at `REPO_FACE_HEARTBEAT_STATE_PATH`. 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. Initiative freezes while an owner-Codex job is active, recovery starts after completion, and the active-job scan marks ancient approved/running jobs failed through the queue before selection. - The same scheduler derives semantic `responsePressure` for scheduler-eligible Personas by embedding canonical Face identity-card projections and recent human messages from channels each Face may enter. Pressure decays by message age, aggregates with noisy-OR, awakens at most three eligible Faces, and exposes its message-scoped cosine evidence. It produces bounded `dynamicHeat` for the next scheduled recovery calculation; an unseen message crossing the Persona's interrupt threshold may pull readiness to the current initiative clock exactly once. It cannot create a job, bypass rest or active-turn brakes, impersonate a direct mention, or write inferred desire into Persona state. - The Windows `GameCult Local Orchestrator` and per-organ scheduled-task wrapper no longer own Persona pulses. `voidbot.service` deploys `persona-scheduler` beside bot, worker, Qdrant, and the CultMesh publisher. Turn frequency is controlled by `REPO_FACE_HEARTBEAT_BASE_RECOVERY_MINUTES`, `REPO_FACE_HEARTBEAT_GLOBAL_HEAT`, `REPO_FACE_HEARTBEAT_SPEED_OVERRIDES`, and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`. Heat keys can target `all`, `kind:`, `turn:`, `identity:`, `repo:`, `display:`, `channel:`, or the bare identity/repo/display key. - The local ignored registry currently includes Nibu for `AetheriaLore`, with Face state in `E:/Projects/AetheriaLore/.voidbot/state/nibu.cc`, Discord role id `1505918221453103247`, and an Aquarium channel allow-list. Nibu's Face state now treats AetheriaLore-first map-building, abrasive character voice, consensus-backed change agency, her own `Aetheria/Lore/Nibu.md` character-note stewardship, Nibu-authored lore essays, and tangential setting fascination as separate active doctrines. That tangential fascination points at embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival. The repo Face heartbeat organ is now pulsed by `GameCult Local Orchestrator`; after restarting the stack onto the new worker code, forced heartbeat job `76020f1e-0c55-424b-9e35-89ba7f77fcc3` completed without base-bot auto-posting. diff --git a/package.json b/package.json index ff4ea1f..d30d94d 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "agents:mimo-nibu-crashout-smoke": "node scripts/smoke-mimo-nibu-crashout.mjs", "agents:speech-boundary-smoke": "tsx scripts/smoke-repo-face-speech-boundary.ts", "agents:heartbeat-response-pressure-fixture": "tsx scripts/smoke-repo-face-response-pressure.ts", + "agents:initiative-engine-smoke": "tsx scripts/smoke-persona-initiative-engine.ts", "agents:memory-projection-smoke": "node scripts/smoke-repo-face-memory-projection.mjs", "agents:shared-documents-smoke": "tsx scripts/smoke-repo-face-shared-documents.ts", "agents:shared-documents-mcp-smoke": "node scripts/smoke-repo-face-shared-documents-mcp.mjs", diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index 9a586ee..5dd2b0d 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -45,6 +45,13 @@ import { type SourceMessage, type SourceMessageAttachment, } from "@voidbot/shared"; +import { + advanceInitiativeClockFromWallClock, + applyPendingMentionPriority, + countPendingMentionsByIdentity, + rescheduleStaleOverdueParticipants, + selectReadyParticipants, +} from "../apps/persona-scheduler/src/initiative-engine"; const HEARTBEAT_SCHEMA_VERSION = REPO_FACE_HEARTBEAT_SCHEMA_VERSION; const HEARTBEAT_COMMAND = "repo-face-rumination"; @@ -1435,70 +1442,6 @@ function applyActiveTurnFreeze( }; } -function selectReadyParticipants( - state: FaceHeartbeatState, - maxJobs: number, - completedThisTick: Set, - restStates: Map, - idleCooling: IdleCoolingSnapshot, -): FaceHeartbeatParticipant[] { - const pendingMentionCounts = countPendingMentionsByIdentity(state.pendingMentions); - const eligible = state.participants - .filter((participant) => { - const pendingMentionCount = pendingMentionCounts.get(participant.identityId) ?? 0; - const restState = restStates.get(participant.identityId); - return ( - participant.status === "active" && - participant.currentLoad < 1 && - !completedThisTick.has(participant.identityId) && - !( - participant.participantKind === "repo_face" && - pendingMentionCount === 0 && - restState?.isNapping === true - ) - ); - }); - - if (eligible.length === 0) { - return []; - } - - const ready = eligible - .filter((participant) => participant.nextTurnAt <= state.initiativeClock) - .sort((left, right) => { - const pendingDelta = - (pendingMentionCounts.get(right.identityId) ?? 0) - - (pendingMentionCounts.get(left.identityId) ?? 0); - if (pendingDelta !== 0) { - return pendingDelta; - } - const readyDelta = left.nextTurnAt - right.nextTurnAt; - if (readyDelta !== 0) { - return readyDelta; - } - if (right.reactionBias !== left.reactionBias) { - return right.reactionBias - left.reactionBias; - } - if (right.effectiveSpeed !== left.effectiveSpeed) { - return right.effectiveSpeed - left.effectiveSpeed; - } - return left.identityId.localeCompare(right.identityId); - }); - - if (!idleCooling.enabled || !idleCooling.active) { - return ready.slice(0, maxJobs); - } - - const mentioned = ready.filter((participant) => (pendingMentionCounts.get(participant.identityId) ?? 0) > 0); - const unprompted = ready.filter((participant) => (pendingMentionCounts.get(participant.identityId) ?? 0) === 0); - const coolingAllowsUnpromptedTurn = - !idleCooling.nextUnpromptedTurnAllowedAt || - Date.parse(idleCooling.nextUnpromptedTurnAllowedAt) <= Date.now(); - const cooled = coolingAllowsUnpromptedTurn && mentioned.length < maxJobs ? unprompted.slice(0, 1) : []; - - return [...mentioned, ...cooled].slice(0, maxJobs); -} - async function applySemanticResponsePressure(input: { state: FaceHeartbeatState; specs: ParticipantSpec[]; @@ -1635,58 +1578,6 @@ function renderInitiativeAffinityCard(spec: ParticipantSpec): string { ].filter((value): value is string => typeof value === "string" && value.trim().length > 0).join("\n"); } -function advanceInitiativeClockFromWallClock(state: FaceHeartbeatState, now: Date): void { - const lastTickMs = Date.parse(state.lastTickAt ?? ""); - if (!Number.isFinite(lastTickMs)) { - return; - } - - const elapsedMinutes = (now.getTime() - lastTickMs) / 60_000; - if (elapsedMinutes <= 0) { - return; - } - - const boundedElapsedMinutes = Math.min(elapsedMinutes, 60); - state.initiativeClock = round3(state.initiativeClock + boundedElapsedMinutes); -} - -function rescheduleStaleOverdueParticipants(state: FaceHeartbeatState): void { - const activeParticipants = state.participants.filter((participant) => participant.status === "active"); - const count = Math.max(activeParticipants.length, 1); - let rescheduledCount = 0; - - activeParticipants.forEach((participant, index) => { - const staleThreshold = Math.max(participant.baseRecoveryMinutes, 15); - if (participant.nextTurnAt >= state.initiativeClock - staleThreshold) { - return; - } - - participant.nextTurnAt = round3(state.initiativeClock + (participant.baseRecoveryMinutes / count) * index); - rescheduledCount += 1; - }); - - if (rescheduledCount > 0) { - state.history.push({ - type: "wall_clock_resync", - rescheduledCount, - initiativeClock: state.initiativeClock, - }); - } -} - -function applyPendingMentionPriority(state: FaceHeartbeatState): void { - const pendingMentionCounts = countPendingMentionsByIdentity(state.pendingMentions); - for (const participant of state.participants) { - if ( - participant.status === "active" && - participant.currentLoad < 1 && - (pendingMentionCounts.get(participant.identityId) ?? 0) > 0 - ) { - participant.nextTurnAt = Math.min(participant.nextTurnAt, state.initiativeClock); - } - } -} - function pendingMentionsForParticipant( state: FaceHeartbeatState, identityId: string, @@ -1696,16 +1587,6 @@ function pendingMentionsForParticipant( .sort((left, right) => Date.parse(left.queuedAt) - Date.parse(right.queuedAt)); } -function countPendingMentionsByIdentity( - pendingMentions: RepoFacePendingMention[], -): Map { - const counts = new Map(); - for (const mention of pendingMentions) { - counts.set(mention.identityId, (counts.get(mention.identityId) ?? 0) + 1); - } - return counts; -} - async function readIdleCoolingSnapshot(input: { config: ReturnType; identities: RepoDiscordIdentity[]; diff --git a/scripts/smoke-persona-initiative-engine.ts b/scripts/smoke-persona-initiative-engine.ts new file mode 100644 index 0000000..5e24016 --- /dev/null +++ b/scripts/smoke-persona-initiative-engine.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; + +import { + advanceInitiativeClockFromWallClock, + applyPendingMentionPriority, + selectReadyParticipants, + type InitiativeParticipant, + type InitiativeState, +} from "../apps/persona-scheduler/src/initiative-engine"; + +function participant(identityId: string, nextTurnAt: number): InitiativeParticipant { + return { + identityId, + participantKind: "repo_face", + status: "active", + currentLoad: 0, + nextTurnAt, + baseRecoveryMinutes: 30, + reactionBias: 1, + effectiveSpeed: 1, + }; +} + +const state: InitiativeState = { + initiativeClock: 10, + lastTickAt: "2026-07-15T20:00:00.000Z", + participants: [participant("quiet", 9), participant("mentioned", 30), participant("napping", 9)], + pendingMentions: [{ identityId: "mentioned" }], + history: [], +}; + +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); +assert.equal(state.participants[1].nextTurnAt, 12.5, "a mention makes its participant ready without queuing it"); + +const selected = selectReadyParticipants( + state, + 2, + new Set(), + new Map([["napping", { isNapping: true }]]), + { enabled: false, active: false }, + Date.parse("2026-07-15T20:02:30.000Z"), +); +assert.deepEqual(selected.map((entry) => entry.identityId), ["mentioned", "quiet"], "mentions lead while unmentioned naps remain asleep"); + +const cooled = selectReadyParticipants( + state, + 2, + new Set(), + new Map(), + { enabled: true, active: true, nextUnpromptedTurnAllowedAt: "2026-07-15T21:00:00.000Z" }, + Date.parse("2026-07-15T20:02:30.000Z"), +); +assert.deepEqual(cooled.map((entry) => entry.identityId), ["mentioned"], "idle cooling suppresses unprompted turns but never pending mentions"); + +process.stdout.write("Persona initiative engine smoke passed.\n"); diff --git a/state/map.yaml b/state/map.yaml index 5f2875d..a0b8f14 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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. 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 wall-clock advancement, stale rescheduling, mention priority, rest/idle eligibility, and ready-order selection as a pure state machine; `scripts/run-repo-face-heartbeats.ts` orchestrates remaining context and queue ports while those organs are extracted. 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. - Keep legacy moderation mirrors, whole-state projection commits, and deleted memory-organ compensators from re-entering the active path before adding more moderation, mood, agency, or public-lane behavior. - Keep public-lane limits, moderation, and provider behavior constrained enough that one 4060 Ti box does not get flayed for sport. - Preserve boring observability and startup discipline around Postgres, Qdrant, Ollama, Docker, backups, scheduled tasks, watchdogs, and any private local fire-signal extensions. @@ -237,7 +237,7 @@ landed_baseline: - Long-term memory is durable but plastic. `revise_durable_memory`, `retire_durable_memory`, and `crystallize_memory_into_identity` make revision, contradiction/obsolescence, and self-doctrine crystallization explicit instead of silently overwriting older thoughts. - `npm run moderation:memory-lifecycle:fixture` verifies memory lifecycle flow: short-term promotion, durable revision, superseded-memory retirement, and crystallization into one active identity memory plus self-profile value. - The sleep-maintenance path has a model-branch fixture: `npm run moderation:memory-maintenance:sleep-fixture` seeds a temporary `.cc`, forces mood drift into sleep maintenance, routes the non-skip Codex branch through a fake child, and verifies short-term memory is consumed into one durable meaning-preserving record. - - The local `GameCult Local Orchestrator` scheduled task is installed as the hidden one-minute pulse for Bifrost dispatch, repo Face CTB heartbeats, Void moderation heartbeat, Void mood drift, Void moderation rumination, and the operations watchdog. It writes per-organ logs under `.voidbot/logs/orchestrator`, summary/status under `.voidbot/status/gamecult-orchestrator*.json`, freezes overlap with one orchestrator lock, and enforces per-organ timeouts so a hung child cannot occupy the whole clock forever. + - The local `GameCult Local Orchestrator` remains a legacy/local pulse for Bifrost dispatch, Void moderation heartbeat, Void mood drift, Void moderation rumination, and the operations watchdog. It no longer invokes or owns repo Face/native Persona CTB heartbeats; Yggdrasil's resident `persona-scheduler` is the sole Persona cadence owner. - VoidBot stack startup now treats Bifrost's CultMesh command lane as a dependency. `scripts/start-voidbot-stack.ps1` checks `BIFROST_CULTMESH_COMMAND_URI`, `BIFROST_CULTMESH_STORE_PATH`, and the Bifrost command processor, exports the provider store when missing, and records `runtime-stack.json.bifrost` before starting the bot and worker. Product HTTP health is not VoidBot's authority for Bifrost daemon readiness. - The typed rumination path now has a model-branch fixture: `npm run moderation:rumination:fixture` seeds a temporary `.cc`, routes the non-skip Codex branch through a fake child, and verifies short-term memory, incubation, agency-pressure, and candidate-intervention proposals cross only through parent-applied typed operations without leaking exact timestamps into the prompt-facing context. - The typed rumination speech path is parent-owned now. Candidate interventions can carry an optional delivery target; the child may queue candidates but must not call Discord send scripts. The parent runner delivers at most one newly queued deliverable candidate per pass, records a delivery receipt, and marks the candidate spoken through `mark_candidate_intervention_spoken`. `npm run moderation:rumination:speech-fixture` verifies this loop with fake Codex and fake Discord send helpers.