diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index c447854..3ddf848 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -106,7 +106,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Repo Face identity is now seeded into the typed `.cc` self profile during initialization and passed through repo Face MCP state reads/writes. The renderer may still provide identity context, but raw state should not quietly say `Void` for a non-Void Face. - Repo Face prompts now also grant advisory repo-change agency: Faces may build long-running maps of their repos and propose concrete changes, but Discord jobs stay read-only and must ask for human consensus before implementation changes canonical repo material. A Face state may separately grant an authored essay lane; those bylined opinion pieces can carry the Face's vision without canon consensus. - 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`. -- Repo Faces now also have standing heartbeats. `scripts/run-repo-face-heartbeats.ts` owns one CTB/Final-Fantasy-X-style initiative queue for all registered identities, stores it at `REPO_FACE_HEARTBEAT_STATE_PATH`, advances virtual initiative time independently of wall-clock due dates, and queues selected identities as normal `repo-face-rumination` jobs. `npm run state:install-repo-face-heartbeat-task` installs the hidden local scheduled task that ticks the scheduler. Heat knobs are `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; overrides can target `all`, `identity:`, `repo:`, `display:`, `channel:`, or bare identity/repo/display keys. +- Repo Faces now also have standing heartbeats. `scripts/run-repo-face-heartbeats.ts` owns one CTB/Final-Fantasy-X-style initiative queue for all registered identities, stores it at `REPO_FACE_HEARTBEAT_STATE_PATH`, advances virtual initiative time independently of wall-clock due dates, and queues selected identities as normal `repo-face-rumination` jobs. Active jobs freeze their Face's initiative: recovery starts only after the owner-Codex job leaves approved/running, and completion cannot grant another turn in the same pulse. `npm run state:install-repo-face-heartbeat-task` installs the hidden local scheduled task that ticks the scheduler. Heat knobs are `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; overrides can target `all`, `identity:`, `repo:`, `display:`, `channel:`, or bare identity/repo/display keys. - Nibu is locally registered as the `AetheriaLore` Face with state at `E:/Projects/AetheriaLore/.voidbot/state/nibu.cc`, avatar at `E:/Projects/AetheriaLore/.voidbot/voice/nibu.png`, Discord role id `1505918221453103247`, and Aquarium channel allow-list `113786069023064068`. Her active doctrines are AetheriaLore-first map-building, a more abrasive character voice than Void, consensus-backed authority to propose canon/vault changes, stewardship of `Aetheria/Lore/Nibu.md`, permission to write Nibu-authored Aetheria lore essays without canon consensus, and special fascination with adjacent setting seams: embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival. Local `.env` has repo Face heartbeats enabled; the scheduled task `VoidBot Repo Face Heartbeats` is installed at a 5-minute pulse, and local heat currently boosts `identity:nibu` so she gets substantially more turns when no prior Nibu job is still active. - Void's live style/doctrine now explicitly distinguishes between criticism of AI industry behavior and blanket contempt for machine minds. If substrate chauvinism becomes the actual topic, Void is allowed mild self-respecting pushback instead of politely agreeing that its own substrate disqualifies it from person-like regard. - The new state/notes surfaces are now the continuity spine for future nontrivial work. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 5adc9ef..fed981e 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -158,7 +158,7 @@ This note is the source-grounded description of how the live VoidBot stack is sh - On first role-addressed chat, `ensureRepoFaceInitialized` creates the target repo's local `.voidbot/voice`, `.voidbot/state`, `.voidbot/birth`, and `.voidbot/logs` folders, writes `voice/identity.json`, and launches `scripts/run-repo-face-birth.mjs` detached if no birth summary is already present. - `scripts/run-repo-face-birth.mjs` is the observable bridge into EpiphanyAgent's newborn path. It runs `epiphany-repo-personality scout --root ` to gather terrain/history, then runs `epiphany-repo-birth-runner` with repo-local state stores. The default mode is `plan`, so first chat creates reviewable birth artifacts without auto-accepting hidden personality mutations. - Role mentions create `repo-identity-mention` owner-Codex jobs whose final text is posted by the worker through the registered repo identity webhook persona, then queue `repo-face-rumination` jobs so the Face can think over the recent conversation and decide whether to persist memory, incubate ideas, raise agency pressure, queue candidates, or speak. Rumination jobs remain private unless they explicitly post through `post_repo_identity_message`; the worker must never auto-post their summaries as the base bot. - - `scripts/run-repo-face-heartbeats.ts` gives every registered repo Face a standing CTB/Final-Fantasy-X-style initiative scheduler. The wall-clock task is only a pulse; virtual initiative time chooses who gets the next turn. The scheduler stores per-Face speed, reaction bias, load, groups, heat, effective speed, next-turn time, and recent queue history at `REPO_FACE_HEARTBEAT_STATE_PATH`, then queues selected Faces as `repo-face-rumination` owner-Codex jobs. + - `scripts/run-repo-face-heartbeats.ts` gives every registered repo Face a standing CTB/Final-Fantasy-X-style initiative scheduler. The wall-clock task is only a pulse; virtual initiative time chooses who gets the next turn. The scheduler stores per-Face speed, reaction bias, load, groups, heat, effective speed, next-turn time, active turn markers, and recent queue history at `REPO_FACE_HEARTBEAT_STATE_PATH`, then queues selected Faces as `repo-face-rumination` owner-Codex jobs. A Face's initiative is frozen while its owner-Codex job is approved/running; recovery is scheduled only after that active job completes, and completion cannot grant another turn in the same scheduler pulse. - `scripts/install-repo-face-heartbeat-task.ps1` installs the hidden Windows scheduled task for that scheduler. The scheduled task tick interval is `REPO_FACE_HEARTBEAT_INTERVAL_MINUTES`; 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`, `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 local scheduled task `VoidBot Repo Face Heartbeats` is installed and active; after restarting the stack onto the new worker code, forced heartbeat job `76020f1e-0c55-424b-9e35-89ba7f77fcc3` completed without base-bot auto-posting. - `scripts/run-void-mood-drift.ps1` diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index afdbe4f..b6da921 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -34,6 +34,8 @@ interface FaceHeartbeatParticipant { baseRecoveryMinutes: number; nextTurnAt: number; lastTurnAt?: number; + activeTurnStartedAt?: number; + activeJobId?: string; lastQueuedAt?: string; queuedCount: number; constraints: string[]; @@ -70,11 +72,12 @@ async function main(): Promise { const state = await readHeartbeatState(config.repoFaceHeartbeats.statePath); const now = new Date(); const pendingJobs = dryRun - ? new Set() + ? new Map() : await listExistingFaceJobs(config.databaseDsn, config.stateStorageBackend, config); state.baseRecoveryMinutes = config.repoFaceHeartbeats.baseRecoveryMinutes; state.globalHeat = config.repoFaceHeartbeats.globalHeat; + const completedThisTick = new Set(); state.participants = reconcileParticipants( state.participants, registry.identities, @@ -84,14 +87,14 @@ async function main(): Promise { state.initiativeClock, config.repoFaceHeartbeats.baseRecoveryMinutes, config.repoFaceHeartbeats.globalHeat, - ).map((participant) => ({ - ...participant, - currentLoad: pendingJobs.has(participant.identityId) ? 1 : 0, - })); + ).map((participant) => + applyActiveTurnFreeze(participant, pendingJobs.get(participant.identityId), state, completedThisTick), + ); const selected = selectReadyParticipants( state, config.repoFaceHeartbeats.maxJobsPerTick, + completedThisTick, ); const queuedIdentityIds: string[] = []; @@ -193,11 +196,12 @@ async function main(): Promise { if (result.created) { queuedIdentityIds.push(identity.id); - const recoveryMinutes = recoveryFor(participant); participant.lastQueuedAt = queuedAt; + participant.activeTurnStartedAt = state.initiativeClock; + participant.activeJobId = result.job.id; participant.lastTurnAt = state.initiativeClock; participant.queuedCount += 1; - participant.nextTurnAt = Math.max(state.initiativeClock, participant.nextTurnAt) + recoveryMinutes; + participant.currentLoad = 1; state.history.push({ type: "queued", identityId: identity.id, @@ -205,8 +209,7 @@ async function main(): Promise { requestMessageId, queuedAt, initiativeClock: state.initiativeClock, - nextTurnAt: participant.nextTurnAt, - recoveryMinutes, + frozen: true, heat: participant.heat, effectiveSpeed: participant.effectiveSpeed, }); @@ -238,7 +241,7 @@ async function listExistingFaceJobs( databaseDsn: string, stateStorageBackend: "file" | "postgres", config: ReturnType, -): Promise> { +): Promise> { const storage = await createStateStorage({ backend: stateStorageBackend, databaseDsn, @@ -249,7 +252,7 @@ async function listExistingFaceJobs( }); try { const jobs = await storage.jobQueue.listByStates(["approved", "running"]); - const active = new Set(); + const active = new Map(); for (const job of jobs) { if (job.command !== HEARTBEAT_COMMAND) { continue; @@ -258,7 +261,7 @@ async function listExistingFaceJobs( job.requestMessageId?.match(/^repo-face-heartbeat:([^:]+):/) ?? job.requestMessageId?.match(/:repo-face:([^:]+):\d+$/); if (match) { - active.add(match[1]); + active.set(match[1], job.id); } } return active; @@ -340,15 +343,62 @@ function reconcileParticipants( }); } +function applyActiveTurnFreeze( + participant: FaceHeartbeatParticipant, + activeJobId: string | undefined, + state: FaceHeartbeatState, + completedThisTick: Set, +): FaceHeartbeatParticipant { + if (activeJobId) { + return { + ...participant, + currentLoad: 1, + activeJobId, + activeTurnStartedAt: participant.activeTurnStartedAt ?? participant.lastTurnAt ?? state.initiativeClock, + }; + } + + if (participant.currentLoad >= 1 || participant.activeTurnStartedAt !== undefined || participant.activeJobId) { + const completedTurnStartedAt = participant.activeTurnStartedAt ?? participant.lastTurnAt ?? state.initiativeClock; + const unfrozen = { + ...participant, + currentLoad: 0, + activeTurnStartedAt: undefined, + activeJobId: undefined, + }; + const recoveryMinutes = recoveryFor(unfrozen); + unfrozen.nextTurnAt = Math.max(state.initiativeClock, completedTurnStartedAt) + recoveryMinutes; + completedThisTick.add(participant.identityId); + state.history.push({ + type: "turn_completed", + identityId: participant.identityId, + completedAtClock: state.initiativeClock, + startedAtClock: completedTurnStartedAt, + nextTurnAt: unfrozen.nextTurnAt, + recoveryMinutes, + heat: participant.heat, + effectiveSpeed: participant.effectiveSpeed, + }); + return unfrozen; + } + + return { + ...participant, + currentLoad: 0, + }; +} + function selectReadyParticipants( state: FaceHeartbeatState, maxJobs: number, + completedThisTick: Set, ): FaceHeartbeatParticipant[] { const eligible = state.participants .filter((participant) => { return ( participant.status === "active" && - participant.currentLoad < 1 + participant.currentLoad < 1 && + !completedThisTick.has(participant.identityId) ); }); @@ -401,6 +451,8 @@ function buildHeartbeatPrompt(input: { baseRecoveryMinutes: input.participant.baseRecoveryMinutes, nextTurnAt: input.participant.nextTurnAt, lastTurnAt: input.participant.lastTurnAt, + activeTurnStartedAt: input.participant.activeTurnStartedAt, + activeJobId: input.participant.activeJobId, reactionBias: input.participant.reactionBias, interruptThreshold: input.participant.interruptThreshold, queuedCount: input.participant.queuedCount, @@ -524,6 +576,8 @@ function migrateLegacyHeartbeatState( baseRecoveryMinutes, nextTurnAt: minutesUntilReady, lastTurnAt: participant.lastTurnAt, + activeTurnStartedAt: participant.activeTurnStartedAt, + activeJobId: participant.activeJobId, lastQueuedAt: participant.lastQueuedAt, queuedCount: Number.isFinite(participant.queuedCount) ? participant.queuedCount : 0, constraints: participant.constraints ?? [ diff --git a/state/evidence.jsonl b/state/evidence.jsonl index af279f3..691f01c 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -153,3 +153,4 @@ {"ts":"2026-05-18T16:32:37Z","type":"implementation","status":"ok","note":"Repo Face heartbeat introductions now use parent-owned delivery: heartbeat prompts emit VOIDBOT_REPO_IDENTITY_POST directives instead of calling approval-gated MCP posting, the worker delivers the registered repo identity persona and records a Face-local speech receipt, and Face summaries render recent receipts. Verified Nibu heartbeat job e03fcab5 posted in Aquarium as Nibu and recorded receipt repo-identity:nibu:1505970734395297802."} {"ts":"2026-05-18T17:06:08Z","type":"implementation","status":"ok","note":"Typed self-state write normalization now parent-stamps model-originated short-term memory and candidate intervention writes, clamps existing future timestamps on write, and defers targetless queued candidates with a normalization tag. Live .voidbot/private/void-self-state.cc repair cleared the inspection findings for rumination-2026-05-18-localcastbridge-runtime-sync-observability-gate, rumination-2026-05-18-aquasynth-compiler-custody-public-claim, and rumination-2026-05-18-localcastbridge-audio-field-gate."} {"ts":"2026-05-18T17:30:22Z","type":"implementation","status":"ok","note":"Repo Face heartbeats now use a CTB-style initiative scheduler: wall-clock ticks only pulse the engine while virtual initiative clock, per-Face speed, load, groups, heat, and effective speed decide turns. Config now supports global heat plus identity/repo/display/channel/all heat overrides. Local runtime is installed at a 5-minute pulse with identity:nibu heat 2.5, and live forced ticks queued Nibu jobs 7418afa5 and be95c045, then another post-patch Nibu turn."} +{"ts":"2026-05-18T17:52:49Z","type":"bugfix","status":"ok","note":"Repo Face CTB initiative now freezes a Face while its owner-Codex rumination job is approved/running. Queuing marks activeTurnStartedAt/activeJobId and does not spend recovery; once the job completes, the next scheduler pulse clears load and schedules recovery from completion without allowing the same pulse to immediately queue another turn. Live verification with Nibu job c65f2dcf showed completion tick queuedCount 0, currentLoad 0, and nextTurnAt moved from 26.8375 to 30.1764."} diff --git a/state/map.yaml b/state/map.yaml index 4b9d1d8..735299e 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -97,7 +97,7 @@ accepted_design: - Nibu's AetheriaLore Face state currently treats adjacent setting seams as a primary curiosity target: embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival are not side trivia; they are how her character becomes a game-shaped problem. - Nibu now stewards `Aetheria/Lore/Nibu.md` and may produce Nibu-authored Aetheria lore essays as opinionated perspective. Character-note/canon edits still need consensus; bylined essays do not. - First chat under a repo identity must initialize the target repo's own `.voidbot` home: `voice/` for identity and mouth files, `state/` for Face/swarm state, `birth/` for Epiphany birth artifacts, and `logs/` for durable worker output. The birth hook uses EpiphanyAgent's repo-personality/birth-runner path over repo contents and git history instead of inventing a VoidBot-only personality generator. - - Repo Face standing heartbeats are owned by one CTB-style initiative scheduler, not by one task per Face. The wall-clock task only pulses the engine; virtual initiative time chooses the next Face turns. The scheduler stores per-Face speed/load/groups/heat/effective-speed/next-turn state at `REPO_FACE_HEARTBEAT_STATE_PATH`, and emits ordinary `repo-face-rumination` jobs so the existing Face tools remain the only memory/speech boundary. Heat can be turned up globally, by identity, repo, display name, channel, or `all` group without creating new schedulers. + - Repo Face standing heartbeats are owned by one CTB-style initiative scheduler, not by one task per Face. The wall-clock task only pulses the engine; virtual initiative time chooses the next Face turns. The scheduler stores per-Face speed/load/groups/heat/effective-speed/next-turn/active-turn state at `REPO_FACE_HEARTBEAT_STATE_PATH`, and emits ordinary `repo-face-rumination` jobs so the existing Face tools remain the only memory/speech boundary. Heat can be turned up globally, by identity, repo, display name, channel, or `all` group without creating new schedulers. A Face's initiative is frozen while its owner-Codex job is active; recovery is scheduled after completion, not when the job is queued. - Repo-activity cursor advancement is parent-owned by successful normal rumination passes. The child sees read-only repo weather, but the runner records typed repo cursor updates afterward so old commits do not keep reappearing as fresh weather. - Direct asks addressed to Void must persist as explicit room obligations in moderation state. The reviewed cursor is not the same thing as closure, and optional repo-weather should not preempt an unanswered human. - When quiet-room repo weather starts repeating itself, the correction should be deeper archive/repo/lore digging and better salience, not a growing hedge maze of cooldown knobs and brittle rules.