Extract Persona repo activity source

This commit is contained in:
Meta Vens 2026-07-15 23:51:53 +01:00
commit d1b7d99bc0
7 changed files with 122 additions and 46 deletions

View file

@ -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 };
}

View file

@ -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 <repo> --state-path <face-state> --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.

View file

@ -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:<participantKind>`, `turn:<turnKind>`, `identity:<id>`, `repo:<repo>`, `display:<name>`, `channel:<id>`, 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.

View file

@ -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<void> {
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<typeof loadConfig>,
): 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[] {

View file

@ -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");

View file

@ -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."}

View file

@ -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.