From b0dbb2d8bdc4f93749d7c3d0ac677be9d00e79f5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 12 Jul 2026 02:21:43 +0100 Subject: [PATCH] Add semantic Persona response pressure --- notes/fresh-workspace-handoff.md | 1 + notes/voidbot-current-system-map.md | 3 +- package.json | 1 + packages/core/src/index.ts | 1 + .../core/src/repo-face-response-pressure.ts | 89 ++++++++ packages/rag/src/affinity-projection.ts | 73 +++++++ packages/rag/src/index.ts | 1 + scripts/render-voidbot-swarm-dashboard.mjs | 15 ++ scripts/run-repo-face-heartbeats.ts | 204 +++++++++++++++++- scripts/smoke-repo-face-response-pressure.ts | 99 +++++++++ state/evidence.jsonl | 1 + state/map.yaml | 1 + 12 files changed, 483 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/repo-face-response-pressure.ts create mode 100644 packages/rag/src/affinity-projection.ts create mode 100644 scripts/smoke-repo-face-response-pressure.ts diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 69f8791..1d57193 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -142,6 +142,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - 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 heartbeats now use one CTB/Final-Fantasy-X-style initiative queue. `scripts/run-repo-face-heartbeats.ts` still has its old filename, but it now reconciles built-in system agents plus registered repo Faces, stores the roster at `REPO_FACE_HEARTBEAT_STATE_PATH`, advances virtual initiative time independently of wall-clock due dates, and dispatches selected turns. Repo Faces queue normal `repo-face-rumination` jobs; built-in `void` starts the existing moderation rumination runner as its turn. Active jobs/locks freeze initiative: recovery starts only after the owner-Codex job or Void moderation lock clears, and completion cannot grant another turn in the same pulse. The active-job scan now marks stale approved/running Face jobs failed through the job queue and records `stale_active_turn_recovered`, so a dead worker claim cannot freeze one Face forever. `GameCult Local Orchestrator` is the live hidden local scheduled task that ticks the scheduler; `npm run state:install-repo-face-heartbeat-task` remains an isolation/backcompat installer, not the normal pulse owner. Heat knobs are `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; overrides can target `all`, `kind:`, `turn:`, `identity:`, `repo:`, `display:`, `channel:`, or bare identity/repo/display keys. Idle cooling is part of the same scheduler: if watched Discord channels have no recent human message, unprompted swarm turns are globally clamped to one dispatch per idle recovery window, defaulting to one every 30 minutes, while pending mentions still bypass the brake. Repo Face prompts now include a jurisdiction-dive cadence so unattended heartbeats periodically inspect repo/history terrain with source tools and convert real findings into state, proposals, essay seeds, agency pressure, or speech. +- 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. - Repo Face prompts now carry deterministic human pronoun guidance outside the model-projected state surface. The 2026-05-22 Nibu misgendering happened because Metacrat's interaction profile correctly resolved explicit `she/her`, but that fact was only inside the state packet handed to the projector, and the projector omitted it. `repo-face-turn.prompt.md` now renders `Known human pronoun guidance` near the transcript and again near the final writing instruction; the renderer also chooses evidence supporting the resolved pronoun set rather than the freshest contradictory ambient read. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 1aa58bc..f98ccaa 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -177,7 +177,8 @@ Verse-facing service contract: `docs/architecture/voidbot-verse-service-contract - 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. -- `scripts/run-repo-face-heartbeats.ts` now acts as the broader agent 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-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 queue `repo-face-rumination` owner-Codex jobs; built-in Void currently participates as the `void` system agent and starts the existing moderation rumination runner as its turn. Initiative freezes while an owner-Codex job or Void moderation lock is active; recovery is scheduled only after that active turn completes, and completion cannot grant another turn in the same scheduler pulse. The active-job scan recovers stale Face jobs by marking ancient approved/running rows failed through the job queue before building the active-turn map. Above-baseline heat bypasses repo Face nap gating, while active-turn freeze still prevents overlapping thoughts. +- `scripts/run-repo-face-heartbeats.ts` now acts as the broader agent 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-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 queue `repo-face-rumination` owner-Codex jobs; built-in Void currently participates as the `void` system agent and starts the existing moderation rumination runner as its turn. Initiative freezes while an owner-Codex job or Void moderation lock is active; recovery is scheduled only after that active turn completes, and completion cannot grant another turn in the same scheduler pulse. The active-job scan recovers stale Face jobs by marking ancient approved/running rows failed through the job queue before building the active-turn map. Above-baseline heat shortens recovery but does not bypass repo Face rest gating; active-turn freeze prevents overlapping thoughts. +- 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 live wall-clock pulse for this scheduler is `scripts/run-gamecult-orchestrator.ps1`, installed as `GameCult Local Orchestrator`. `scripts/install-repo-face-heartbeat-task.ps1` remains available for isolated testing, but its per-organ scheduled task should stay disabled in normal operation. 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. - The local ignored registry now also includes Epiphany for `EpiphanyAgent`, with Face state in `E:/Projects/EpiphanyAgent/.voidbot/state/epiphany.cc`, Discord role id `1506375937439301642`, and Aquarium channel allow-list `1501196543150264332`. Her repo-local `.voidbot` birth surface has completed in plan/review mode; the generated EpiphanyAgent startup packets still require Self review before accepted native birth mutations. Her jurisdiction is the self-improvement spine: typed state, CultCache/CultNet, Codex bridge boundaries, birth/init rites, low-copy runtime contracts, review gates, graph-shaped memory, heartbeat physiology, and deletion of duplicated authority. diff --git a/package.json b/package.json index 035c76d..3c8acfa 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "agents:interpreter-loop-smoke": "node scripts/smoke-repo-face-interpreter-loop.mjs", "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:memory-projection-smoke": "node scripts/smoke-repo-face-memory-projection.mjs", "agents:article-contract-smoke": "tsx scripts/smoke-repo-face-article-contract.ts", "persona:export-friendly": "tsx scripts/export-persona-friendly-input.ts", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8c56611..3347b1d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,7 @@ export * from "./job-queue"; export * from "./permission-engine"; export * from "./repo-discord-identities"; export * from "./repo-face-heartbeat-queue"; +export * from "./repo-face-response-pressure"; export * from "./repo-face-initializer"; export * from "./repo-face-rest"; export * from "./repo-face-voice-presence"; diff --git a/packages/core/src/repo-face-response-pressure.ts b/packages/core/src/repo-face-response-pressure.ts new file mode 100644 index 0000000..10eb237 --- /dev/null +++ b/packages/core/src/repo-face-response-pressure.ts @@ -0,0 +1,89 @@ +export interface ResponsePressureEvidence { + messageId: string; + observedAt: string; + similarity: number; +} + +export interface ResponsePressureProjection { + identityId: string; + pressure: number; + interrupt: boolean; + evidence: Array; +} + +export interface ResponsePressureParticipant { + identityId: string; + interruptThreshold: number; +} + +export function projectRepoFaceResponsePressure(input: { + participants: ResponsePressureParticipant[]; + evidence: Array; + now: Date; + halfLifeMinutes?: number; + ttlMinutes?: number; + similarityFloor?: number; + maxAwakened?: number; +}): ResponsePressureProjection[] { + const halfLifeMinutes = positive(input.halfLifeMinutes, 30); + const ttlMinutes = positive(input.ttlMinutes, 180); + const similarityFloor = bounded(input.similarityFloor ?? 0.32, 0, 0.99); + const byIdentity = new Map>(); + + for (const item of input.evidence) { + const ageMinutes = Math.max(0, (input.now.getTime() - Date.parse(item.observedAt)) / 60_000); + if (!Number.isFinite(ageMinutes) || ageMinutes > ttlMinutes || item.similarity < similarityFloor) { + continue; + } + const normalized = (item.similarity - similarityFloor) / (1 - similarityFloor); + const contribution = bounded(normalized * 2 ** (-ageMinutes / halfLifeMinutes), 0, 1); + if (contribution <= 0) { + continue; + } + const entries = byIdentity.get(item.identityId) ?? []; + const priorIndex = entries.findIndex((entry) => entry.messageId === item.messageId); + const projected = { ...item, contribution }; + if (priorIndex >= 0) { + entries[priorIndex] = projected; + } else { + entries.push(projected); + } + byIdentity.set(item.identityId, entries); + } + + const projections = input.participants.map((participant) => { + const evidence = (byIdentity.get(participant.identityId) ?? []) + .sort((left, right) => right.contribution - left.contribution || left.messageId.localeCompare(right.messageId)); + const pressure = bounded(noisyOr(evidence.map((entry) => entry.contribution)), 0, 1); + return { + identityId: participant.identityId, + pressure, + interrupt: pressure >= bounded(participant.interruptThreshold, 0, 1), + evidence: evidence.slice(0, 6), + }; + }).sort((left, right) => right.pressure - left.pressure || left.identityId.localeCompare(right.identityId)); + + const awakened = new Set( + projections.filter((projection) => projection.pressure > 0).slice(0, positiveInteger(input.maxAwakened, 3)) + .map((projection) => projection.identityId), + ); + return projections.map((projection) => awakened.has(projection.identityId) + ? projection + : { ...projection, pressure: 0, interrupt: false, evidence: [] }); +} + +function noisyOr(values: number[]): number { + return 1 - values.reduce((remainder, value) => remainder * (1 - bounded(value, 0, 1)), 1); +} + +function bounded(value: number, minimum: number, maximum: number): number { + return Number.isFinite(value) ? Math.max(minimum, Math.min(maximum, value)) : minimum; +} + +function positive(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; +} + +function positiveInteger(value: number | undefined, fallback: number): number { + return Math.max(1, Math.floor(positive(value, fallback))); +} diff --git a/packages/rag/src/affinity-projection.ts b/packages/rag/src/affinity-projection.ts new file mode 100644 index 0000000..250b866 --- /dev/null +++ b/packages/rag/src/affinity-projection.ts @@ -0,0 +1,73 @@ +import { createHash } from "node:crypto"; + +import { cosineSimilarity, type TextEmbedder } from "./hash-embedder"; + +export interface AffinityCard { + id: string; + text: string; +} + +export interface EmbeddedAffinityCard extends AffinityCard { + contentHash: string; + embedderId: string; + embedding: number[]; +} + +export interface AffinityMessage { + id: string; + text: string; + observedAt: string; +} + +export interface AffinityScore { + cardId: string; + messageId: string; + observedAt: string; + similarity: number; +} + +export async function embedAffinityCards( + embedder: TextEmbedder, + cards: AffinityCard[], +): Promise { + const usable = cards.filter((card) => card.id.trim() && card.text.trim()); + const embeddings = await embedder.embedDocuments(usable.map((card) => card.text)); + if (embeddings.length !== usable.length) { + throw new Error(`Affinity embedder returned ${embeddings.length} card vectors for ${usable.length} cards.`); + } + + return usable.map((card, index) => ({ + ...card, + contentHash: createHash("sha256").update(card.text).digest("hex"), + embedderId: embedder.id, + embedding: embeddings[index] ?? [], + })); +} + +export async function scoreAffinityMessages( + embedder: TextEmbedder, + cards: EmbeddedAffinityCard[], + messages: AffinityMessage[], +): Promise { + const mismatched = cards.find((card) => card.embedderId !== embedder.id); + if (mismatched) { + throw new Error(`Affinity card ${mismatched.id} uses ${mismatched.embedderId}, not ${embedder.id}.`); + } + + const usable = messages.filter((message) => message.id.trim() && message.text.trim()); + const embeddings = await embedder.embedDocuments(usable.map((message) => message.text)); + if (embeddings.length !== usable.length) { + throw new Error(`Affinity embedder returned ${embeddings.length} message vectors for ${usable.length} messages.`); + } + + return cards.flatMap((card) => usable.map((message, index) => ({ + cardId: card.id, + messageId: message.id, + observedAt: message.observedAt, + similarity: clampSimilarity(cosineSimilarity(card.embedding, embeddings[index] ?? [])), + }))); +} + +function clampSimilarity(value: number): number { + return Number.isFinite(value) ? Math.max(-1, Math.min(1, value)) : 0; +} diff --git a/packages/rag/src/index.ts b/packages/rag/src/index.ts index 47f1733..5bc6267 100644 --- a/packages/rag/src/index.ts +++ b/packages/rag/src/index.ts @@ -1,4 +1,5 @@ export * from "./embedder-factory"; +export * from "./affinity-projection"; export * from "./history-ingester"; export * from "./history-retrieval"; export * from "./discord-log-importer"; diff --git a/scripts/render-voidbot-swarm-dashboard.mjs b/scripts/render-voidbot-swarm-dashboard.mjs index 42343ff..7f974b9 100644 --- a/scripts/render-voidbot-swarm-dashboard.mjs +++ b/scripts/render-voidbot-swarm-dashboard.mjs @@ -264,6 +264,8 @@ function buildUpcomingTurns(participants) { nextTurnInMinutes: participant.nextTurnInMinutes, effectiveSpeed: participant.effectiveSpeed, heat: participant.heat, + dynamicHeat: participant.dynamicHeat, + responsePressure: participant.responsePressure, lane: index % 3, timelinePosition: participant.activeJobId ? 0 : Math.min(100, Math.max(0, ((Math.max(0, participant.nextTurnInMinutes ?? 0)) / maxMinutes) * 100)), })); @@ -331,6 +333,16 @@ function projectParticipant(participant, pendingMentions, initiativeClock, ident status: String(participant.status ?? "unknown"), currentLoad: numberOrNull(participant.currentLoad), heat: numberOrNull(participant.heat), + dynamicHeat: numberOrNull(participant.dynamicHeat), + responsePressure: numberOrNull(participant.responsePressure), + responsePressureEvidence: Array.isArray(participant.responsePressureEvidence) + ? participant.responsePressureEvidence.slice(0, 6).map((entry) => ({ + messageId: stringOrNull(entry?.messageId), + observedAt: stringOrNull(entry?.observedAt), + similarity: numberOrNull(entry?.similarity), + contribution: numberOrNull(entry?.contribution), + })) + : [], effectiveSpeed: numberOrNull(participant.effectiveSpeed), initiativeSpeed: numberOrNull(participant.initiativeSpeed), nextTurnAt, @@ -2431,6 +2443,8 @@ function renderHtml(snapshot) { statBar("Memory", number(memoryCount), Math.min(100, memoryCount * 7), "cool") + statBar("Pressure", number(counts.pressures || 0), Math.min(100, (counts.pressures || 0) * 14), (counts.pressures || 0) > 4 ? "hot" : "") + statBar("Heat", number(agent.heat), Math.min(100, (agent.heat || 0) * 34), (agent.heat || 0) > 1.5 ? "hot" : "") + + statBar("Response", number(agent.responsePressure), Math.min(100, (agent.responsePressure || 0) * 100), (agent.responsePressure || 0) > 0.6 ? "hot" : "") + + statBar("Dynamic", number(agent.dynamicHeat), Math.min(100, (agent.dynamicHeat || 0) * 40), (agent.dynamicHeat || 0) > 1.5 ? "hot" : "") + statBar("Load", number(agent.currentLoad), Math.min(100, (agent.currentLoad || 0) * 100), (agent.currentLoad || 0) > 0.7 ? "hot" : "") + statBar("Speed", number(agent.effectiveSpeed), Math.min(100, (agent.effectiveSpeed || 0) * 34), "") + "

" + esc(agent.description || "No Face description registered.") + "

" + (agent.channelPermissions || []).map((channel) => "
" + esc(channel.label || "channel") + "x" + esc(number(channel.speedMultiplier || 1)) + "" + esc(channel.topic || "no topic") + "" + esc(channel.speechThreshold || "threshold") + "
").join("") + "
"; @@ -2767,6 +2781,7 @@ function redactSnapshot(snapshot) { ...participant, activeJobId: participant.activeJobId ? "active" : null, constraints: [], + responsePressureEvidence: [], constraintCount: participant.constraintCount, description: participant.description ? truncate(participant.description, 220) : null, faceStatePath: null, diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index c0ec98d..1087807 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -20,6 +20,7 @@ import { loadFaceIdentityRegistry, loadVoidSelfStateTypedDocuments, REPO_FACE_HEARTBEAT_SCHEMA_VERSION, + projectRepoFaceResponsePressure, type RepoFaceRestSnapshot, resolveRepoFaceStatePath, type RepoFacePendingMention, @@ -29,8 +30,10 @@ import { import { createTextEmbedder, createVectorStores, + embedAffinityCards, normalizeText, RetrievalService, + scoreAffinityMessages, } from "@voidbot/rag"; import { type EmbeddingChunk, @@ -61,6 +64,15 @@ interface FaceHeartbeatParticipant { status: "active" | "blocked" | "withdrawn" | "offscreen"; groups: string[]; heat: number; + dynamicHeat: number; + responsePressure: number; + responsePressureEvidence: Array<{ + messageId: string; + observedAt: string; + similarity: number; + contribution: number; + }>; + semanticInterruptReceipts: string[]; effectiveSpeed: number; baseRecoveryMinutes: number; nextTurnAt: number; @@ -99,6 +111,7 @@ interface IdleCoolingSnapshot { recoveryMinutes: number; lastUnpromptedTurnQueuedAt?: string; nextUnpromptedTurnAllowedAt?: string; + observedHumanMessages: Array; } interface StaleActiveTurn { @@ -269,6 +282,15 @@ async function main(): Promise { state, now, }); + await applySemanticResponsePressure({ + state, + specs: buildParticipantSpecs(registry.identities), + messages: idleCooling.observedHumanMessages, + restStates, + completedThisTick, + config, + now, + }); const selected = selectReadyParticipants( state, @@ -297,6 +319,8 @@ async function main(): Promise { nextTurnAt: participant.nextTurnAt, recoveryMinutes, heat: participant.heat, + dynamicHeat: participant.dynamicHeat, + responsePressure: participant.responsePressure, effectiveSpeed: participant.effectiveSpeed, pendingMentionCount: pendingMentions.length, }); @@ -366,6 +390,8 @@ async function main(): Promise { initiativeClock: state.initiativeClock, frozen: true, heat: participant.heat, + dynamicHeat: participant.dynamicHeat, + responsePressure: participant.responsePressure, effectiveSpeed: participant.effectiveSpeed, pendingMentionCount: pendingMentions.length, }); @@ -382,6 +408,8 @@ async function main(): Promise { initiativeClock: state.initiativeClock, reason: turn.failureReason, heat: participant.heat, + dynamicHeat: participant.dynamicHeat, + responsePressure: participant.responsePressure, effectiveSpeed: participant.effectiveSpeed, }); } @@ -406,7 +434,11 @@ async function main(): Promise { selected: selected.map((entry) => entry.identityId), queued: queuedIdentityIds, statePath: config.repoFaceHeartbeats.statePath, - idleCooling, + idleCooling: { + ...idleCooling, + observedHumanMessages: undefined, + observedHumanMessageCount: idleCooling.observedHumanMessages.length, + }, })}\n`, ); } @@ -1457,7 +1489,8 @@ function reconcileParticipants( const speed = initiativeSpeedFor(spec, speedOverrides) * spec.channelSpeedMultiplier; const groups = initiativeGroupsFor(spec); const heat = heatFor(spec, groups, globalHeat, heatOverrides); - const effectiveSpeed = clamp(speed * heat, 0.1, 12); + const dynamicHeat = Number.isFinite(current?.dynamicHeat) ? current.dynamicHeat : 1; + const effectiveSpeed = clamp(speed * heat * dynamicHeat, 0.1, 12); const nextTurnAt = Number.isFinite(current?.nextTurnAt) ? current.nextTurnAt : initiativeClock + ((baseRecoveryMinutes / count) * index); @@ -1471,6 +1504,10 @@ function reconcileParticipants( initiativeSpeed: speed, groups, heat, + dynamicHeat, + responsePressure: Number.isFinite(current.responsePressure) ? current.responsePressure : 0, + responsePressureEvidence: Array.isArray(current.responsePressureEvidence) ? current.responsePressureEvidence : [], + semanticInterruptReceipts: Array.isArray(current.semanticInterruptReceipts) ? current.semanticInterruptReceipts : [], effectiveSpeed, baseRecoveryMinutes, nextTurnAt, @@ -1502,6 +1539,10 @@ function reconcileParticipants( status: hasChannel ? "active" : "blocked", groups, heat, + dynamicHeat: 1, + responsePressure: 0, + responsePressureEvidence: [], + semanticInterruptReceipts: [], effectiveSpeed, baseRecoveryMinutes, nextTurnAt, @@ -1624,6 +1665,142 @@ function selectReadyParticipants( return [...mentioned, ...cooled].slice(0, maxJobs); } +async function applySemanticResponsePressure(input: { + state: FaceHeartbeatState; + specs: ParticipantSpec[]; + messages: Array; + restStates: Map; + completedThisTick: Set; + config: ReturnType; + now: Date; +}): Promise { + const specs = input.specs.filter((spec) => { + const participant = input.state.participants.find((entry) => entry.identityId === spec.id); + return Boolean( + spec.identity && + spec.participantKind !== "system_agent" && + participant?.status === "active" && + participant.currentLoad < 1 && + !input.completedThisTick.has(participant.identityId) && + input.restStates.get(participant.identityId)?.isNapping !== true, + ); + }); + const recentMessages = input.messages + .filter((message) => !message.isBot && message.content.trim().length > 0) + .filter((message) => input.now.getTime() - Date.parse(message.timestamp) <= 3 * 60 * 60_000) + .sort((left, right) => right.timestamp.localeCompare(left.timestamp)) + .slice(0, 24); + + if (specs.length === 0 || recentMessages.length === 0) { + for (const participant of input.state.participants) { + participant.dynamicHeat = 1; + participant.responsePressure = 0; + participant.responsePressureEvidence = []; + participant.effectiveSpeed = clamp(participant.initiativeSpeed * participant.heat, 0.1, 12); + } + return; + } + + try { + const embedder = createTextEmbedder({ + backend: input.config.ragEmbeddingBackend, + hashDimensions: input.config.ragEmbeddingDimensions, + ollamaBaseUrl: input.config.ragOllamaBaseUrl, + ollamaModel: input.config.ragOllamaModel, + ollamaTimeoutMs: input.config.ragOllamaTimeoutMs, + }); + const cards = await embedAffinityCards(embedder, specs.map((spec) => ({ + id: spec.id, + text: renderInitiativeAffinityCard(spec), + }))); + const scores = await scoreAffinityMessages(embedder, cards, recentMessages.map((message) => ({ + id: message.id, + text: message.content, + observedAt: message.timestamp, + }))); + const projections = projectRepoFaceResponsePressure({ + participants: input.state.participants + .filter((participant) => specs.some((spec) => spec.id === participant.identityId)) + .map((participant) => ({ + identityId: participant.identityId, + interruptThreshold: participant.interruptThreshold, + })), + evidence: scores.flatMap((score) => { + const spec = specs.find((entry) => entry.id === score.cardId); + const message = recentMessages.find((entry) => entry.id === score.messageId); + if (!spec || !message || !spec.allowedChannelIds.includes(message.channelId)) { + return []; + } + return [{ + identityId: score.cardId, + messageId: score.messageId, + observedAt: score.observedAt, + similarity: score.similarity, + }]; + }), + now: input.now, + maxAwakened: Math.min(3, specs.length), + }); + + for (const participant of input.state.participants) { + const projection = projections.find((entry) => entry.identityId === participant.identityId); + participant.responsePressure = projection?.pressure ?? 0; + participant.responsePressureEvidence = projection?.evidence ?? []; + participant.dynamicHeat = clamp(1 + participant.responsePressure * 1.5, 1, 2.5); + participant.effectiveSpeed = clamp( + participant.initiativeSpeed * participant.heat * participant.dynamicHeat, + 0.1, + 12, + ); + const unseenInterruptEvidence = projection?.interrupt + ? projection.evidence.find((entry) => !participant.semanticInterruptReceipts.includes(entry.messageId)) + : undefined; + if (unseenInterruptEvidence && participant.currentLoad < 1) { + participant.nextTurnAt = Math.min(participant.nextTurnAt, input.state.initiativeClock); + participant.semanticInterruptReceipts = mergeStrings( + participant.semanticInterruptReceipts, + unseenInterruptEvidence.messageId, + ).slice(-40); + input.state.history.push({ + type: "semantic_response_interrupt", + identityId: participant.identityId, + messageId: unseenInterruptEvidence.messageId, + pressure: participant.responsePressure, + similarity: unseenInterruptEvidence.similarity, + contribution: unseenInterruptEvidence.contribution, + observedAt: unseenInterruptEvidence.observedAt, + projectedAt: input.now.toISOString(), + }); + } + } + } catch (error) { + for (const participant of input.state.participants) { + participant.dynamicHeat = 1; + participant.responsePressure = 0; + participant.responsePressureEvidence = []; + participant.effectiveSpeed = clamp(participant.initiativeSpeed * participant.heat, 0.1, 12); + } + input.state.history.push({ + type: "semantic_response_pressure_unavailable", + observedAt: input.now.toISOString(), + reason: collapseWhitespace(error instanceof Error ? error.message : String(error), 300), + }); + } +} + +function renderInitiativeAffinityCard(spec: ParticipantSpec): string { + const identity = spec.identity; + return [ + `${spec.displayName} is the Persona steward of ${spec.repoName}.`, + identity?.description, + ...(identity?.channelPermissions ?? []).flatMap((permission) => [ + permission.topic, + permission.label, + permission.posture, + ]), + ].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)) { @@ -1710,11 +1887,9 @@ async function readIdleCoolingSnapshot(input: { idleAfterMinutes: policy.idleAfterMinutes, recoveryMinutes: policy.recoveryMinutes, lastUnpromptedTurnQueuedAt: newestUnpromptedTurnQueuedAt(input.state), + observedHumanMessages: [] as Array, }; - if (!policy.enabled) { - return { ...base, reason: "disabled" }; - } if (!input.config.botToken) { return { ...base, reason: "missing_discord_bot_token" }; } @@ -1735,6 +1910,9 @@ async function readIdleCoolingSnapshot(input: { if (message.isBot || !message.content.trim()) { continue; } + if (!base.observedHumanMessages.some((entry) => entry.id === message.id)) { + base.observedHumanMessages.push({ ...message, channelId }); + } const timestampMs = Date.parse(message.timestamp); const newestMs = Date.parse(newestHumanActivityAt ?? ""); if (Number.isFinite(timestampMs) && (!Number.isFinite(newestMs) || timestampMs > newestMs)) { @@ -1752,6 +1930,14 @@ async function readIdleCoolingSnapshot(input: { ? new Date(lastUnpromptedTurnQueuedMs + policy.recoveryMinutes * 60_000).toISOString() : undefined; + if (!policy.enabled) { + return { + ...base, + reason: fetchErrors.length > 0 ? "disabled_with_activity_fetch_failure" : "disabled", + lastHumanActivityAt: newestHumanActivityAt, + }; + } + if (!newestHumanActivityAt) { return { ...base, @@ -5584,6 +5770,14 @@ function migrateLegacyHeartbeatState( status: participant.status ?? "active", groups: participant.groups ?? [], heat: Number.isFinite(participant.heat) ? participant.heat : 1, + dynamicHeat: Number.isFinite(participant.dynamicHeat) ? participant.dynamicHeat : 1, + responsePressure: Number.isFinite(participant.responsePressure) ? participant.responsePressure : 0, + responsePressureEvidence: Array.isArray(participant.responsePressureEvidence) + ? participant.responsePressureEvidence + : [], + semanticInterruptReceipts: Array.isArray(participant.semanticInterruptReceipts) + ? participant.semanticInterruptReceipts + : [], effectiveSpeed: Number.isFinite(participant.effectiveSpeed) ? participant.effectiveSpeed : speed, baseRecoveryMinutes, nextTurnAt: minutesUntilReady, diff --git a/scripts/smoke-repo-face-response-pressure.ts b/scripts/smoke-repo-face-response-pressure.ts new file mode 100644 index 0000000..9696031 --- /dev/null +++ b/scripts/smoke-repo-face-response-pressure.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; + +import { projectRepoFaceResponsePressure } from "@voidbot/core"; +import { + embedAffinityCards, + scoreAffinityMessages, + type TextEmbedder, +} from "@voidbot/rag"; + +const now = new Date("2026-07-12T12:00:00.000Z"); +const embedder: TextEmbedder = { + id: "fixture:3", + dimensions: 3, + async embedDocuments(texts) { + return texts.map(vectorFor); + }, + async embedQuery(text) { + return vectorFor(text); + }, +}; + +async function main(): Promise { +const cards = await embedAffinityCards(embedder, [ + { id: "aqua", text: "music synthesizer sound patch" }, + { id: "heimdall", text: "oauth custody authentication grants" }, + { id: "nibu", text: "aetheria lore narrative factions" }, +]); +const scores = await scoreAffinityMessages(embedder, cards, [ + { id: "music", text: "the synthesizer patch has a strange sound", observedAt: now.toISOString() }, + { id: "auth", text: "oauth grants need revocation", observedAt: now.toISOString() }, +]); + +assert.equal(cards.length, 3); +const score = (cardId: string, messageId: string): number => + scores.find((item) => item.cardId === cardId && item.messageId === messageId)?.similarity ?? -1; +assert.ok(score("aqua", "music") > score("heimdall", "music")); +assert.ok(score("heimdall", "auth") > score("aqua", "auth")); + +const projected = projectRepoFaceResponsePressure({ + participants: cards.map((card) => ({ identityId: card.id, interruptThreshold: 0.5 })), + evidence: scores.map((item) => ({ + identityId: item.cardId, + messageId: item.messageId, + observedAt: item.observedAt, + similarity: item.similarity, + })), + now, + maxAwakened: 2, +}); +assert.ok((projected.find((item) => item.identityId === "aqua")?.pressure ?? 0) > 0.5); +assert.ok((projected.find((item) => item.identityId === "heimdall")?.pressure ?? 0) > 0.5); +assert.equal(projected.find((item) => item.identityId === "nibu")?.pressure, 0, "dogpile cap must cool irrelevant cards"); + +const expired = projectRepoFaceResponsePressure({ + participants: [{ identityId: "aqua", interruptThreshold: 0.5 }], + evidence: [{ + identityId: "aqua", + messageId: "old", + observedAt: "2026-07-12T06:00:00.000Z", + similarity: 1, + }], + now, + ttlMinutes: 60, +}); +assert.equal(expired[0]?.pressure, 0, "expired evidence must not alter initiative"); + +const duplicate = projectRepoFaceResponsePressure({ + participants: [{ identityId: "aqua", interruptThreshold: 0.5 }], + evidence: [ + { identityId: "aqua", messageId: "same", observedAt: now.toISOString(), similarity: 0.8 }, + { identityId: "aqua", messageId: "same", observedAt: now.toISOString(), similarity: 0.8 }, + ], + now, +}); +assert.equal(duplicate[0]?.evidence.length, 1, "replayed windows must be idempotent by message id"); + +await assert.rejects( + scoreAffinityMessages({ ...embedder, id: "other" }, cards, []), + /uses fixture:3, not other/, +); + +process.stdout.write(`${JSON.stringify({ ok: true, cards: cards.length, scoreCount: scores.length, projected })}\n`); +} + +void main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); + +function vectorFor(text: string): number[] { + const lower = text.toLowerCase(); + const vector = [ + /music|synth|sound|patch/.test(lower) ? 1 : 0, + /oauth|auth|grant|custody|revocation/.test(lower) ? 1 : 0, + /aetheria|lore|narrative|faction/.test(lower) ? 1 : 0, + ]; + const magnitude = Math.sqrt(vector.reduce((sum, value) => sum + value ** 2, 0)); + return magnitude > 0 ? vector.map((value) => value / magnitude) : vector; +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index e7c29c0..9d955a4 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -385,3 +385,4 @@ {"ts":"2026-07-10T01:47:00Z","type":"operations","status":"ok","note":"Brought the repo Face swarm back up on Codex: local .env now routes REPO_FACE_HEARTBEAT_PROVIDER to owner_codex, Persona turns to gpt-5.6-sol at low effort, and Interpreter/support turns to gpt-5.4 at low effort through bundled Codex CLI 0.144.0-alpha.4. Nibu no-post dry run passed with route/STATE NOTE/SAY; live Postgres jobs now record owner_codex and process inspection verified Sol child plus 5.4 parent. Recovered all-zero derived orchestrator/swarm/Odin stores by quarantining and regenerating them, restored the all-zero canonical Void self-state from the only valid 2026-05-21 typed restorepoint, and verified bot, worker, scheduled heartbeat, mood, swarm surface, Odin RUDP announcement, and CultMesh server are live. Newer Void self-state after the restorepoint was already unrecoverable from the zeroed artifact."} {"ts":"2026-07-10T01:53:00Z","type":"repair","status":"ok","note":"Fixed VoidBot's Odin MCP interface reader for mixed-schema CultCache catalogs. The previous reader opened Odin's whole odin.ccmp through CultMesh while registering only gamecult.eve.surface_state.v1, so hydration failed first on odin.interface_layout.v1 and would next fail on provider advertisements. The reader now uses CultCache's schema-independent inspector to locate Odin's owned all-seer surface without duplicating every provider schema. TypeScript build passed; a fresh child Codex MCP call returned voidbot.swarm; direct MCP smokes passed list_odin_providers, list_odin_verses, get_odin_surface, and load_odin_interface_context."} {"ts":"2026-07-10T02:00:00Z","type":"repair","status":"ok","note":"Fixed missing Discord webhook avatars for the newer Odin gang: Sleipnir, Hermodr, Odin, and Ymir had only local avatarPath entries, while the shared webhook pipe consumes avatarUrl. Added public raw GitHub avatar URLs to the active private Face registry and verified each asset returns HTTP 200 plus the live MCP registry projects hasAvatarUrl=true for all four."} +{"ts":"2026-07-12T12:00:00Z","type":"implementation","status":"ok","note":"Added embedding-derived Persona response pressure to the CTB scheduler without a generative call or second scheduling authority. Canonical Face identity-card projections and recent permitted-channel human messages are embedded symmetrically, scored by cosine similarity, recency-decayed with noisy-OR, capped to three scheduler-eligible Faces, and surfaced as dynamicHeat plus message-scoped evidence. One-shot semantic interrupt receipts prevent a stimulus repeatedly pulling readiness; mentions and scheduler brakes remain authoritative. TypeScript build, deterministic affinity/pressure fixture, and a live read-only 23-participant scheduler dry run passed."} diff --git a/state/map.yaml b/state/map.yaml index 44152a7..7d81c18 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -155,6 +155,7 @@ accepted_design: - Repo Face personality seeding has a named prompt surface now: `prompts/repo-face-personality-seed.prompt.md`. Use it for newborn or strengthening passes that trawl repo docs, source layout, commit history, repo-local `.voidbot` material, and legacy `memory.json`, then convert useful meaning into typed `.cc` state instead of leaving it in inert memory files. - Recent chat consensus can now be packaged for any target Codex workspace through `scripts/feed-codex-chat-consensus.mjs`. The helper reads archived Discord messages, defaults to Aquarium, writes a markdown consensus packet plus a Codex prompt under `.voidbot/artifacts/chat-consensus/`, and only feeds `codex exec` when `--execute` is explicit. - Agent standing heartbeats are owned by one CTB-style initiative scheduler, not by one task per agent. Wall-clock elapsed time advances initiative; the scheduled task only supplies frequent pulses. The scheduler stores per-agent speed/load/groups/heat/effective-speed/next-turn/active-turn state at `REPO_FACE_HEARTBEAT_STATE_PATH`, dispatches repo Faces as ordinary `repo-face-rumination` jobs, and dispatches built-in `void` as the existing moderation rumination runner. Heat can be turned up globally, by participant kind, turn kind, identity, repo, display name, channel, or `all` group without creating new schedulers, but heat only changes recovery speed; it must not fast-forward time. Active-turn freeze and rest gating remain hard brakes unless a direct mention creates room debt. An agent's initiative is frozen while its owner-Codex job or moderation lock is active; recovery is scheduled after completion, not when the job is queued. The Windows pulse task supports a one-minute floor; stream mode should raise heat or temporarily loosen fatigue, not compress wall-clock time. Idle cooling is also scheduler-owned: when watched Discord channels have no recent human activity, non-mentioned swarm turns are globally throttled to one unprompted dispatch per `REPO_FACE_HEARTBEAT_IDLE_RECOVERY_MINUTES` window, while pending mentions still pull their target forward. + - Dynamic Persona response pressure is a derived scheduler input, not Persona memory or a second initiative owner. Each pulse embeds canonical Face identity-card projections and recent permitted-channel human messages through the existing RAG embedder, converts cosine evidence into bounded recency-decayed noisy-OR pressure, caps awakening to the three strongest scheduler-eligible Faces, and records message-scoped evidence plus one-shot semantic interrupt receipts. Pressure changes the recovery calculation when recovery is next scheduled through `dynamicHeat`; only threshold crossing may pull `nextTurnAt` to the current initiative clock. Pending mentions remain hard obligations and active-turn, rest, blocked-status, idle-cooling, and max-job brakes remain authoritative. Embedding failure fails cool at baseline heat. - Runtime liveness is not enough. The operations watchdog must also verify bot/worker freshness by comparing each live process start time against its built `dist/index.js` entrypoint timestamp. A process that predates its entrypoint is stale and must fail `runtime..freshness`; otherwise source/build repairs can silently sit beside an old in-memory worker that keeps eating new Face action DSL output as private summaries. - Active-turn freeze is bounded by actual job liveness, not blind faith in an old `running` row. Repo Face heartbeat jobs that remain approved/running past the stale-job window are marked failed through the job queue, recorded as `stale_active_turn_recovered`, and removed from the active-turn map so one dead worker claim cannot silence a Face indefinitely. - Repo Face rest projection is parent-owned state, not a read-only scheduler illusion. If the heartbeat runner projects a Face sleep cycle forward, it must persist the normalized `update_sleep_cycle` operation to that Face's `.cc` state before ready selection. Missed nap windows roll forward as completed rest instead of starting a brand-new nap at inspection time; otherwise overdue agents can be skipped forever as "currently napping" while raw state keeps the same stale boundary.