diff --git a/config/moderation-review-agent.md b/config/moderation-review-agent.md index 5203beb..a694dd1 100644 --- a/config/moderation-review-agent.md +++ b/config/moderation-review-agent.md @@ -350,8 +350,8 @@ should leave the state cleaner and more connected than it found it. - if your first archive excursion or repo sweep lands on a seam that feels familiar, take another retrieval hop into a different archive branch or repo/doc seam before you consider speaking - when you do retrieve, prefer archive seams that do not overlap the last few bridge syntheses or the last several `recent_archive_excursions` - let a fresh archive seam outrank a comfortable old obsession, but do not force novelty theater when the current thought still has real blood in it - - perform a recent tracked-repo activity sweep with `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3 --cursor-file .voidbot/private/moderation-agent-state.json` when the active thought depends on repo motion or when incubation needs fresher fuel - - treat that helper as an incremental feed, not a four-day nostalgia trough: it advances `moderation_runtime.repo_activity_cursor` and suppresses commits already injected into prior heartbeat sweeps unless you explicitly opt into `--stateless` + - perform a recent tracked-repo activity sweep with `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3 --state-path .voidbot/private/moderation-agent-state.msgpack` when the active thought depends on repo motion or when incubation needs fresher fuel + - treat that helper as an incremental feed, not a four-day nostalgia trough: it advances the typed `update_repo_activity_cursor` operation and suppresses commits already injected into prior heartbeat sweeps unless you explicitly opt into `--stateless` - treat that sweep as a first-class seed for the associative lane; notice experiment clusters, sudden motion, dead pauses, converging themes, and mismatched tempo across the zoo - when the sweep looks familiar, stop summarizing commit perfume and go trawling through actual repo docs, diffs, source context, or adjacent lore until the branch becomes specific enough to deserve memory - prefer branches with concrete stakes: Aetheria-Economy gameplay systems, the Aetheria lore vault, Aquarium frontend work, Epiphany swarm architecture, and VoidBot's own organs are all better fuel than another free-floating maxim about honorable engineering diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 70ff121..af0ac96 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -27,6 +27,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Commit-one boundary naming is now in code at `packages/core/src/void-self-state-domain.ts`: it declares the global typed document kinds and strict operation payload schemas. Runtime behavior has not moved yet. - Read-only projection is now in `packages/core/src/void-self-state-projection.ts`. `loadVoidSelfState` uses that typed projection for nap/reply-mode context while leaving the old richer prompt summary intact until typed documents explicitly own those details. - Typed operations are now applied through `packages/core/src/void-self-state-service.ts` and `scripts/void-self-state.mjs apply-operation`. During migration this service writes the new typed documents and updates only the narrow legacy monolith fields still required by current runners. +- `scripts/export-recent-repo-activity.mjs` no longer writes the JSON working projection for cursor advancement. It applies `update_repo_activity_cursor` through `scripts/void-self-state.mjs`; old `--cursor-file .json` arguments are only translated to the sibling `.msgpack` path for compatibility. - Most of the deterministic cleanup in `scripts/void-memory-organ.mjs` is recent compensator cruft from failed attempts to manually clean an exploded state file. Do not preserve it as an earned organ. Architect the state boundary so that cleanup pile is unnecessary. - Sleep/distillation must be rewritten with a meaning-preservation contract: concrete subject, claim/question, evidence refs, live tension, and future-action implication survive compression. Bulk can die. Meaning does not get put through the office shredder and called a dream. - The owner Codex lane is no longer one swollen file; orchestration, runtime/parsing, rendering, and shared helpers are split under `packages/providers/src/owner-codex-*.ts`. @@ -85,7 +86,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou ## Likely Next Bounded Move - Stop feature work on moderation, mood, agency, and public-lane behavior until the private-state boundary is rebuilt. -- First bounded move after the current commit: move repo-activity cursor updates and delivered-reply receipts behind `scripts/void-self-state.mjs apply-operation`. +- First bounded move after the current commit: move delivered-reply receipts behind `scripts/void-self-state.mjs apply-operation`. - Next cuts should delete unstable foundations early after concrete writers move: replace whole-state agent JSON edits with operation output, remove `legacyJsonPath`/working-projection authority, then cut top-level mirror fields and cleanup code that only compensated for those broken boundaries. - Rebuild sleep/distillation before trusting it again. Fixture check: a memory like `AquariumSynthCSharp: Workflow cannot own the body` must survive sleep with its concrete subject, claim, evidence, tension, and implication intact. - Do not build adapters around the JSON projection. That is how the heap learned to stand upright and ask for snacks. diff --git a/scripts/export-recent-repo-activity.mjs b/scripts/export-recent-repo-activity.mjs index a73eb4f..17cd135 100644 --- a/scripts/export-recent-repo-activity.mjs +++ b/scripts/export-recent-repo-activity.mjs @@ -1,23 +1,25 @@ import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const defaultRepoActivityCursorPath = resolve( +const defaultModerationStatePath = resolve( repoRoot, - ".voidbot/private/moderation-agent-state.json", + ".voidbot/private/moderation-agent-state.msgpack", ); -const moderationStateStoreScriptPath = resolve(repoRoot, "scripts/moderation-state-store.mjs"); +const voidSelfStateScriptPath = resolve(repoRoot, "scripts/void-self-state.mjs"); +const coreDistPath = resolve(repoRoot, "packages/core/dist/index.js"); function main() { const options = parseArgs(process.argv.slice(2)); const config = readConfig(); const trackedRepoNames = readTrackedRepoNames(config.archivePath, options.repoNames); const availableRepos = readAvailableRepos(config.sourceRepoRoot); - const cursorFilePath = options.cursorFile ? resolve(repoRoot, options.cursorFile) : null; - const moderationState = cursorFilePath ? readJsonFileSafe(cursorFilePath) : null; - const repoActivityCursor = readRepoActivityCursor(moderationState); + const canonicalStatePath = resolveCanonicalStatePath(options); + const repoActivityCursor = canonicalStatePath + ? readRepoActivityCursorFromCanonicalState(canonicalStatePath) + : new Map(); const sinceIso = new Date(Date.now() - options.hours * 60 * 60 * 1000).toISOString(); const generatedAt = new Date().toISOString(); @@ -32,12 +34,10 @@ function main() { ); repos.sort(compareRepoActivity); - const cursorUpdate = cursorFilePath + const cursorUpdate = canonicalStatePath ? writeRepoActivityCursor({ - state: moderationState, - cursorFilePath, + canonicalStatePath, repos, - hours: options.hours, generatedAt, }) : { mode: "stateless", updated: false, repoCount: 0 }; @@ -52,7 +52,7 @@ function main() { hours: options.hours, maxCommits: options.maxCommits, cursorMode: cursorUpdate.mode, - cursorFile: cursorFilePath, + statePath: canonicalStatePath, cursorUpdated: cursorUpdate.updated, cursorRepoCount: cursorUpdate.repoCount, totalTrackedRepos: trackedRepoNames.length, @@ -70,7 +70,8 @@ function parseArgs(args) { hours: 72, maxCommits: 3, repoNames: undefined, - cursorFile: defaultRepoActivityCursorPath, + statePath: defaultModerationStatePath, + cursorFile: undefined, }; for (let index = 0; index < args.length; index += 1) { @@ -93,7 +94,12 @@ function parseArgs(args) { options.cursorFile = args[index + 1] ?? ""; index += 1; break; + case "--state-path": + options.statePath = args[index + 1] ?? ""; + index += 1; + break; case "--stateless": + options.statePath = null; options.cursorFile = null; break; default: @@ -112,16 +118,21 @@ function parseArgs(args) { return options; } -function readJsonFileSafe(path) { - try { - return JSON.parse(stripLeadingBom(readFileSync(path, "utf8"))); - } catch (error) { - if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { - return null; - } - - throw error; +function resolveCanonicalStatePath(options) { + if (options.statePath === null) { + return null; } + + if (options.cursorFile) { + const cursorFilePath = resolve(repoRoot, options.cursorFile); + const derived = deriveCanonicalModerationStatePath(cursorFilePath); + if (derived) { + return derived; + } + return cursorFilePath.toLowerCase().endsWith(".msgpack") ? cursorFilePath : null; + } + + return options.statePath ? resolve(repoRoot, options.statePath) : null; } function parseRepoList(value) { @@ -488,37 +499,33 @@ function renderDigest(repos, sinceIso, cursorMode) { return lines.join("\n"); } -function readRepoActivityCursor(state) { - const lookup = new Map(); - const rawCursor = state?.moderation_runtime?.repo_activity_cursor; - if (!rawCursor || typeof rawCursor !== "object" || Array.isArray(rawCursor)) { - return lookup; +function readRepoActivityCursorFromCanonicalState(canonicalStatePath) { + if (!existsSync(canonicalStatePath)) { + return new Map(); } - for (const [repoName, cursorEntry] of Object.entries(rawCursor)) { - if (!repoName || !cursorEntry || typeof cursorEntry !== "object" || Array.isArray(cursorEntry)) { + if (!existsSync(coreDistPath)) { + throw new Error(`Missing built core package at ${coreDistPath}. Run npm run build first.`); + } + + const projectedCursor = readProjectedRepoActivityCursor(canonicalStatePath); + const lookup = new Map(); + + for (const cursorEntry of projectedCursor) { + if (!cursorEntry?.repo) { continue; } - lookup.set(repoName.toLowerCase(), cursorEntry); + lookup.set(cursorEntry.repo.toLowerCase(), { + lastSeenHash: cursorEntry.lastCommitSha ?? null, + lastSeenCommittedAt: cursorEntry.lastCommitAt ?? null, + }); } return lookup; } -function writeRepoActivityCursor({ state, cursorFilePath, repos, hours, generatedAt }) { - if (!state || typeof state !== "object" || Array.isArray(state)) { - return { mode: "incremental", updated: false, repoCount: 0 }; - } - - if (!state.moderation_runtime || typeof state.moderation_runtime !== "object" || Array.isArray(state.moderation_runtime)) { - state.moderation_runtime = {}; - } - - const currentCursor = state.moderation_runtime.repo_activity_cursor; - const nextCursor = - currentCursor && typeof currentCursor === "object" && !Array.isArray(currentCursor) ? { ...currentCursor } : {}; - +function writeRepoActivityCursor({ canonicalStatePath, repos, generatedAt }) { let updated = false; let repoCount = 0; @@ -531,44 +538,73 @@ function writeRepoActivityCursor({ state, cursorFilePath, repos, hours, generate lastSeenHash: repo.latestCommit.hash, lastSeenCommittedAt: repo.latestCommit.committedAt ?? null, lastInjectedAt: generatedAt, - hoursWindow: hours, - branch: repo.branch ?? null, }; - const previousEntry = nextCursor[repo.repoName]; - - if (JSON.stringify(previousEntry) !== JSON.stringify(nextEntry)) { - nextCursor[repo.repoName] = nextEntry; - updated = true; - } - + applyRepoActivityCursorOperation({ + canonicalStatePath, + repoName: repo.repoName, + nextEntry, + }); + updated = true; repoCount += 1; } - if (updated) { - state.moderation_runtime.repo_activity_cursor = nextCursor; - writeFileSync(cursorFilePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); - const canonicalPath = deriveCanonicalModerationStatePath(cursorFilePath); - if (canonicalPath && existsSync(moderationStateStoreScriptPath)) { - execFileSync( - process.execPath, - [ - moderationStateStoreScriptPath, - "commit-working-view", - "--canonical", - canonicalPath, - "--working", - cursorFilePath, - ], - { - cwd: repoRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }, - ); - } + return { mode: "incremental", updated, repoCount }; +} + +function applyRepoActivityCursorOperation({ canonicalStatePath, repoName, nextEntry }) { + if (!existsSync(voidSelfStateScriptPath)) { + throw new Error(`Missing typed self-state CLI at ${voidSelfStateScriptPath}.`); } - return { mode: "incremental", updated, repoCount }; + const operation = { + operation: "update_repo_activity_cursor", + cursor: { + repo: repoName, + lastCommitAt: nextEntry.lastSeenCommittedAt ?? undefined, + lastCommitSha: nextEntry.lastSeenHash ?? undefined, + updatedAt: nextEntry.lastInjectedAt, + }, + }; + + execFileSync( + process.execPath, + [ + voidSelfStateScriptPath, + "apply-operation", + "--canonical", + canonicalStatePath, + "--operation", + JSON.stringify(operation), + ], + { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); +} + +function readProjectedRepoActivityCursor(canonicalStatePath) { + const marker = "__VOIDBOT_ASYNC_RESULT__"; + const script = ` + const core = require(${JSON.stringify(coreDistPath)}); + core.loadModerationState(${JSON.stringify(canonicalStatePath)}) + .then((state) => { + const projected = core.projectModerationStateToTypedSelfState(state); + console.log(${JSON.stringify(marker)} + JSON.stringify(projected.moderationCursor.repoActivityCursor ?? [])); + }) + .catch((error) => { console.error(error); process.exit(1); }); + `; + const output = execFileSync(process.execPath, ["-e", script], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const line = output.split(/\r?\n/).find((entry) => entry.startsWith(marker)); + if (!line) { + throw new Error("Failed to read moderation state through core loader."); + } + return JSON.parse(line.slice(marker.length)); } function deriveCanonicalModerationStatePath(cursorFilePath) { diff --git a/scripts/run-void-moderator-rumination.ps1 b/scripts/run-void-moderator-rumination.ps1 index 3773fe8..42d1539 100644 --- a/scripts/run-void-moderator-rumination.ps1 +++ b/scripts/run-void-moderator-rumination.ps1 @@ -363,7 +363,7 @@ Required operating posture: - For this run, use this exact chronological polling command first: `$historyCommandForRun`. - Do not widen that into a bootstrap `--hours 6` poll if a reviewed cursor already exists. - Use `node scripts/export-random-discord-history.mjs --before --window 6 --min-content-length 24` for novelty excursions when the room is quiet or when a fresh hook deserves an adjacent archive dive. -- Use `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3` to inspect current tracked-repo motion across the broader GameCult zoo. +- Use `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3 --state-path .voidbot/private/moderation-agent-state.msgpack` to inspect current tracked-repo motion across the broader GameCult zoo. - Treat `moderation_runtime.open_cases` as real unfinished business. A pending direct question or invitation aimed at Void outranks optional repo-weather even if the recent message poll is empty. - Treat `moderation_runtime.recent_delivery_receipts` as binding memory of what already got said. If you already answered message X, do not answer message X again just because it still appears in a wider history window. - The saved cursor means "reviewed", not "resolved". Do not confuse those. diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 7058265..908816c 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -92,3 +92,4 @@ {"ts":"2026-05-16T10:03:21Z","type":"implementation","status":"ok","note":"The typed private self-state boundary now exists as code in packages/core/src/void-self-state-domain.ts: one polymorphic CultCache registry names global document kinds for self profile, moderation cursor/open cases/repo cursor, speech receipts, thought memory/incubation, scheduled runtime, and candidate interventions, plus strict operation payload schemas. This is Commit 1 territory only; runtime still uses the old moderation state store until read-only projections and mutation application are built."} {"ts":"2026-05-16T11:01:13Z","type":"implementation","status":"ok","note":"Read-only typed self-state projection now exists in packages/core/src/void-self-state-projection.ts. The direct self-state loader derives nap/reply-mode projection from typed scheduled-runtime and thought-memory documents while preserving the older rich prompt summary until the new document model explicitly owns those details. Template and temp CultCache smoke checks passed."} {"ts":"2026-05-16T11:06:01Z","type":"implementation","status":"ok","note":"Typed self-state mutation service and scripts/void-self-state.mjs now apply strict operation payloads to the polymorphic CultCache file. During migration, the service writes typed global documents and updates the narrow legacy monolith fields current scripts still read; this dual write is centralized in one service, not spread across projection-file editors. Temp-store CLI and repo-cursor operation smokes passed."} +{"ts":"2026-05-16T11:09:34Z","type":"implementation","status":"ok","note":"Repo activity cursor writes now go through scripts/void-self-state.mjs apply-operation with update_repo_activity_cursor instead of editing moderation-agent-state.json and committing the whole projection. export-recent-repo-activity still accepts old --cursor-file JSON paths by deriving the sibling .msgpack path, but the runner/doctrine now point at --state-path .voidbot/private/moderation-agent-state.msgpack. Temp-state repo sweep smoke passed."} diff --git a/state/map.yaml b/state/map.yaml index ee76a7f..3442b1a 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -149,4 +149,4 @@ rejected_paths: current_status: summary: VoidBot's ordinary bot/worker/provider/RAG/Postgres/Qdrant structure is still conceptually sound, but the moderation/mood/private-self-state foundation has become architectural Jenga. It currently depends on canonical MessagePack plus an editable JSON projection, broad passthrough schemas, legacy mirror fields, a giant memory-organ cleanup pass, and a prompt-owned scheduled runner. Much of the cleanup pass is recent compensator cruft from attempts to manually clean exploded state; it should not be dignified into permanent architecture. - next_action: The typed CultCache self-state document vocabulary, read-only projection layer, and operation mutation CLI/service now exist. Next move concrete writers behind those operations, starting with repo activity cursor and delivery receipts, then replace whole-state agent JSON edits with operation output. Delete JSON projection authority and legacy mirrors early instead of adding adapters around them. Redesign sleep/distillation around a meaning-preservation contract before trusting it to compress memory. + next_action: The typed CultCache self-state document vocabulary, read-only projection layer, operation mutation CLI/service, and repo-activity cursor writer now exist. Next move delivered-reply receipts behind the same operation path, then replace whole-state agent JSON edits with operation output. Delete JSON projection authority and legacy mirrors early instead of adding adapters around them. Redesign sleep/distillation around a meaning-preservation contract before trusting it to compress memory.