diff --git a/apps/persona-scheduler/src/repo-activity-source.ts b/apps/persona-scheduler/src/repo-activity-source.ts new file mode 100644 index 0000000..7d84f59 --- /dev/null +++ b/apps/persona-scheduler/src/repo-activity-source.ts @@ -0,0 +1,68 @@ +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; + +import { + getRepoFaceSourceRepoName, + resolveRepoFaceStatePath, + type RepoDiscordIdentity, +} from "@voidbot/core"; + +export type RepoActivityObservation = + | { status: "unconfigured" } + | { status: "ok"; sourceRepoName: string; digest: string } + | { status: "unavailable"; sourceRepoName: string; detail?: string } + | { status: "malformed"; sourceRepoName: string; raw: string }; + +interface ActivityProcessResult { + status: number | null; + stdout?: string; + stderr?: string; +} + +export function readRepoActivity(input: { + identity: RepoDiscordIdentity; + storageRoot: string; + cwd?: string; + runExporter?: (scriptPath: string, args: string[], cwd: string) => ActivityProcessResult; +}): RepoActivityObservation { + const sourceRepoName = getRepoFaceSourceRepoName(input.identity); + if (!sourceRepoName) return { status: "unconfigured" }; + const cwd = input.cwd ?? process.cwd(); + const scriptPath = resolve(cwd, "scripts", "export-recent-repo-activity.mjs"); + const args = [ + "--repos", sourceRepoName, + "--state-path", resolveRepoFaceStatePath(input.identity, input.storageRoot), + "--read-only", + "--hours", "96", + "--max-commits", "5", + ]; + const result = input.runExporter + ? input.runExporter(scriptPath, args, cwd) + : runExporter(scriptPath, args, cwd); + const stdout = result.stdout ?? ""; + if (result.status !== 0) { + const detail = `${stdout}\n${result.stderr ?? ""}`.trim().slice(-600); + return { status: "unavailable", sourceRepoName, ...(detail ? { detail } : {}) }; + } + try { + const parsed = JSON.parse(stdout) as { digest?: unknown }; + return { + status: "ok", + sourceRepoName, + digest: typeof parsed.digest === "string" ? parsed.digest.trim() : "", + }; + } catch { + return { status: "malformed", sourceRepoName, raw: stdout.slice(0, 500) }; + } +} + +function runExporter(scriptPath: string, args: string[], cwd: string): ActivityProcessResult { + const result = spawnSync(process.execPath, [scriptPath, ...args], { + cwd, + env: process.env, + encoding: "utf8", + windowsHide: true, + timeout: 30_000, + }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 7b0eb48..c932b39 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -149,6 +149,8 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Persona job submission is isolated in `turn-actuator.ts`. That output port alone owns worker actor/guild context construction, `ContextBundle` lowering, the `repo-face-rumination` command, approval defaults, request IDs, and `createJob`; it returns a queue receipt and has no state-selection or prompt-composition authority. - Persona channel policy is isolated in `turn-routing.ts`. It owns pending-attention room preference, default/threshold fallback, snapshot neighborhoods, low-threshold topic projection, and bounded channel-speed projection; prompt/context code receives a route plan and does not choose rooms independently. - Operator control acquisition is isolated in `control-source.ts`. It owns pause-witness parsing and typed CultMesh swarm-heat loading with fail-closed validation; it reports facts while engine commits alone apply heat or record skipped ticks. +- Home-repo activity acquisition is isolated in `repo-activity-source.ts`. It owns source-repo/cursor resolution and the bounded read-only exporter invocation, returning typed observations that prompt composition renders without subprocess or JSON-parsing authority. +- Deployment authority detail: Idunn executes `/srv/odin/deploy-manifests/voidbot`; `/usr/local/sbin/deploy-voidbot` is only the identical inspection/recovery copy. Install both from `gamecult-ops/scripts/deploy-voidbot.sh`. `/srv/build/VoidBot/.git` and `/srv/build/cultcache-ts/.git` must remain wholly `idunn`-owned because lazy partial-clone reads can fetch objects; root owns releases and service promotion, never build-repo Git state. - Dynamic heat is live as derived semantic response pressure. `semantic-pressure-source.ts` batches canonical Face affinity cards and recent permitted-channel human-message embeddings through the existing RAG embedder, applies recency decay/noisy-OR aggregation, and returns bounded projections for at most three scheduler-eligible Faces. It does not write scheduler state. `initiative-engine.ts` alone commits inspectable evidence, `dynamicHeat`, one-time semantic interrupt receipts, and readiness changes. 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. `turn-context-source.ts` owns raw Discord acquisition and its bounded attachment cache; it returns current-room and nearby-channel messages oldest-to-newest plus explicit nearby-channel failure witnesses, without reading Persona state or composing prompts. Pending mentions remain scheduler-owned input. 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 ff7b97f..1b53569 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -188,6 +188,8 @@ VoidBot runs on Yggdrasil. Idunn owns deployment and consumes upstream repositor - `apps/persona-scheduler/src/turn-actuator.ts` is the worker-queue output port. Given a completed prompt, output channel, evidence fields, provider, identity, timestamp, and narrow `createJob` capability, it builds the canonical worker actor/guild `ContextBundle`, creates one approved `repo-face-rumination` job, and returns `{created, activeJobId, requestMessageId}`. It cannot choose a Persona, mutate scheduler state, or compose prompt content. The orchestration shell no longer knows `ContextBuilder`, job approval defaults, worker command names, or `createJob` mechanics. - `apps/persona-scheduler/src/turn-routing.ts` owns channel selection and projection. It turns registered channel permissions, the configured default, and the newest pending-attention channel into one `PersonaChannelPlan` containing the primary output room, nearby snapshot rooms, normalized options, and low-threshold topics. It also owns the bounded channel-speed multiplier used by participant specs. Context readers and prompt renderers consume this plan without deciding routes. - `apps/persona-scheduler/src/control-source.ts` owns operator-control observation. It reads the pause witness with explicit absent/running and malformed/fail-closed semantics, loads the typed `voidbot.swarm_control_state` CultMesh document, validates heat bounds, and returns neutral facts. It cannot write scheduler state; engine commits own control application and skip witnesses. +- `apps/persona-scheduler/src/repo-activity-source.ts` owns the home-repo activity subprocess boundary. It resolves the source repo and Face cursor, invokes `export-recent-repo-activity.mjs` with a bounded 96-hour/five-commit window and mandatory `--read-only`, and returns a typed observation. Prompt composition renders that observation and cannot invoke or parse the exporter. +- Idunn invokes the root-owned audited manifest at `/srv/odin/deploy-manifests/voidbot`; `/usr/local/sbin/deploy-voidbot` is a byte-identical inspection/recovery copy, not the live path. Both derive from `gamecult-ops/scripts/deploy-voidbot.sh`. The `idunn` user exclusively owns mutable Git state in `/srv/build/VoidBot` and `/srv/build/cultcache-ts`, including lazy partial-clone object fetches; the root actuator owns immutable artifact construction/promotion, systemd, and deployment witnesses without running Git as root. - 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/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index 9745f3a..56a12f3 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -1,6 +1,6 @@ import "dotenv/config"; -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; @@ -83,6 +83,10 @@ import { readAgentSwarmPause, readSwarmControlState, } from "../apps/persona-scheduler/dist/control-source.js"; +import { + readRepoActivity, + type RepoActivityObservation, +} from "../apps/persona-scheduler/dist/repo-activity-source.js"; async function main(): Promise { const config = loadConfig(); @@ -415,7 +419,7 @@ async function queueRepoFaceTurn(input: { ); const repoActivitySurface = identity.identityKind === "native_persona" ? renderNativePersonaBodySurface(identity) - : renderRepoFaceRepoActivitySurface(identity, input.config); + : renderRepoActivityObservation(readRepoActivity({ identity, storageRoot: input.config.storageRoot })); const globalAgentDoctrine = await loadGlobalAgentDoctrine(); const conversationMemorySurface = renderRepoFaceConversationTranscript({ identity, @@ -917,7 +921,9 @@ async function assembleRepoFaceTurnPrompt(input: { roomContext, humanPronounGuidance, ); - const repoActivitySurface = renderRepoFaceRepoActivitySurface(identity, input.config); + const repoActivitySurface = renderRepoActivityObservation( + readRepoActivity({ identity, storageRoot: input.config.storageRoot }), + ); const semanticMemoryRecallSurface = await renderRepoFaceSemanticMemoryRecallForTurn( identity, input.config, @@ -3570,59 +3576,25 @@ function renderVisibleConversationChronology(input: { ].join("\n"); } -function renderRepoFaceRepoActivitySurface( - identity: RepoDiscordIdentity, - config: ReturnType, -): string { - const sourceRepoName = getRepoFaceSourceRepoName(identity); - if (!sourceRepoName) { +function renderRepoActivityObservation(observation: RepoActivityObservation): string { + if (observation.status === "unconfigured") { return "- This Persona has no source repository configured; no repo activity was requested."; } - - const statePath = resolveRepoFaceStatePath(identity, config.storageRoot); - const result = spawnSync( - process.execPath, - [ - resolve("scripts", "export-recent-repo-activity.mjs"), - "--repos", - sourceRepoName, - "--state-path", - statePath, - "--read-only", - "--hours", - "96", - "--max-commits", - "5", - ], - { - cwd: process.cwd(), - env: process.env, - encoding: "utf8", - windowsHide: true, - timeout: 30_000, - }, - ); - - if (result.status !== 0) { - const detail = `${result.stdout}\n${result.stderr}`.trim().slice(-600); + if (observation.status === "unavailable") { return [ - `- Recent ${sourceRepoName} activity could not be read for this turn.`, - detail ? `- Reader error: ${collapseWhitespace(detail, 500)}` : "- Reader error: no diagnostic output.", + `- Recent ${observation.sourceRepoName} activity could not be read for this turn.`, + observation.detail ? `- Reader error: ${collapseWhitespace(observation.detail, 500)}` : "- Reader error: no diagnostic output.", "- Do not claim current repo state from stale memory; use source/history tools before making fresh claims.", ].join("\n"); } - - try { - const parsed = JSON.parse(result.stdout) as { digest?: unknown }; - const digest = typeof parsed.digest === "string" ? parsed.digest.trim() : ""; - return digest || `- No recent ${sourceRepoName} activity was reported.`; - } catch { + if (observation.status === "malformed") { return [ - `- Recent ${sourceRepoName} activity output was not parseable.`, - `- Raw output: ${collapseWhitespace(result.stdout, 500)}`, + `- Recent ${observation.sourceRepoName} activity output was not parseable.`, + `- Raw output: ${collapseWhitespace(observation.raw, 500)}`, "- Do not claim current repo state from stale memory; use source/history tools before making fresh claims.", ].join("\n"); } + return observation.digest || `- No recent ${observation.sourceRepoName} activity was reported.`; } function formatConversationMessages(messages: SourceMessage[], limit: number, channelId?: string): string[] { diff --git a/scripts/smoke-persona-initiative-engine.ts b/scripts/smoke-persona-initiative-engine.ts index 0921ca4..20cd26b 100644 --- a/scripts/smoke-persona-initiative-engine.ts +++ b/scripts/smoke-persona-initiative-engine.ts @@ -58,6 +58,7 @@ import { readAgentSwarmPause, readSwarmControlState, } from "../apps/persona-scheduler/dist/control-source.js"; +import { readRepoActivity } from "../apps/persona-scheduler/dist/repo-activity-source.js"; function participant(identityId: string, nextTurnAt: number): InitiativeParticipant { return { @@ -388,6 +389,7 @@ assert.equal(submittedJobs[0].requester.id, "voidbot-agent-turn", "the actuator const routedIdentity = { id: "nibu", + identityKind: "repo_face", repoName: "AetheriaLore", displayName: "Nibu", allowedChannelIds: ["legacy"], @@ -431,6 +433,32 @@ try { await rm(controlDirectory, { recursive: true, force: true }); } +let repoActivityArgs: string[] = []; +const repoActivity = readRepoActivity({ + identity: routedIdentity, + storageRoot: "state-root", + cwd: "workspace", + runExporter: (_scriptPath, args) => { + repoActivityArgs = args; + return { status: 0, stdout: JSON.stringify({ digest: "- Fresh repo motion." }) }; + }, +}); +assert.deepEqual(repoActivity, { status: "ok", sourceRepoName: "AetheriaLore", digest: "- Fresh repo motion." }, "repo activity crosses the source boundary as facts, not prompt prose"); +assert.ok(repoActivityArgs.includes("--read-only"), "Persona turns cannot advance the repo activity cursor while observing body context"); +assert.deepEqual(repoActivityArgs.slice(-4), ["--hours", "96", "--max-commits", "5"], "the source owns one bounded activity window"); +const failedRepoActivity = readRepoActivity({ + identity: routedIdentity, + storageRoot: "state-root", + runExporter: () => ({ status: 1, stderr: "reader failed" }), +}); +assert.deepEqual(failedRepoActivity, { status: "unavailable", sourceRepoName: "AetheriaLore", detail: "reader failed" }, "activity process failure is an explicit observation"); +const malformedRepoActivity = readRepoActivity({ + identity: routedIdentity, + storageRoot: "state-root", + runExporter: () => ({ status: 0, stdout: "not-json" }), +}); +assert.equal(malformedRepoActivity.status, "malformed", "malformed activity output cannot impersonate current repo truth"); + const stateDirectory = await mkdtemp(join(tmpdir(), "voidbot-persona-scheduler-")); try { const statePath = join(stateDirectory, "scheduler.json"); diff --git a/state/evidence.jsonl b/state/evidence.jsonl index fc65625..ab83cb6 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -402,3 +402,5 @@ {"ts":"2026-07-15T22:31:18Z","type":"implementation","status":"ok","note":"Persona channel policy now lives in turn-routing.ts; pending-attention preference, fallback selection, snapshot neighborhoods, low-threshold topics, and channel-speed projection share one deterministic route plan."} {"ts":"2026-07-15T22:33:37Z","type":"implementation","status":"ok","note":"Operator pause and CultMesh swarm-heat acquisition now live in control-source.ts with explicit absent/malformed pause semantics and bounded heat validation; the source returns facts while initiative-engine commits controls."} {"ts":"2026-07-15T22:37:10Z","type":"verification","status":"ok","note":"Yggdrasil exposed a missing spawnSync import after the Bifrost source extraction when a real selected turn entered repo-activity assembly. The remaining repo-activity dependency was restored; local assembly advanced past that call and stopped only at absent local Postgres. Hosted selected-turn verification remains required."} +{"ts":"2026-07-15T22:51:45Z","type":"implementation","status":"ok","note":"Home-repo body context now comes through repo-activity-source.ts, which owns source/cursor resolution, bounded read-only exporter invocation, and typed observations; prompt composition no longer owns subprocess or JSON parsing."} +{"ts":"2026-07-15T22:51:46Z","type":"verification","status":"ok","note":"Idunn live-path audit found /srv/odin/deploy-manifests/voidbot was the invoked actuator while /usr/local/sbin was only an unused copy. gamecult-ops now installs both from one source and runs all build-repository Git operations as idunn; live manifest hashes match and Git ownership was repaired."} diff --git a/state/map.yaml b/state/map.yaml index 3a43ae9..d6dba90 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -71,6 +71,8 @@ active_subgoals: - `turn-actuator.ts` owns lowering an assembled Persona prompt and evidence bundle into the canonical worker `ContextBundle` and approved `repo-face-rumination` job. It owns the worker actor, command, request ID, approval defaults, and `createJob` call, then returns only a queue receipt. The orchestration shell chooses no queue mechanics and the actuator cannot read Persona or initiative state. - `turn-routing.ts` owns deterministic Persona channel policy: newest pending-attention preference, default/threshold fallback, snapshot neighborhoods, low-threshold topic projection, and bounded channel-speed projection. Prompt renderers consume a `PersonaChannelPlan`; they do not recompute routing or scheduler speed. - `control-source.ts` owns operator pause observation and typed CultMesh swarm-heat loading. Missing pause state means running, malformed present pause state fails closed, and out-of-contract heat is rejected. The source returns facts only; `initiative-engine.ts` owns committing controls or skip history. + - `repo-activity-source.ts` owns the read-only home-repo activity exporter contract and returns typed `ok`, `unconfigured`, `unavailable`, or `malformed` observations. It always uses the Face state cursor with `--read-only`; prompt composition renders the observation but cannot invoke or parse the exporter. + - Idunn's live VoidBot actuator is the root-owned `/srv/odin/deploy-manifests/voidbot`; `/usr/local/sbin/deploy-voidbot` is only the matching inspection/recovery copy. Both must be installed from the same canonical `gamecult-ops/scripts/deploy-voidbot.sh`. Mutable Git state under `/srv/build/VoidBot` and `/srv/build/cultcache-ts` belongs exclusively to `idunn`, including lazy partial-clone fetches during archive; root owns immutable release promotion and service control only. - 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.