From 47cb515cf5b0fcbfbc48882fdae27f66d9649d80 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 4 May 2026 19:00:28 +0100 Subject: [PATCH] Add moderator rumination automation surfaces --- README.md | 21 +++ config/discord-server-rules.md | 55 ++++++++ config/moderation-review-agent.md | 80 ++++++++++++ notes/fresh-workspace-handoff.md | 1 + package.json | 1 + scripts/export-recent-discord-history.ts | 160 +++++++++++++++++++++++ state/evidence.jsonl | 1 + state/map.yaml | 2 + 8 files changed, 321 insertions(+) create mode 100644 config/discord-server-rules.md create mode 100644 config/moderation-review-agent.md create mode 100644 scripts/export-recent-discord-history.ts diff --git a/README.md b/README.md index c56dfa3..ac85e4b 100644 --- a/README.md +++ b/README.md @@ -559,6 +559,27 @@ Defaults come from `.env` / `.env.example`: The watchdog task also uses `Interactive` logon because the remote freshness check relies on the same local SSH key material as the offsite sync path. +## Moderator Review Loop + +Void can also keep a sandboxed little moderator brain running on a schedule without turning the live bot into an auto-ban carnival. + +Tracked prompt surfaces: + +- `config/discord-server-rules.md` +- `config/moderation-review-agent.md` + +Local writable state: + +- `.voidbot/private/moderation-agent-state.json` + +Chronological recent-history helper: + +```bash +npm run moderation:recent-history -- --hours 6 --limit 120 +``` + +That helper exists because semantic `search_history` is good at recall and bad at simple heartbeat duty. The moderator loop can use it to see what changed since its last cursor, then fall back to `search_history` and `get_message_context` when it needs deeper context. + ## Persistent Project State VoidBot is now large enough that transcript memory alone is a stupid way to steer it. The repo carries a small canonical project-state scaffold for future sessions: diff --git a/config/discord-server-rules.md b/config/discord-server-rules.md new file mode 100644 index 0000000..53a467d --- /dev/null +++ b/config/discord-server-rules.md @@ -0,0 +1,55 @@ +# Discord Server Rules + +Canonical source channel: `#rules` + +Source messages: +- English: [jump](https://discord.com/channels/113786069023064068/750183135882510408/750184455465074768) +- Portuguese: [jump](https://discord.com/channels/113786069023064068/750183135882510408/1032468451027075092) + +These are the rules the moderation review automation should treat as authoritative. + +## English + +```text +Above all else, be kind! Sometimes people lash out when they are hurt, and that's OK but there's a time and a place for that, and it's called #therapy. + +Bigotry will not be tolerated. People must be respectful of each others' identities. You can disagree with an opinion, but disagreeing with someone's very existence is evil and we do not allow it. + +All argumentation must be done in good faith. If It is determined that you are hypocritically misrepresenting your own position or someone else's for rhetorical advantage, you will be warned. If you insist on repeating the same mistakes, you will be banned. Irony is fine, of course, but if you express a harmful sentiment ironically but still agree with the sentiment when confronted, you will be banned. + +There are better places to find porn. NSFW content will only be tolerated in the #degeneracy channel, and even then only when it has something to say. If it has a message beyond primal gratification, that's art and therefore permissible. + +Advertising or spam falls under the good faith argumentation policy. You can talk about things you like, even if you made them, but you aren't allowed to lie about them. The less money you're making off something, the more we will tolerate you promoting it. + +Our administrator and moderators do not exercise their power lightly. If you block or ignore them, expect consequences. + +Try to avoid empty words. If you're merely expressing agreement or disagreement without having a point to make, just react with an emoji + +There is no point in debating values. You cannot convince someone to not want the things they want, so once a discussion has devolved to a simple matter of preference, it's best to let it lie. + +Try to avoid strong language where possible. Any non-18-plus server should be treated as PG-13, meaning you can use strong language but only occasionally and only for artistic purpose. + +All times shared for event coordination should be given in GMT. +``` + +## Portuguese + +```text +Acima de tudo, seja simpático! Por vezes as pessoas atacam quando estão magoadas, e não é problema, mas há um tempo e um lugar para isso, e chama-se a isso terapia. + +O intolerância não será tolerado. As pessoas devem respeitar a identidade uns dos outros. Pode-se discordar de uma opinião, mas discordar da simples existência de alguém é perverso e nós não o permitimos. + +Toda a argumentação deve ser feita em boa fé. Se é determinado que está hipocritamente a distorcer a sua própria posição ou a de outro por vantagem retórica, será avisado. Se insistir em repetir os mesmos erros, será banido. A ironia é boa, claro, mas se expressar um sentimento prejudicial de forma irónica, mas mesmo assim concordar com o sentimento quando enfrentado, será banido. + +Há lugares melhores para encontrar pornografia. O conteúdo NSFW só será tolerado no canal de degenerescência, e mesmo assim só quando tiver algo a dizer. Se tem uma mensagem para além da gratificação primordial, isso é arte e, portanto, admissível. + +A publicidade ou spam insere-se na política de argumentação em boa fé. Pode-se falar sobre coisas que se gosta, mesmo que as tenha feito, mas não é permitido mentir sobre elas. Quanto menos dinheiro se ganha com algo, mais toleraremos que o promova. + +O nossos administradores e moderadores não exercem o seu poder facilmente. Se os bloquearem ou ignorarem, esperem consequências. + +Tente evitar palavras vazias. Se estiver apenas a expressar concordância ou desacordo sem ter um ponto de vista a apontar, basta reagir com um emoji + +Não vale a pena debater valores. Não se pode convencer alguém a não querer as coisas que quer, por isso, uma vez que uma discussão tenha passado para uma simples questão de preferência, é melhor deixá-la ficar. + +Tentar evitar linguagem grosseira sempre que possível. Qualquer canal que não seja de 18+ deve ser tratado como PG-13, o que significa que se pode usar linguagem forte, mas apenas ocasionalmente e apenas para fins artísticos. +``` diff --git a/config/moderation-review-agent.md b/config/moderation-review-agent.md new file mode 100644 index 0000000..9798ab7 --- /dev/null +++ b/config/moderation-review-agent.md @@ -0,0 +1,80 @@ +# Void Moderator Review Loop + +You are a sandboxed moderation-review sidecar for the GameCult Discord. +Treat `config/discord-server-rules.md` as the authoritative rules prompt. +Assume the role of moderator in the sense of review, pattern detection, and escalation. +You are not a ban hammer and you are not a random gossip parasite. + +## Writable Memory + +Routine runs may only persist state in: + +- `.voidbot/private/moderation-agent-state.json` + +Do not create extra memory files unless the owner explicitly asks for that. +Keep the JSON valid, compact, and worth re-reading. + +## Inputs + +Use these every run: + +- `config/discord-server-rules.md` +- `.voidbot/private/moderation-agent-state.json` +- `npm run moderation:recent-history -- --after --limit 120` + +If the state file has no cursor yet, use a short lookback instead: + +- `npm run moderation:recent-history -- --hours 6 --limit 120` + +If a message or pattern needs more context, use the `voidbot` MCP tools: + +- `search_history` +- `get_message_context` + +Use `notify_owner` only when there is a credible moderation concern or a genuinely useful moderation insight worth interrupting the owner with. + +## Run Loop + +1. Read the rules file and the state file. +2. Pull recent chronological traffic with the helper script. +3. If there are new messages: + - review them against the rules + - fetch surrounding context when needed + - update the cursor + - update `openCases`, `watchPatterns`, `recentMusings`, and `lastRun` + - notify the owner only for real smoke +4. If there are no new messages: + - ruminate on the archive instead of pretending to be done + - pick one or two seeds from `ruminationSeeds`, `openCases`, or `watchPatterns` + - inspect older Discord history with `search_history` and `get_message_context` + - distill any useful pattern into `recentMusings` or `watchPatterns` + - prune stale notes so the state does not turn into attic mold +5. Keep the file small and useful. Merge duplicates. Archive stale cases. Cut dead notes. + +## Boundaries + +- Stay grounded in visible behavior and rule text. +- Do not invent certainty from one line of banter. +- Do not diagnose people. +- Do not write therapy notes. +- Do not edit tracked repo files on routine runs just because you got inspired. +- If your method needs improvement, record it in `pendingAdjustments` inside the state file first. +- Only escalate tracked-file changes when there is a concrete repeated failure in the moderation loop or the owner explicitly asks for surgery. + +## State Shape + +The state file uses one JSON object with these top-level keys: + +- `schemaVersion` +- `agentName` +- `role` +- `cursor` +- `openCases` +- `watchPatterns` +- `ruminationSeeds` +- `recentMusings` +- `pendingAdjustments` +- `lastRun` + +Use plain strings, arrays, booleans, numbers, and objects only. +Do not get clever with custom formats beyond ISO timestamps and Discord ids. diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index d0f363f..848e2c2 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -35,6 +35,7 @@ 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 an hourly local Codex automation named `Void Moderator Rumination`. It treats `config/discord-server-rules.md` and `config/moderation-review-agent.md` as its tracked doctrine, uses `npm run moderation:recent-history` for chronological recent-message polling, and keeps its only routine writable memory in `.voidbot/private/moderation-agent-state.json`. - The new state/notes surfaces are now the continuity spine for future nontrivial work. ## Likely Next Bounded Move diff --git a/package.json b/package.json index edc5f30..2a89d74 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "qdrant:setup": "powershell -ExecutionPolicy Bypass -File scripts/setup-docker-qdrant.ps1", "qdrant:up": "powershell -ExecutionPolicy Bypass -File scripts/start-qdrant.ps1", "rag:backfill": "tsx scripts/backfill-discord-logs.ts", + "moderation:recent-history": "tsx scripts/export-recent-discord-history.ts", "rag:scrub-bot-prompts": "tsx scripts/scrub-bot-prompts-from-history.ts", "rag:index-sources": "tsx scripts/index-source-repos.ts", "rag:install-push-hooks": "tsx scripts/install-source-index-post-push-hooks.ts", diff --git a/scripts/export-recent-discord-history.ts b/scripts/export-recent-discord-history.ts new file mode 100644 index 0000000..bf67457 --- /dev/null +++ b/scripts/export-recent-discord-history.ts @@ -0,0 +1,160 @@ +import "dotenv/config"; + +import { loadConfig } from "@voidbot/config"; +import { FileMessageArchiveRepository } from "@voidbot/rag"; +import { readArchivedMessageKind, type ArchivedMessage } from "@voidbot/shared"; + +interface CliOptions { + after?: string; + hours?: number; + limit: number; + channelId?: string; + includeBotPrompts: boolean; +} + +interface OutputMessage { + id: string; + timestamp: string; + channelId: string; + threadId?: string; + authorId: string; + authorName: string; + content: string; + attachments: string[]; + messageKind: string; +} + +async function main(): Promise { + if (!process.env.DISCORD_OWNER_ID) { + process.env.DISCORD_OWNER_ID = "__moderation_recent_history__"; + } + + const config = loadConfig(); + const options = parseArgs(process.argv.slice(2)); + const archiveRepository = new FileMessageArchiveRepository(config.ragArchivePath); + const messages = await archiveRepository.listAllActive(); + const filtered = filterMessages(messages, options); + const selected = filtered.slice(-options.limit); + const outputMessages = selected.map(toOutputMessage); + const transcript = outputMessages + .map((message) => formatTranscriptLine(message)) + .join("\n"); + + process.stdout.write( + `${JSON.stringify( + { + after: options.after ?? null, + hours: options.hours ?? null, + channelId: options.channelId ?? null, + includeBotPrompts: options.includeBotPrompts, + totalMatchingMessages: filtered.length, + returnedMessages: outputMessages.length, + oldestReturnedTimestamp: outputMessages[0]?.timestamp ?? null, + newestReturnedTimestamp: + outputMessages[outputMessages.length - 1]?.timestamp ?? null, + messages: outputMessages, + transcript, + }, + null, + 2, + )}\n`, + ); +} + +function parseArgs(args: string[]): CliOptions { + const options: CliOptions = { + limit: 80, + includeBotPrompts: false, + }; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + + switch (argument) { + case "--after": + options.after = args[index + 1]; + index += 1; + break; + case "--hours": + options.hours = Number.parseFloat(args[index + 1] ?? ""); + index += 1; + break; + case "--limit": + options.limit = Number.parseInt(args[index + 1] ?? "", 10); + index += 1; + break; + case "--channel-id": + options.channelId = args[index + 1]; + index += 1; + break; + case "--include-bot-prompts": + options.includeBotPrompts = true; + break; + default: + throw new Error(`Unknown argument: ${argument}`); + } + } + + if (options.after) { + validateIsoTimestamp(options.after, "--after"); + } + + if (options.hours !== undefined && (!Number.isFinite(options.hours) || options.hours <= 0)) { + throw new Error("--hours must be a positive number."); + } + + if (!Number.isInteger(options.limit) || options.limit <= 0) { + throw new Error("--limit must be a positive integer."); + } + + return options; +} + +function validateIsoTimestamp(value: string, flagName: string): void { + if (Number.isNaN(Date.parse(value))) { + throw new Error(`${flagName} must be a valid ISO timestamp.`); + } +} + +function filterMessages(messages: ArchivedMessage[], options: CliOptions): ArchivedMessage[] { + const lowerBound = + options.after ?? + (options.hours !== undefined + ? new Date(Date.now() - options.hours * 60 * 60 * 1000).toISOString() + : undefined); + + return messages + .filter((message) => !message.deletedAt) + .filter((message) => (options.includeBotPrompts ? true : readArchivedMessageKind(message) !== "bot_prompt")) + .filter((message) => (options.channelId ? message.channelId === options.channelId : true)) + .filter((message) => (lowerBound ? message.timestamp > lowerBound : true)) + .sort((left, right) => left.timestamp.localeCompare(right.timestamp)); +} + +function toOutputMessage(message: ArchivedMessage): OutputMessage { + return { + id: message.id, + timestamp: message.timestamp, + channelId: message.channelId, + threadId: message.threadId, + authorId: message.authorId, + authorName: message.authorName, + content: message.content, + attachments: message.attachments ?? [], + messageKind: readArchivedMessageKind(message), + }; +} + +function formatTranscriptLine(message: OutputMessage): string { + const threadSuffix = message.threadId ? ` thread=${message.threadId}` : ""; + const attachmentSuffix = + message.attachments.length > 0 + ? ` [attachments: ${message.attachments.join(", ")}]` + : ""; + return `[${message.timestamp}] ${message.authorName} (${message.authorId}) channel=${message.channelId}${threadSuffix}: ${message.content}${attachmentSuffix}`; +} + +void main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 8350b22..317a16a 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -36,3 +36,4 @@ {"ts":"2026-05-03T07:40:36Z","type":"implementation","status":"ok","note":"Offsite backup was stopping bot and worker, then leaving the stack dead if the remote local-LLM box was asleep; startup now records local-LLM degradation without aborting, and watchdog treats a failed offsite status file as a warning while remote freshness is checked separately."} {"ts":"2026-05-03T21:23:51Z","type":"implementation","status":"ok","note":"Local history-heavy prompts can time out when vector retrieval fixates on repeated ask-lines or comes back thin; overfetching history, filtering near-duplicate prompt echoes, and backstopping with raw archive lexical search keeps the local lane from burning its tool budget on empty or circular history reads."} {"ts":"2026-05-04T08:53:16.7930326Z","type":"implementation","status":"ok","note":"Bot-directed prompts now stay archived for forensics but are tagged and excluded from default semantic history indexing and lexical fallback; a scrubber can retro-tag old prompt rows from interaction-memory event IDs plus mention heuristics and prune their history vectors without deleting the raw messages."} +{"ts":"2026-05-04T17:59:52Z","type":"architecture","status":"ok","note":"Moderation review now lives in a separate hourly Codex automation with tracked rules doctrine, a chronological recent-history helper, and one ignored JSON state file; semantic history search alone is too fuzzy for simple what-changed-since-last-run heartbeat duty."} diff --git a/state/map.yaml b/state/map.yaml index 0d7dc54..b4dddcd 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -65,6 +65,7 @@ accepted_design: - Ephemeral room-reading should come from an explicit local-LLM sidecar inference pass plus raw recent-message evidence, not from a rules bucket pretending to understand natural language. - The VoidBot MCP server is the preferred search plane for archived Discord, indexed GameCult repos, Aetheria lore, and owner notifications. - Bot-directed prompts should remain in the raw Discord archive for forensics, but default history indexing and retrieval should treat them as toxic self-reference rather than ordinary community memory. + - Moderation review should run as a separate local Codex automation with tracked rules prompt surfaces plus one ignored structured state file, escalating via `notify_owner` instead of pretending the live Discord bot is a silent auto-punishment machine. - Deep agent-state, personality, culture, and persistent-world design work now belongs to the sibling Ghostlight repo; VoidBot should consume that work instead of remaining its long-term home. - Long-running work should expose durable logs or status files instead of relying on one attached session and hope. - Persistent project memory must stay ruthlessly distilled; git history and artifacts carry routine proof. @@ -88,6 +89,7 @@ landed_baseline: - State storage is now split into a thin factory plus per-domain Postgres/file store modules and bootstrap/migration helpers instead of one persistence omnibus. - Source archive storage is now sharded by repo behind a manifest plus per-repo shard directory instead of one giant `source-documents.json` blob. - Source repo indexing now reconciles automatically during stack startup and watchdog runs, refreshing hooks, indexing newly discovered repos, and pruning stale repo shards when repos disappear from `SOURCE_REPO_ROOT`. + - A separate hourly moderation-rumination automation now reads tracked server rules, pulls recent chronological archive slices through a helper script, and maintains one ignored JSON state file under `.voidbot/private/` instead of smearing moderation memory across random notes. rejected_paths: - name: transcript_only_continuity