From 8b370522c15991bcaa82d02bf42cc7196a0dac36 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 10 May 2026 16:37:17 +0100 Subject: [PATCH] Make heartbeat repo weather incremental --- config/moderation-agent-state-template.json | 9 + config/moderation-review-agent.md | 3 +- notes/fresh-workspace-handoff.md | 3 +- notes/voidbot-current-system-map.md | 2 +- scripts/export-recent-repo-activity.mjs | 209 +++++++++++++++++++- state/evidence.jsonl | 1 + state/map.yaml | 1 + 7 files changed, 215 insertions(+), 13 deletions(-) diff --git a/config/moderation-agent-state-template.json b/config/moderation-agent-state-template.json index 7d60201..c85540f 100644 --- a/config/moderation-agent-state-template.json +++ b/config/moderation-agent-state-template.json @@ -695,6 +695,15 @@ } } ], + "repo_activity_cursor": { + "CultLib": { + "lastSeenHash": null, + "lastSeenCommittedAt": null, + "lastInjectedAt": null, + "hoursWindow": 96, + "branch": "main" + } + }, "recent_novelty_checks": [ { "timestamp": null, diff --git a/config/moderation-review-agent.md b/config/moderation-review-agent.md index 6778e81..714abbe 100644 --- a/config/moderation-review-agent.md +++ b/config/moderation-review-agent.md @@ -255,7 +255,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` when the active thought depends on repo motion or when incubation needs fresher fuel + - 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` - 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 - if one repo or lore seam looks unusually alive, you may spend multiple quiet runs following it before reporting back; not every worthwhile thought needs same-turn publication diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index b6e8267..94ed408 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -35,9 +35,10 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Source repo discovery now reconciles automatically during `npm run stack:start` and through the watchdog path, so new repos under `SOURCE_REPO_ROOT` stop waiting for manual hook folklore before they get indexed. - Bot-directed prompts now stay in the raw Discord archive but are excluded from default semantic history indexing and lexical fallback, so repeated summons stop teaching the bot its own call-and-response loops. - Source-grounding hints are advisory now; the owner and local lanes can decide when repo or lore tool use is actually warranted instead of obeying a lexical front-door veto. -- There is now a 15-minute local scheduled moderation loop named `Void Moderator Rumination`. It runs `scripts/run-void-moderator-rumination.ps1`, launches `codex exec` from the VoidBot workspace with the usual MCP/tool surface, treats `config/discord-server-rules.md`, `config/moderation-review-agent.md`, and `styles/void-default.md` as doctrine/personality surfaces, uses `node scripts/export-recent-discord-history.mjs` for chronological polling, uses `node scripts/export-random-discord-history.mjs` for quiet-room novelty dives, uses `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3` to watch cross-repo motion, novelty-checks candidate thoughts against `search_history`, and now keeps canonical moderation state in `.voidbot/private/moderation-agent-state.msgpack` while exposing only the editable working projection at `.voidbot/private/moderation-agent-state.json` to the unattended Codex child. +- There is now a 15-minute local scheduled moderation loop named `Void Moderator Rumination`. It runs `scripts/run-void-moderator-rumination.ps1`, launches `codex exec` from the VoidBot workspace with the usual MCP/tool surface, treats `config/discord-server-rules.md`, `config/moderation-review-agent.md`, and `styles/void-default.md` as doctrine/personality surfaces, uses `node scripts/export-recent-discord-history.mjs` for chronological polling, uses `node scripts/export-random-discord-history.mjs` for quiet-room novelty dives, uses `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3 --cursor-file .voidbot/private/moderation-agent-state.json` to watch cross-repo motion without re-injecting old commits as fresh weather, novelty-checks candidate thoughts against `search_history`, and now keeps canonical moderation state in `.voidbot/private/moderation-agent-state.msgpack` while exposing only the editable working projection at `.voidbot/private/moderation-agent-state.json` to the unattended Codex child. - That moderation state file is now Ghostlight-shaped: identity, canonical state, goals, memories, perceived overlays, and a `moderation_runtime` block. Inside that runtime block, the loop now keeps parallel analytic and associative thought lanes plus a bridge for syntheses, saturation warnings, and unresolved tensions, so one good seam does not get to annex the entire mind. - The moderation loop now records `moderation_runtime.recent_archive_excursions` plus `moderation_runtime.recent_repo_activity_sweeps` and is explicitly told to avoid laundering the same musing through fresh wording. Quiet runs should follow at least one fresh archive seam or concrete hook before they settle into theory, should also sweep recent tracked-repo commit motion for experiment clusters or converging themes, and should not let any single theme dominate more than a couple of recent bridge syntheses without fresh evidence. +- Repo-weather sweeps are now incremental rather than a rolling four-day scrapbook. `scripts/export-recent-repo-activity.mjs` advances `moderation_runtime.repo_activity_cursor` inside `.voidbot/private/moderation-agent-state.json`, so already-injected commits stop coming back as “news” on every heartbeat unless someone explicitly opts into `--stateless`. - Novelty now needs two bars, not one: a thought can be new to the room while still being stale to Void. The memory organ should score both room novelty and self novelty, merge repeated seams into existing clusters instead of minting new thought shards, and cool/refract themes that keep winning just because they already won. - Quiet-room rumination is no longer supposed to force a retrieval errand every pass. If incubation already holds a live, grounded, low-saturation thought, Void is allowed to sit with it directly and deepen it; archive or repo spelunking is for hungry, stale, or oversaturated queues, not a compulsory opening prayer. - The loop is now less shy about that repo weather: if a fresh convergence across active repos survives a pass or two and still feels room-native, it should bias toward a concise herald note or at least a medium-priority candidate intervention instead of filing the thought under private wallpaper. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 7faf8c3..fd73f89 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -167,7 +167,7 @@ Within the Postgres path, the implementation is split on purpose now too: 1. The Windows scheduled task `Void Moderator Rumination` starts `scripts/run-void-moderator-rumination.ps1` every 15 minutes. 2. The runner launches `codex exec` from `E:\Projects\VoidBot` with full local workspace/tool access so the child agent sees the same project rules, `.codex/config.toml`, and `voidbot` MCP server as a normal VoidBot workspace session. 3. The child agent reads `config/discord-server-rules.md`, `config/moderation-review-agent.md`, `styles/void-default.md`, and the editable working projection `.voidbot/private/moderation-agent-state.json`. The runner materializes that projection from the canonical `.voidbot/private/moderation-agent-state.msgpack` file before each pass and commits it back afterward. -4. For chronological heartbeat duty, it polls `node scripts/export-recent-discord-history.mjs`; when the room is quiet and incubation feels hungry, stale, or oversaturated, it can use `node scripts/export-random-discord-history.mjs` to widen the branch. If an incubating thought is already live and grounded, the loop is allowed to deepen that thought directly instead of performing a novelty errand for ceremony's sake. It also uses `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3` when repo motion is the actual seam or when the queue needs fresher fuel, can inspect exact diffs or nearby source context when one repo seam looks alive, and uses semantic `search_history` to novelty-check candidate thoughts before deciding whether they are already in the room. +4. For chronological heartbeat duty, it polls `node scripts/export-recent-discord-history.mjs`; when the room is quiet and incubation feels hungry, stale, or oversaturated, it can use `node scripts/export-random-discord-history.mjs` to widen the branch. If an incubating thought is already live and grounded, the loop is allowed to deepen that thought directly instead of performing a novelty errand for ceremony's sake. It also uses `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3 --cursor-file .voidbot/private/moderation-agent-state.json` when repo motion is the actual seam or when the queue needs fresher fuel; that helper advances `moderation_runtime.repo_activity_cursor` so the same four-day commit window does not keep getting reinjected as fresh weather. The loop can inspect exact diffs or nearby source context when one repo seam looks alive, and uses semantic `search_history` to novelty-check candidate thoughts before deciding whether they are already in the room. 5. If speaking would genuinely help, it posts through `node scripts/send-discord-message.mjs`; for guild channels and threads that helper can either speak as the base bot or execute through the shared per-channel webhook pipe with an overridden persona name/avatar so swarm agents can front without needing separate Discord bot identities. Otherwise it updates only the ignored moderation-state working projection, which the runner then commits back into canonical MessagePack with the typed cache helper. The state carries cursors, open room obligations, memories, analytic/associative thought lanes, bridge syntheses and saturation notes, recent archive excursions, recent repo-activity sweeps, novelty checks, speaking-bias hints, and candidate interventions. A direct ask aimed at Void is supposed to survive as an `open_cases` obligation until it is answered or explicitly retired; cursor advancement alone is not closure. 6. The runner writes pulse files under `.voidbot/status/moderation-rumination.json` plus `.voidbot/logs/moderation-rumination*`, so the loop can be observed without pretending an app heartbeat card is a real workstation daemon. diff --git a/scripts/export-recent-repo-activity.mjs b/scripts/export-recent-repo-activity.mjs index d0964e1..a73eb4f 100644 --- a/scripts/export-recent-repo-activity.mjs +++ b/scripts/export-recent-repo-activity.mjs @@ -1,16 +1,25 @@ import { execFileSync } from "node:child_process"; -import { readdirSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, writeFileSync } 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( + repoRoot, + ".voidbot/private/moderation-agent-state.json", +); +const moderationStateStoreScriptPath = resolve(repoRoot, "scripts/moderation-state-store.mjs"); 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 sinceIso = new Date(Date.now() - options.hours * 60 * 60 * 1000).toISOString(); + const generatedAt = new Date().toISOString(); const repos = trackedRepoNames.map((repoName) => inspectRepoActivity({ @@ -18,23 +27,37 @@ function main() { repoPath: availableRepos.get(repoName.toLowerCase()), sinceIso, maxCommits: options.maxCommits, + cursorEntry: repoActivityCursor.get(repoName.toLowerCase()) ?? null, }), ); repos.sort(compareRepoActivity); + const cursorUpdate = cursorFilePath + ? writeRepoActivityCursor({ + state: moderationState, + cursorFilePath, + repos, + hours: options.hours, + generatedAt, + }) + : { mode: "stateless", updated: false, repoCount: 0 }; process.stdout.write( `${JSON.stringify( { - generatedAt: new Date().toISOString(), + generatedAt, sourceRepoRoot: config.sourceRepoRoot, archivePath: config.archivePath, since: sinceIso, hours: options.hours, maxCommits: options.maxCommits, + cursorMode: cursorUpdate.mode, + cursorFile: cursorFilePath, + cursorUpdated: cursorUpdate.updated, + cursorRepoCount: cursorUpdate.repoCount, totalTrackedRepos: trackedRepoNames.length, repos, - digest: renderDigest(repos, sinceIso), + digest: renderDigest(repos, sinceIso, cursorUpdate.mode), }, null, 2, @@ -47,6 +70,7 @@ function parseArgs(args) { hours: 72, maxCommits: 3, repoNames: undefined, + cursorFile: defaultRepoActivityCursorPath, }; for (let index = 0; index < args.length; index += 1) { @@ -65,6 +89,13 @@ function parseArgs(args) { options.repoNames = parseRepoList(args[index + 1] ?? ""); index += 1; break; + case "--cursor-file": + options.cursorFile = args[index + 1] ?? ""; + index += 1; + break; + case "--stateless": + options.cursorFile = null; + break; default: throw new Error(`Unknown argument: ${argument}`); } @@ -81,6 +112,18 @@ 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 parseRepoList(value) { return value .split(",") @@ -197,7 +240,7 @@ function readAvailableRepos(sourceRepoRoot) { return lookup; } -function inspectRepoActivity({ repoName, repoPath, sinceIso, maxCommits }) { +function inspectRepoActivity({ repoName, repoPath, sinceIso, maxCommits, cursorEntry }) { if (!repoPath) { return { repoName, @@ -207,6 +250,8 @@ function inspectRepoActivity({ repoName, repoPath, sinceIso, maxCommits }) { recentCommitCount: 0, latestCommit: null, commits: [], + windowRecentCommitCount: 0, + suppressedRecentCommitCount: 0, }; } @@ -230,15 +275,21 @@ function inspectRepoActivity({ repoName, repoPath, sinceIso, maxCommits }) { .filter(Boolean) .map((line) => enrichCommit(repoPath, parseCommitLine(line))) .filter(Boolean); + const freshCommits = filterCommitsSinceCursor(commits, cursorEntry); + const freshCommitCount = freshCommits.length; + const windowRecentCommitCount = Number.isFinite(recentCommitCount) ? recentCommitCount : commits.length; return { repoName, repoPath, status: "ok", branch, - recentCommitCount: Number.isFinite(recentCommitCount) ? recentCommitCount : commits.length, + recentCommitCount: freshCommitCount, + windowRecentCommitCount, + suppressedRecentCommitCount: Math.max(0, windowRecentCommitCount - freshCommitCount), latestCommit, - commits, + commits: freshCommits, + cursorEntry, }; } catch (error) { return { @@ -249,11 +300,36 @@ function inspectRepoActivity({ repoName, repoPath, sinceIso, maxCommits }) { recentCommitCount: 0, latestCommit: null, commits: [], + windowRecentCommitCount: 0, + suppressedRecentCommitCount: 0, error: error instanceof Error ? error.message : String(error), }; } } +function filterCommitsSinceCursor(commits, cursorEntry) { + if (!Array.isArray(commits) || commits.length === 0 || !cursorEntry || typeof cursorEntry !== "object") { + return commits; + } + + const lastSeenHash = typeof cursorEntry.lastSeenHash === "string" ? cursorEntry.lastSeenHash.trim() : ""; + if (lastSeenHash) { + const seenIndex = commits.findIndex((commit) => commit?.hash === lastSeenHash); + if (seenIndex !== -1) { + return commits.slice(0, seenIndex); + } + } + + const lastSeenCommittedAt = + typeof cursorEntry.lastSeenCommittedAt === "string" ? cursorEntry.lastSeenCommittedAt.trim() : ""; + const lastSeenMs = lastSeenCommittedAt ? Date.parse(lastSeenCommittedAt) : Number.NaN; + if (Number.isFinite(lastSeenMs)) { + return commits.filter((commit) => Date.parse(commit.committedAt) > lastSeenMs); + } + + return commits; +} + function execGit(repoPath, args) { return execFileSync("git", ["-C", repoPath, ...args], { cwd: repoRoot, @@ -341,25 +417,38 @@ function compareRepoActivity(left, right) { return rightTimestamp.localeCompare(leftTimestamp); } -function renderDigest(repos, sinceIso) { - const lines = [`Recent tracked repo activity since ${sinceIso}:`]; +function renderDigest(repos, sinceIso, cursorMode) { + const isIncremental = cursorMode === "incremental"; + const lines = [ + isIncremental + ? `New tracked repo activity since the saved repo cursor (bounded to ${sinceIso}):` + : `Recent tracked repo activity since ${sinceIso}:`, + ]; + let mentionedRepoCount = 0; for (const repo of repos) { if (repo.status === "missing") { lines.push(`- ${repo.repoName}: missing locally under SOURCE_REPO_ROOT`); + mentionedRepoCount += 1; continue; } if (repo.status === "error") { lines.push(`- ${repo.repoName}: failed to read git activity (${repo.error})`); + mentionedRepoCount += 1; + continue; + } + + if (isIncremental && repo.recentCommitCount <= 0) { continue; } const header = repo.recentCommitCount > 0 - ? `- ${repo.repoName} [${repo.branch}]: ${repo.recentCommitCount} recent commit${repo.recentCommitCount === 1 ? "" : "s"}` + ? `- ${repo.repoName} [${repo.branch}]: ${repo.recentCommitCount} ${isIncremental ? "new" : "recent"} commit${repo.recentCommitCount === 1 ? "" : "s"}` : `- ${repo.repoName} [${repo.branch}]: no recent commits`; lines.push(header); + mentionedRepoCount += 1; if (repo.commits.length > 0) { for (const commit of repo.commits) { @@ -381,16 +470,116 @@ function renderDigest(repos, sinceIso) { lines.push(` paths: ${commit.changedPaths.join(", ")}`); } } - } else if (repo.latestCommit) { + } else if (!isIncremental && repo.latestCommit) { lines.push( ` - latest overall ${repo.latestCommit.committedAt} ${repo.latestCommit.author}: ${repo.latestCommit.subject}`, ); } } + if (mentionedRepoCount === 0) { + lines.push( + isIncremental + ? "- No new tracked repo commits crossed the saved repo-activity cursor." + : "- No recent tracked repo commits.", + ); + } + 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; + } + + for (const [repoName, cursorEntry] of Object.entries(rawCursor)) { + if (!repoName || !cursorEntry || typeof cursorEntry !== "object" || Array.isArray(cursorEntry)) { + continue; + } + + lookup.set(repoName.toLowerCase(), cursorEntry); + } + + 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 } : {}; + + let updated = false; + let repoCount = 0; + + for (const repo of repos) { + if (repo.status !== "ok" || !repo.latestCommit?.hash || repo.recentCommitCount <= 0) { + continue; + } + + const nextEntry = { + 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; + } + + 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 deriveCanonicalModerationStatePath(cursorFilePath) { + if (typeof cursorFilePath !== "string" || !cursorFilePath.toLowerCase().endsWith(".json")) { + return null; + } + + const candidate = `${cursorFilePath.slice(0, -".json".length)}.msgpack`; + return existsSync(candidate) ? candidate : null; +} + function stripLeadingBom(input) { return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input; } diff --git a/state/evidence.jsonl b/state/evidence.jsonl index ac3c0fd..5035f2b 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -66,3 +66,4 @@ {"ts":"2026-05-08T16:06:43Z","type":"architecture","status":"ok","note":"Quiet-room moderation no longer treats retrieval as compulsory ritual. If incubation already holds a live, grounded, low-saturation thought, Void may deepen it directly; archive dives and repo sweeps are now explicit support for hungry, stale, or oversaturated queues rather than mandatory throat-clearing before every private thought."} {"ts":"2026-05-08T19:39:40Z","type":"implementation","status":"ok","note":"Discord bot token health checks can hit Discord API rate limits even when the token is fine. The watchdog now distinguishes HTTP 429 from actual credential failures, downgrading rate limits to a warning with retry-after data instead of reporting a dead token."} {"ts":"2026-05-09T18:08:46Z","type":"implementation","status":"ok","note":"Moderation rumination mojibake came from UTF-8 boundaries in the PowerShell runner, not corrupted canonical state. Explicitly decode redirected Codex stdout/stderr as UTF-8, read the saved final-message file as UTF-8, and write stdin through a UTF-8 StreamWriter for Windows PowerShell 5.1 compatibility."} +{"ts":"2026-05-10T15:36:28Z","type":"implementation","status":"ok","note":"Heartbeat repo weather was re-injecting the same four-day commit window on every run because the repo-activity helper had no per-repo cursor. The helper now uses moderation state as an incremental cursor store, persists repo commit cutoffs through the canonical MessagePack-backed state seam, and suppresses already-injected commits by default so repeated sweeps return only genuinely new motion."} diff --git a/state/map.yaml b/state/map.yaml index 62a104e..72903f4 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -73,6 +73,7 @@ accepted_design: - The moderation loop is also Void's only standing cross-project perch. Quiet runs should sweep recent tracked-repo commit activity, record repo-activity passes in state, and treat the broader experiment zoo as fair game for grounded commentary when that wider view would help the room. - The moderation self-state now lives behind a typed cache seam: canonical state in MessagePack, editable unattended working view in JSON, consumer code talking to the persistence layer instead of pawing raw files directly. - The repo-weather lane should not be pathologically timid: a fresh, grounded convergence across active repos should bias the bridge toward a concise herald note or at least a stronger draft instead of being buried as private diary sludge by default. + - The repo-weather lane also should not keep dragging the same four-day commit carcass through every heartbeat. The recent repo-activity helper now advances `moderation_runtime.repo_activity_cursor` inside the live moderation state so already-injected commits stop coming back as fresh weather by default. - Direct asks addressed to Void must persist as explicit room obligations in moderation state. The reviewed cursor is not the same thing as closure, and optional repo-weather should not preempt an unanswered human. - When quiet-room repo weather starts repeating itself, the correction should be deeper archive/repo/lore digging and better salience, not a growing hedge maze of cooldown knobs and brittle rules. - Before suppressing a thought, the moderation loop should novelty-check it against archived Discord with semantic search. If the room has not really said the thing yet, that novelty should push strongly toward speech rather than silence.