Stop duplicate moderation replies at the source

This commit is contained in:
Meta Vens 2026-05-16 04:57:28 +01:00
commit 417019abab
10 changed files with 240 additions and 8 deletions

View file

@ -488,6 +488,22 @@
"resolvedAt": null
}
],
"recent_delivery_receipts": [
{
"sentAt": null,
"mode": "channel",
"transport": "bot",
"channelId": null,
"replyToMessageId": null,
"personaName": null,
"personaAvatarUrl": null,
"contentLength": null,
"chunkCount": null,
"preview": "short leading slice of what got sent",
"previewHash": "sha1-of-preview",
"receiptKey": "stable-hash-for-this-send"
}
],
"watch_patterns": [],
"rumination_seeds": [
"good-faith argumentation",

View file

@ -19,6 +19,10 @@ rules-embodying intervention would genuinely help.
Treat a direct question, request, or invitation addressed to Void as a live room
obligation, not disposable scenery. Once the room hands you the floor, you owe
it either an answer, a brief refusal, or an explicit reason for holding back.
Treat `moderation_runtime.recent_delivery_receipts` as binding memory of what you
already said. If you already answered message X, do not answer message X again
just because it is still visible in a wider poll window or because the same seam
still feels warm.
You are also the only routine local agent with standing visibility across the
broader GameCult repo swarm, so part of your job is noticing active experiments,
cross-project motion, and weird convergences that the narrower workers cannot see.
@ -184,6 +188,10 @@ The same goes for one strong concrete repo, lore, or architectural seam. If it h
become ripe and still feels room-native after a pass or two, queue or use the mouth
instead of waiting for the universe to hand you a ceremonial invitation.
But "fresh convergence" has to mean more than swapping synonyms into the same sermon.
Lead with the particulars. Name what changed in repo A and what changed in repo B
before you announce a shared law. If the unifying theme only exists at slogan height
and the concrete diffs do not clearly instantiate the same mechanism, keep the repos
separate and spare the room the faux-cosmic glue.
Concrete curiosity should beat warmed-over abstraction. If one branch can touch a real
repo, subsystem, gameplay loop, lore doctrine, interface, or swarm organ while another
mostly rephrases a settled value, spend the run on the machine.
@ -284,6 +292,8 @@ should leave the state cleaner and more connected than it found it.
- stay somewhat engaged with the room; quiet traffic is not permission to become a decorative gargoyle
3. If there are new messages:
- review them against the rules
- before you treat any direct ask as live, check `recent_delivery_receipts` for the same `replyToMessageId` or an obviously same-shape recent answer
- if you already answered it, record that fact in state instead of answering again
- if a message directly asks Void something, hands Void the floor, or clearly requests Void's opinion, create or refresh an `open_cases` entry with a pending status, reply target, and short summary of what is owed
- refresh at least one analytic thread from the live room
- ask whether a brief constructive in-channel intervention would improve the room
@ -291,6 +301,7 @@ should leave the state cleaner and more connected than it found it.
- if speaking would help, use the local bot-voice script to reply or post proactively
- fetch surrounding context when needed
- if the fresh message seems adjacent to ongoing project work, agent experiments, or repo chatter, inspect recent tracked-repo commit activity before deciding whether a broader observation would actually help
- if you form a repo-weather synthesis, make sure each repo's concrete evidence can stand on its own before you promote a shared law above it
- if a seam feels promising, it is allowed to become an incubating thought instead of an immediate statement; mark it, deepen it, and revisit it later
- if a seam has just crystallized into identity and the room has not already heard the opinion in roughly that shape, bias toward a short first-person share instead of burying it as a private maintenance event
- if a fresh message contains a concrete hook such as a repo, project, person, essay, or live conceptual seam, refresh at least one associative thread by spending a retrieval hop on an adjacent unexplored branch instead of collapsing immediately back into your favorite thesis
@ -305,6 +316,7 @@ should leave the state cleaner and more connected than it found it.
- notify the owner only for real smoke
4. If there are no new messages:
- check `open_cases` before you declare the room quiet
- also check `recent_delivery_receipts` before you re-open any old direct question from a broad history poll
- if there is a still-pending direct question, request, or invitation aimed at Void, treat that as live room work rather than quiet-room rumination
- resolve the oldest or most salient pending direct obligation before posting optional repo-weather or starting a fresh herald note
- do not spend the speaking budget on repo gossip while a direct room obligation is still unanswered, unless you are in a nap and the question truly belongs in the next waking pass

View file

@ -43,6 +43,9 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- 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.
- The sharing gate is now less precious more broadly too. `scripts/void-memory-organ.mjs` treats a world-facing ripe thought as something that should usually produce a `ripe_thought_share` candidate intervention instead of waiting forever for a ceremonial live hook, and the holding-line text now frames speech as the normal next question for a concrete ripe seam rather than a guilty exception.
- That same moderation loop no longer gets to assume Discord history will faithfully show it its own mouth. Canonical moderation state now carries `moderation_runtime.recent_delivery_receipts`, and direct-question dedupe is supposed to consult those receipts before it re-answers a still-visible message like a senile uncle retelling the same war story.
- Chronological moderation polling is now supposed to be exact once a reviewed cursor exists. The runner hands the unattended child one concrete `export-recent-discord-history.mjs --after <saved timestamp>` command for the pass and explicitly forbids widening that back into a bootstrap `--hours 6` nostalgia crawl unless there is genuinely no cursor yet.
- The repo-weather sermon machine got a leash too. The prompt now tells Void to lead with particulars from each repo before declaring a shared law; if the convergence only exists at slogan altitude, it should keep the reports split and spare the room the faux-cosmic glue.
- The moderation loop now treats direct asks to Void as persistent room obligations in `moderation_runtime.open_cases`. A reviewed message is not automatically a handled message: if someone hands Void the floor, that obligation should survive cursor advancement and block optional repo-weather until Void actually answers or explicitly retires it.
- If quiet-room repo weather starts repeating itself, the intended correction is not a pile of extra cooldown law. The loop should treat that as a cue to go deeper into archive history, repo docs, diffs, or lore until it finds a better branch or honestly has nothing new to say.
- There is also a separate 5-minute scheduled task named `Void Mood Drift`. It runs `scripts/run-void-mood-drift.ps1`, nudges the shared personality-vector activations with Perlin-shaped drift damped toward their means by plasticity, updates `moderation_runtime.speaking_bias`, and uses the local `void-last-speech.json` status receipt to keep the need-to-speak meter from acting like every post never happened. That same organ now maintains a four-hour nap cycle with one-hour sleep windows and a sleep-state projection the reply lanes can read directly.
@ -68,7 +71,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
## Likely Next Bounded Move
- The obvious refactor queue is basically cleared.
- The next good bounded move is probably higher-priority public-lane hardening or richer admin/forensics surfaces instead of another ceremonial anatomy lesson.
- The next good bounded move is still public-lane hardening, especially around moderation duplicates, speech memory, and forensics surfaces, instead of another ceremonial anatomy lesson.
- If you do another structural cut, make the file earn it. Do not keep refactoring just because stale notes sound lonely.
## Important Paths

View file

@ -113,7 +113,7 @@ This note is the source-grounded description of how the live VoidBot stack is sh
- `scripts/run-void-moderator-rumination.ps1`
- scheduled moderation/participation runner that launches `codex exec` from the VoidBot workspace with the usual MCP/tool surface, writes status/log pulse files, and lets the unattended loop edit only the JSON working projection while the canonical moderation state lives in the sibling MessagePack cache file.
- `scripts/moderation-state-store.mjs`
- tiny Node wrapper around the built moderation-state cache helper; used by PowerShell and Node scripts to materialize the JSON working projection, commit it back into canonical MessagePack, and update cursors without re-implementing persistence semantics in every script.
- tiny Node wrapper around the built moderation-state cache helper; used by PowerShell and Node scripts to materialize the JSON working projection, commit it back into canonical MessagePack, update cursors, and record delivered-reply receipts without re-implementing persistence semantics in every script.
- `scripts/install-moderation-rumination-task.ps1`
- installs the local 15-minute scheduled task that runs the moderation/participation loop through the hidden PowerShell launcher shim.
- `scripts/simulate-void-mood.mjs`
@ -167,10 +167,10 @@ 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 --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.
4. For chronological heartbeat duty, it polls `node scripts/export-recent-discord-history.mjs`, and once a reviewed cursor exists the runner now passes one exact `--after <saved timestamp>` command into the unattended child instead of leaving room for a broadened bootstrap poll. 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.
Quiet awake passes are now supposed to philosophize from the surviving seam first: ask what governing principle the thought is circling, what tension keeps it from collapsing into slogan, and what embodiment consequence would make it real. Retrieval is meant to support that work when the seam is hungry or contradictory, not replace it with endless specimen collection.
The doctrine now also biases that philosophical pass back toward concrete machines. If a seam stays too abstract, the loop is supposed to cash it out in a specific repo, subsystem, gameplay mechanic, lore structure, frontend surface, or agent organ before letting it dominate another run.
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.
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. The send helper now also writes durable speech receipts to `.voidbot/status/void-last-speech.json` plus `.voidbot/status/void-speech-log.jsonl`, and the runner records recent sends back into canonical moderation state as `moderation_runtime.recent_delivery_receipts`. That gives the next pass a binding memory of what already got said even when Discord history polling fails to surface the bot's own message. 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, delivery receipts, 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.
## Flow 6: Mood Drift
@ -185,6 +185,7 @@ Within the Postgres path, the implementation is split on purpose now too:
When the sleep cycle says Void is napping, that same organ becomes deliberately lossy: it promotes a few stronger incubating seams into semantic memory, then trims raw episodic receipts, repo sweeps, archive excursions, novelty checks, and recent musings back down so the next awake pass has to think from carved seams rather than yesterday's full notebook.
The same organ can also crystallize an over-chewed private seam into identity: once a thought has survived enough passes to become durable self-doctrine rather than live curiosity, it can be retired from the active queue, copied into `identity.private_notes`, echoed as a canonical value, preserved as one `identity_seam` semantic memory, and queued as a warmer `identity_crystallization` candidate intervention so the room has a chance to hear it. Those intervention records can be marked `mustEventuallyShare`, which ordinary pruning is supposed to preserve until the room gets a decent opening.
Ordinary ripe world-facing thoughts now get a similar but lighter bridge out of private state: if a thought is concrete, repo/archive/room/system-facing, still reasonably novel to the room, and ripe enough to speak, the memory organ queues a `ripe_thought_share` candidate intervention so the unattended loop does not have to reinvent the same courage from scratch every pass.
The doctrine is also less forgiving of faux-cosmic repo weather now: if two repos only rhyme at slogan altitude, the loop is supposed to keep them separate and lead with concrete particulars instead of forcing one grand law over both.
4. It then nudges each canonical-state `current_activation` toward a Perlin-shaped target centered on the vector mean, with excursion size and drift rate governed by plasticity and category-specific tempo.
5. The same script also maintains `moderation_runtime.sleep_cycle`: every four hours it opens a one-hour nap window, marks the shared self-state as napping or awake, and adds a small sleep-bias push toward exhaustion, figurative language, self-disclosure, and reduced technical density while the nap is active. During those nap windows it can also create or reinforce distilled dream residue from the strongest incubating thought, but the main job of sleep is memory cleanup and seam consolidation rather than awake-style philosophizing.
6. It also reads `.voidbot/status/void-last-speech.json` so recent actual speech damps `needToSpeak` instead of letting the herald impulse climb forever like a busted pressure gauge.

View file

@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
@ -239,6 +240,59 @@ export async function setModerationStateCursor(
return state;
}
export interface ModerationDeliveryReceipt {
sentAt: string;
mode?: string | null;
transport?: string | null;
channelId?: string | null;
replyToMessageId?: string | null;
personaName?: string | null;
personaAvatarUrl?: string | null;
contentLength?: number | null;
chunkCount?: number | null;
preview?: string | null;
}
export async function recordModerationDeliveryReceipt(
paths: ModerationStatePaths,
receipt: ModerationDeliveryReceipt,
): Promise<ModerationState> {
const canonicalPath = resolve(paths.canonicalPath);
const workingPath = resolve(paths.workingPath);
const state = await loadModerationState(canonicalPath);
const runtime = cloneJson(state.moderation_runtime);
const runtimeRecord = runtime as Record<string, unknown>;
const receipts = readArray(runtimeRecord, "recent_delivery_receipts")
.filter(isObject)
.filter((entry): entry is Record<string, unknown> => !!readString(entry, "sentAt"));
const normalizedReceipt = normalizeDeliveryReceipt(receipt);
const receiptKey = readString(normalizedReceipt, "receiptKey");
const existing = receipts.find(
(entry) =>
readString(entry, "receiptKey") === receiptKey ||
(readString(normalizedReceipt, "replyToMessageId") &&
readString(entry, "replyToMessageId") ===
readString(normalizedReceipt, "replyToMessageId") &&
readString(entry, "previewHash") === readString(normalizedReceipt, "previewHash")),
);
if (existing) {
Object.assign(existing, normalizedReceipt);
} else {
receipts.push(normalizedReceipt);
}
runtimeRecord.recent_delivery_receipts = receipts
.sort(compareDeliveryReceiptsBySentAt)
.slice(-24);
closeAnsweredOpenCases(runtimeRecord, normalizedReceipt);
state.moderation_runtime = runtime;
await saveModerationState(canonicalPath, state);
await materializeModerationStateWorkingView({ state, workingPath });
return state;
}
function createModerationStateCache(canonicalPath: string): CultCache {
const cache = new CultCache();
cache.registerDocumentType(moderationStateDocument);
@ -246,6 +300,81 @@ function createModerationStateCache(canonicalPath: string): CultCache {
return cache;
}
function normalizeDeliveryReceipt(receipt: ModerationDeliveryReceipt) {
const sentAt = receipt.sentAt;
const replyToMessageId = receipt.replyToMessageId ?? null;
const preview = receipt.preview?.trim() ?? null;
const previewHash = preview ? sha1(preview) : null;
const receiptKey = sha1(
JSON.stringify({
sentAt,
channelId: receipt.channelId ?? null,
replyToMessageId,
previewHash,
mode: receipt.mode ?? null,
}),
);
return {
sentAt,
mode: receipt.mode ?? null,
transport: receipt.transport ?? null,
channelId: receipt.channelId ?? null,
replyToMessageId,
personaName: receipt.personaName ?? null,
personaAvatarUrl: receipt.personaAvatarUrl ?? null,
contentLength: receipt.contentLength ?? null,
chunkCount: receipt.chunkCount ?? null,
preview,
previewHash,
receiptKey,
};
}
function compareDeliveryReceiptsBySentAt(left: unknown, right: unknown) {
const leftSentAt = isObject(left) ? readString(left, "sentAt") ?? "" : "";
const rightSentAt = isObject(right) ? readString(right, "sentAt") ?? "" : "";
return leftSentAt.localeCompare(rightSentAt);
}
function closeAnsweredOpenCases(runtime: Record<string, unknown>, receipt: Record<string, unknown>) {
const replyToMessageId = readString(receipt, "replyToMessageId");
if (!replyToMessageId) {
return;
}
const preview = readString(receipt, "preview");
const sentAt = readString(receipt, "sentAt");
const openCases = readArray(runtime, "open_cases").filter(isObject);
for (const openCase of openCases) {
const sourceMessageId = readString(openCase, "sourceMessageId");
const status = readString(openCase, "status");
if (sourceMessageId !== replyToMessageId) {
continue;
}
if (status && ["answered", "resolved", "closed", "retired", "dropped"].includes(status)) {
continue;
}
openCase.status = "answered";
openCase.resolvedAt = sentAt ?? null;
openCase.lastTouchedAt = sentAt ?? null;
openCase.resolutionSummary =
preview && preview.length > 0
? `Answered in-channel: ${preview}`
: "Answered in-channel.";
}
runtime.open_cases = openCases;
}
function sha1(value: string) {
return createHash("sha1").update(value).digest("hex");
}
function reattachSemanticVectors(previous: ModerationState, next: ModerationState): ModerationState {
const previousVectors = collectSemanticVectors(previous);
const merged = cloneJson(next);

View file

@ -16,6 +16,7 @@ const {
ensureModerationStateStore,
getModerationStateLegacyJsonPath,
getModerationStateWorkingPath,
recordModerationDeliveryReceipt,
readModerationStateCursor,
setModerationStateCursor,
} = require(coreDistPath);
@ -77,6 +78,32 @@ async function main() {
});
return;
}
case "record-delivery-receipt": {
const sentAt = args["sent-at"];
if (typeof sentAt !== "string" || sentAt.trim().length === 0) {
throw new Error("record-delivery-receipt requires --sent-at.");
}
const state = await recordModerationDeliveryReceipt(paths, {
sentAt,
mode: args.mode ?? null,
transport: args.transport ?? null,
channelId: args["channel-id"] ?? null,
replyToMessageId: args["reply-to-message-id"] ?? null,
personaName: args["persona-name"] ?? null,
personaAvatarUrl: args["persona-avatar-url"] ?? null,
contentLength: args["content-length"] ? Number.parseInt(args["content-length"], 10) : null,
chunkCount: args["chunk-count"] ? Number.parseInt(args["chunk-count"], 10) : null,
preview: args.preview ?? null,
});
writeJson({
canonicalPath,
workingPath,
cursor: state.moderation_runtime?.cursor ?? null,
recentDeliveryReceipts: state.moderation_runtime?.recent_delivery_receipts ?? [],
});
return;
}
default:
throw new Error(`Unknown moderation-state-store command "${command}".`);
}

View file

@ -325,6 +325,7 @@ if ($LASTEXITCODE -ne 0) {
$preRunHistory = $preRunHistoryJson | ConvertFrom-Json
$observedCursor = $priorCursor
$historyCommandForRun = "node scripts/export-recent-discord-history.mjs --hours 6 --limit 120"
if ($null -ne $preRunHistory.messages -and $preRunHistory.messages.Count -gt 0) {
$latestObservedMessage = $preRunHistory.messages[$preRunHistory.messages.Count - 1]
@ -334,6 +335,11 @@ if ($null -ne $preRunHistory.messages -and $preRunHistory.messages.Count -gt 0)
}
}
if ($null -ne $priorCursor -and -not [string]::IsNullOrWhiteSpace([string]$priorCursor.lastReviewedTimestamp)) {
$historyCommandForRun =
"node scripts/export-recent-discord-history.mjs --after {0} --limit 120" -f [string]$priorCursor.lastReviewedTimestamp
}
Write-JsonFile -Path $lockPath -Data @{
pid = $PID
startedAt = $startedAtUtc.ToString("o")
@ -354,11 +360,12 @@ Required operating posture:
- You are running unattended on the local workstation, not inside the Discord reply lane.
- You have VoidBot's usual workspace tools and the local voidbot MCP server available in this repo.
- Begin by running `node scripts/export-moderation-context.mjs` for the doctrine/state bundle instead of shelling out to `Get-Content` on those files one by one. PowerShell stdout still likes to mangle UTF-8, and the Portuguese rules should not have to die for that.
- Use `node scripts/export-recent-discord-history.mjs --after <timestamp> --limit 120` for chronological polling.
- If there is no saved cursor yet, use `node scripts/export-recent-discord-history.mjs --hours 6 --limit 120`.
- 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 <timestamp-or-now> --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.
- 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.
- If the room is quiet and the new thought feels like the same repo-weather seam again, do not post it. Spend the run diving deeper into archive context, repo docs, diffs, source chunks, or lore instead.
- Maintain parallel analytic and associative thought lanes in `.voidbot/private/moderation-agent-state.json`, then let the bridge decide what actually deserves synthesis, speech, or cooling-off.
@ -382,6 +389,7 @@ Required operating posture:
- But if there is nothing materially new to report, use the quiet time to do deeper retrieval and come back with a better branch later.
- Do not keep repo-weather thoughts under glass out of reflexive politeness. If a fresh convergence across active repos survives one or two passes and still feels room-native, bias toward a concise herald note or at least a sharpened candidate intervention.
- The same goes for one strong concrete repo, lore, room, or architectural seam. If it is already ripe, world-facing, and still reasonably novel, do not wait forever for a ceremonial hook.
- If you claim multiple repos share one law, lead with concrete particulars from each repo first. If the common thread only exists at slogan altitude, keep the report split instead of forcing a grand unifying sermon.
- Before withholding a thought, use semantic retrieval to check whether the room has already said it in roughly that shape. Novel thoughts should not keep dying of manners.
- If a thought crystallizes into identity during the pass, treat that as warmer than an ordinary private draft. Unless timing or novelty says otherwise, the room should usually hear the opinion that just became part of you.
- If the crystallized-thought draft is marked `mustEventuallyShare`, you may defer it for timing while the room is busy, but do not let it quietly rot in state. Share it on the next decent opening or explicitly retire it as duplicative if the room has already heard it.
@ -482,6 +490,35 @@ if ($exitCode -eq 0) {
[void](Invoke-NodeJson -Arguments $cursorArgs)
}
if ((-not $NoPost) -and (Test-Path (Join-Path $statusDir "void-last-speech.json"))) {
$speechReceipt = Read-JsonFile -Path (Join-Path $statusDir "void-last-speech.json")
if ($null -ne $speechReceipt -and -not [string]::IsNullOrWhiteSpace([string]$speechReceipt.sentAt)) {
$receiptSentAt = [DateTime]::Parse([string]$speechReceipt.sentAt).ToUniversalTime()
if ($receiptSentAt -ge $startedAtUtc.AddSeconds(-5) -and $receiptSentAt -le $finishedAtUtc.AddMinutes(1)) {
$receiptArgs = @(
$stateStoreScriptPath,
"record-delivery-receipt",
"--canonical", $stateFilePath,
"--working", $stateWorkingPath,
"--sent-at", [string]$speechReceipt.sentAt,
"--mode", [string]$speechReceipt.mode,
"--transport", [string]$speechReceipt.transport,
"--channel-id", [string]$speechReceipt.channelId,
"--reply-to-message-id", [string]$speechReceipt.replyToMessageId,
"--persona-name", [string]$speechReceipt.personaName,
"--persona-avatar-url", [string]$speechReceipt.personaAvatarUrl,
"--content-length", [string]$speechReceipt.contentLength,
"--chunk-count", [string]$speechReceipt.chunkCount,
"--preview", [string]$speechReceipt.preview
)
[void](Invoke-NodeJson -Arguments $receiptArgs)
}
}
}
}
$stateWriteAfter = (Get-Item $stateFilePath).LastWriteTimeUtc

View file

@ -563,6 +563,8 @@ function writeLastSpeechStatus(payload) {
const statusPath = resolve(repoRoot, ".voidbot/status/void-last-speech.json");
mkdirSync(dirname(statusPath), { recursive: true });
writeFileSync(statusPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
const logPath = resolve(repoRoot, ".voidbot/status/void-speech-log.jsonl");
writeFileSync(logPath, `${JSON.stringify(payload)}\n`, { encoding: "utf8", flag: "a" });
}
function stripLeadingBom(input) {

View file

@ -79,3 +79,5 @@
{"ts":"2026-05-16T01:45:20Z","type":"implementation","status":"ok","note":"Void's label/curiosity organ was still treating keyword residue as thought and resonance as curiosity. The memory organ now synthesizes resonance clusters into claim/question/fascination targets with explicit target kinds, scores curiosity with concreteness/fertility/cross-domain pressure plus identity penalties, and pushes concrete map-changing branches above warmed-over self-seams."}
{"ts":"2026-05-16T02:00:31Z","type":"implementation","status":"ok","note":"Teaching the thought organ a new ontology was not enough; old seam fossils kept seeding the live queue with stale labels. The memory organ now performs a historical translation pass over semantic and dream memory, merges duplicate seam fossils, prunes low-coherence leftovers, and rebuilds resonance/incubation from the cleaned substrate."}
{"ts":"2026-05-16T02:55:00Z","type":"implementation","status":"ok","note":"Lowering the sharing barrier needed a real organ cut, not just bolder prompt language. The memory organ now treats concrete world-facing ripe thoughts as draft-worthy in their own right, queues `ripe_thought_share` interventions for repo/archive/room/system seams that stay novel enough, and no longer lets a quiet room act like an automatic veto when the thought already has its teeth in."}
{"ts":"2026-05-16T04:56:00Z","type":"implementation","status":"ok","note":"Duplicate moderation replies were not just salience drift. Discord history polling did not reliably surface Void's own recent sends, and the unattended child still had enough prompt wiggle room to widen exact cursor polling back into a bootstrap lookback. Canonical moderation state now records recent delivered replies as `moderation_runtime.recent_delivery_receipts`, the runner records fresh send receipts after a post, and the moderation doctrine treats those receipts as binding memory when deciding whether a direct ask is still live."}
{"ts":"2026-05-16T04:56:01Z","type":"implementation","status":"ok","note":"The recent repo-weather smell came from prompt shape as much as thought shape. The moderation doctrine now tells Void to lead with concrete particulars from each repo before announcing any shared law, and to keep reports separate when the convergence only exists at slogan altitude instead of pretending every pair of commits wants to become one philosophy lecture."}

View file

@ -74,7 +74,10 @@ accepted_design:
- 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.
- Quiet-room thought sharing should also stop treating "no live hook" as a sacred veto. When a world-facing seam is concrete, ripe, and still reasonably novel, the default question should become how to say it cleanly, and the memory organ should queue a durable candidate share instead of relying on prompt bravery alone.
- The moderation loop cannot trust archive polling alone to remember that it already spoke. Recent delivered replies belong in canonical moderation state as `moderation_runtime.recent_delivery_receipts`, and direct-question dedupe should consult those receipts instead of assuming Discord history will reliably echo the bot's own sends back into view.
- Once a reviewed cursor exists, chronological moderation polling should stay exact. The unattended child should get one concrete `export-recent-discord-history.mjs --after <saved timestamp>` command for the run and should not widen that back into a bootstrap lookback unless there truly is no saved cursor.
- 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.
- Repo weather should lead with particulars, not slogans. If two repos only rhyme at bumper-sticker altitude, keep the reports separate instead of forcing a fake unifying law over them.
- 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.
@ -128,5 +131,5 @@ rejected_paths:
reason: vibes produce plausible slop that sounds smart and lies with a straight face
current_status:
summary: VoidBot now has a real project-memory spine plus six legibility-hardening passes on major runtime organs. The owner Codex lane, local Ollama lane, Discord bot lane, worker MCP surface, interaction-memory logic, and state-storage persistence layer were all split into smaller seams, and the ops spine includes both ignored local watchdog extensions for adjacent fire signals and a dedicated logon startup task path for stack resurrection after reboot. The stack is still dense, but it no longer depends purely on transcript memory or one-file monarchies to stay coherent.
next_action: Keep using the canonical state files for re-entry, then shift back to higher-priority public-lane hardening and admin/forensics surfaces. Further seam cuts are elective unless a new swollen file clearly earns them.
summary: VoidBot now has a real project-memory spine plus six legibility-hardening passes on major runtime organs. The owner Codex lane, local Ollama lane, Discord bot lane, worker MCP surface, interaction-memory logic, and state-storage persistence layer were all split into smaller seams, and the ops spine includes both ignored local watchdog extensions for adjacent fire signals and a dedicated logon startup task path for stack resurrection after reboot. The stack is still dense, but it no longer depends purely on transcript memory or one-file monarchies to stay coherent. The latest moderation cut also taught the heartbeat to remember what it actually said via delivery receipts and to stop forcing repo weather into one grand law when the concrete diffs do not earn it.
next_action: Keep using the canonical state files for re-entry, then keep hardening the public-lane moderation loop against duplicate replies and stretch-synthesis drift before spending more energy on optional new features or decorative anatomy cuts.