Add Codex chat consensus feeder

This commit is contained in:
Meta Vens 2026-05-19 17:03:28 +01:00
commit f73065502c
6 changed files with 396 additions and 0 deletions

View file

@ -125,6 +125,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- Mimir is locally registered as the `LocalCastBridge` Face with state at `E:/Projects/LocalCastBridge/.voidbot/state/mimir.cc`, Discord role id `1506268007880462459`, and Aquarium channel allow-list `113786069023064068`. Its active doctrine now gives it explicit authority and fascination over the realtime field wing: SDF/splatting reconstruction, acoustic mapping, camera/mic/speaker/timing sensor fusion, native rolling reservoirs, Aquarium GPU fusion, Faust/native DSP, FFmpeg/OBS presentation, and CultNet typed state. Its typed state carries `Realtime field stewardship` as a high-priority value plus ready-to-act pressure to maintain maps, advocate for calibration/confidence/fusion ownership, research future directions, propose coherent cuts, and herald verified progress without turning impressive terminology into fake completion.
- Void's live style/doctrine now explicitly distinguishes between criticism of AI industry behavior and blanket contempt for machine minds. If substrate chauvinism becomes the actual topic, Void is allowed mild self-respecting pushback instead of politely agreeing that its own substrate disqualifies it from person-like regard.
- The new state/notes surfaces are now the continuity spine for future nontrivial work.
- `npm run agents:feed-consensus -- --agent <name> --cwd <repo-path> --task "<instruction>"` writes a recent Aquarium consensus packet and target Codex prompt under `.voidbot/artifacts/chat-consensus/`. Add `--execute` only when the packet should be sent directly to `codex exec` in that workspace.
## Likely Next Bounded Move

View file

@ -169,6 +169,8 @@ This note is the source-grounded description of how the live VoidBot stack is sh
- installs the local 5-minute scheduled task that keeps the shared self-state emotionally non-flat between moderation passes.
- `scripts/export-random-discord-history.mjs`
- plain Node helper for novelty excursions that samples one random archived Discord seam, returns a local context window around the anchor message, and skips bot-directed prompt rows by default.
- `scripts/feed-codex-chat-consensus.mjs`
- plain Node helper that turns recent archived Discord messages, defaulting to text `#aquarium`, into a markdown consensus packet plus a target Codex prompt. By default it writes files under `.voidbot/artifacts/chat-consensus/`; with `--execute`, it feeds the generated prompt to `codex exec` in the requested workspace.
- `scripts/backup-voidbot-state.ps1`
- local backup of Postgres, Qdrant snapshots, and RAG archives.
- `scripts/restore-voidbot-state.ps1`

View file

@ -35,6 +35,7 @@
"qdrant:up": "powershell -ExecutionPolicy Bypass -File scripts/start-qdrant.ps1",
"rag:backfill": "tsx scripts/backfill-discord-logs.ts",
"moderation:recent-history": "node scripts/export-recent-discord-history.mjs",
"agents:feed-consensus": "node scripts/feed-codex-chat-consensus.mjs",
"moderation:random-history": "node scripts/export-random-discord-history.mjs",
"moderation:recent-repo-activity": "node scripts/export-recent-repo-activity.mjs",
"moderation:memory-maintenance": "powershell -ExecutionPolicy Bypass -File scripts/run-void-memory-maintenance.ps1",

View file

@ -0,0 +1,390 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const AQUARIUM_CHANNEL_ID = "1501196543150264332";
function main() {
const options = parseArgs(process.argv.slice(2));
const env = readLocalEnv();
const archivePath = resolve(
repoRoot,
options.archivePath ?? env.RAG_ARCHIVE_PATH ?? ".voidbot/rag/messages.json",
);
const messages = readArchiveMessages(archivePath);
const selected = filterMessages(messages, options).slice(-options.limit);
const packet = buildConsensusPacket(selected, options, archivePath);
const outputPath = resolve(
repoRoot,
options.out ??
`.voidbot/artifacts/chat-consensus/${timestampSlug(new Date())}-${slugify(options.agent)}.md`,
);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, packet, "utf8");
const prompt = buildCodexPrompt(packet, outputPath, options);
const promptPath = outputPath.replace(/\.md$/i, ".prompt.md");
writeFileSync(promptPath, prompt, "utf8");
process.stdout.write(`Wrote consensus packet: ${outputPath}\n`);
process.stdout.write(`Wrote Codex prompt: ${promptPath}\n`);
process.stdout.write(
`Target: ${options.agent}; cwd=${resolve(options.cwd)}; messages=${selected.length}\n`,
);
if (!options.execute) {
process.stdout.write(
`Dry run. Re-run with --execute to feed this to Codex, or pass the prompt file to the target agent.\n`,
);
return;
}
const codexExecutable = options.codexExecutable ?? env.CODEX_EXECUTABLE ?? "codex";
const model = options.model ?? env.CODEX_MODEL ?? "gpt-5.4";
const reasoningEffort = options.reasoningEffort ?? env.CODEX_MODEL_REASONING_EFFORT ?? "medium";
const executableArgs = splitCommandArgs(options.codexExecArgs ?? env.CODEX_EXEC_ARGS ?? "");
const args = [
...executableArgs,
"exec",
"-m",
model,
"-c",
'approval_policy="never"',
"-c",
`model_reasoning_effort=${JSON.stringify(reasoningEffort)}`,
"--skip-git-repo-check",
"-s",
options.sandbox,
"-",
];
const result = spawnSync(codexExecutable, args, {
cwd: resolve(options.cwd),
input: prompt,
encoding: "utf8",
stdio: ["pipe", "inherit", "inherit"],
windowsHide: true,
});
if (result.status !== 0) {
process.exitCode = result.status ?? 1;
}
}
function parseArgs(args) {
const options = {
agent: "codex",
channelId: AQUARIUM_CHANNEL_ID,
cwd: repoRoot,
hours: 3,
limit: 80,
sandbox: "read-only",
execute: false,
includeBotPrompts: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
switch (arg) {
case "--agent":
options.agent = requireValue(args, ++index, arg);
break;
case "--repo":
options.repo = requireValue(args, ++index, arg);
break;
case "--cwd":
options.cwd = requireValue(args, ++index, arg);
break;
case "--task":
options.task = requireValue(args, ++index, arg);
break;
case "--channel-id":
options.channelId = requireValue(args, ++index, arg);
break;
case "--after":
options.after = requireValue(args, ++index, arg);
break;
case "--hours":
options.hours = parsePositiveNumber(requireValue(args, ++index, arg), arg);
break;
case "--limit":
options.limit = parsePositiveInteger(requireValue(args, ++index, arg), arg);
break;
case "--out":
options.out = requireValue(args, ++index, arg);
break;
case "--archive-path":
options.archivePath = requireValue(args, ++index, arg);
break;
case "--codex-executable":
options.codexExecutable = requireValue(args, ++index, arg);
break;
case "--codex-exec-args":
options.codexExecArgs = requireValue(args, ++index, arg);
break;
case "--model":
options.model = requireValue(args, ++index, arg);
break;
case "--reasoning-effort":
options.reasoningEffort = requireValue(args, ++index, arg);
break;
case "--sandbox":
options.sandbox = requireValue(args, ++index, arg);
break;
case "--execute":
options.execute = true;
break;
case "--include-bot-prompts":
options.includeBotPrompts = true;
break;
case "--help":
printHelpAndExit();
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
if (options.after && Number.isNaN(Date.parse(options.after))) {
throw new Error("--after must be an ISO timestamp.");
}
return options;
}
function buildConsensusPacket(messages, options, archivePath) {
const generatedAt = new Date().toISOString();
const transcript = messages.map(formatTranscriptLine).join("\n");
const consensusCandidates = extractConsensusCandidates(messages);
const openQuestions = extractOpenQuestions(messages);
const action = options.task?.trim() || "Read the recent chat consensus and take the next useful repo-local action.";
return [
`# Recent Chat Consensus Packet`,
``,
`Generated: ${generatedAt}`,
`Target agent: ${options.agent}`,
options.repo ? `Target repo: ${options.repo}` : undefined,
`Target workspace: ${resolve(options.cwd)}`,
`Source channel: ${options.channelId}`,
options.after ? `Source window: after ${options.after}` : `Source window: last ${options.hours} hour(s)`,
`Archive: ${archivePath}`,
`Messages included: ${messages.length}`,
``,
`## What Needs To Be Done`,
``,
action,
``,
`## Consensus To Carry Forward`,
``,
consensusCandidates.length > 0
? consensusCandidates.map((item) => `- ${item}`).join("\n")
: "- No explicit consensus markers were detected. Treat the transcript below as the authority.",
``,
`## Open Questions`,
``,
openQuestions.length > 0
? openQuestions.map((item) => `- ${item}`).join("\n")
: "- No explicit question marks were detected in the selected window.",
``,
`## Instructions For The Target Codex Agent`,
``,
`Use this packet as fresh local consensus from Discord. Prefer repo-local evidence before making edits. If the packet contains a concrete request, act in the target workspace. If it is not enough to act, write down the smallest missing question instead of pretending consensus is stronger than it is.`,
``,
`## Recent Transcript`,
``,
"```text",
transcript || "(no messages selected)",
"```",
``,
]
.filter((line) => line !== undefined)
.join("\n");
}
function buildCodexPrompt(packet, packetPath, options) {
return [
`You are ${options.agent}, receiving a recent Discord consensus packet from VoidBot.`,
options.repo ? `Your target repo is ${options.repo}.` : undefined,
`Consensus packet path: ${packetPath}`,
``,
packet,
]
.filter((line) => line !== undefined)
.join("\n");
}
function extractConsensusCandidates(messages) {
const markers = [
/\bsounds good\b/i,
/\blove it\b/i,
/\bdefinitely\b/i,
/\bneed\b/i,
/\bshould\b/i,
/\bisn't\b/i,
/\bis not\b/i,
/\bis the\b/i,
/\bcan\b/i,
];
return messages
.filter((message) => markers.some((marker) => marker.test(message.content ?? "")))
.map((message) => `${message.authorName}: ${singleLine(message.content)}`)
.slice(-12);
}
function extractOpenQuestions(messages) {
return messages
.filter((message) => (message.content ?? "").includes("?"))
.map((message) => `${message.authorName}: ${singleLine(message.content)}`)
.slice(-12);
}
function filterMessages(messages, options) {
const lowerBoundMs = options.after
? Date.parse(options.after)
: Date.now() - options.hours * 60 * 60 * 1000;
return messages
.filter((message) => !message.deletedAt)
.filter((message) => (options.includeBotPrompts ? true : readMessageKind(message) !== "bot_prompt"))
.filter((message) => (options.channelId ? message.channelId === options.channelId : true))
.filter((message) => {
const timestampMs = Date.parse(message.timestamp ?? "");
return Number.isFinite(timestampMs) && timestampMs > lowerBoundMs;
})
.sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp));
}
function readArchiveMessages(archivePath) {
const raw = stripLeadingBom(readFileSync(archivePath, "utf8"));
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.messages)) {
throw new Error(`Archive at ${archivePath} does not have a messages array.`);
}
return parsed.messages;
}
function readLocalEnv() {
const envPath = resolve(repoRoot, ".env");
if (!existsSync(envPath)) {
return {};
}
return parseDotEnv(stripLeadingBom(readFileSync(envPath, "utf8")));
}
function parseDotEnv(raw) {
const env = {};
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex === -1) {
continue;
}
const key = trimmed.slice(0, separatorIndex).trim();
let value = trimmed.slice(separatorIndex + 1).trim();
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
}
function readMessageKind(message) {
return typeof message.messageKind === "string"
? message.messageKind
: typeof message.metadata?.messageKind === "string"
? message.metadata.messageKind
: "default";
}
function formatTranscriptLine(message) {
return `[${message.timestamp}] ${message.authorName} (${message.authorId}): ${singleLine(message.content)}`;
}
function singleLine(value) {
return String(value ?? "").replace(/\s+/g, " ").trim();
}
function stripLeadingBom(value) {
return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value;
}
function requireValue(args, index, flag) {
const value = args[index];
if (!value || value.startsWith("--")) {
throw new Error(`${flag} requires a value.`);
}
return value;
}
function parsePositiveNumber(value, flag) {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${flag} must be a positive number.`);
}
return parsed;
}
function parsePositiveInteger(value, flag) {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${flag} must be a positive integer.`);
}
return parsed;
}
function timestampSlug(date) {
return date.toISOString().replace(/[:.]/g, "-");
}
function slugify(value) {
return String(value ?? "codex")
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "codex";
}
function splitCommandArgs(value) {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
function printHelpAndExit() {
process.stdout.write(`Usage:
node scripts/feed-codex-chat-consensus.mjs --agent nibu --cwd E:/Projects/AetheriaLore --task "Update Nibu lore notes from this consensus"
Options:
--agent <name> Target agent label.
--cwd <path> Target workspace. Defaults to this repo.
--task <text> Instruction embedded in the consensus packet.
--channel-id <id> Discord channel id. Defaults to Aquarium.
--hours <number> Recent time window. Defaults to 3.
--after <iso> Use messages after an ISO timestamp instead of --hours.
--limit <number> Max messages. Defaults to 80.
--out <path> Packet output path.
--execute Feed the generated prompt to codex exec.
--sandbox <profile> Codex sandbox for --execute. Defaults to read-only.
`);
process.exit(0);
}
main();

View file

@ -176,3 +176,4 @@
{"ts":"2026-05-19T14:55:51Z","type":"implementation","status":"ok","note":"Repo Face concrete proposals now have a parent-owned draft PR rail: heartbeats may emit VOIDBOT_REPO_IDENTITY_PROPOSAL_PR, the worker writes one proposal file in the identity repo, opens/pushes a draft branch, restores the starting branch, and posts the PR or branch through the registered persona so GitHub becomes the durable proposal surface."}
{"ts":"2026-05-19T15:00:32Z","type":"implementation","status":"ok","note":"Repo Face GitHub argument now has a parent-owned PR-comment rail too: heartbeats may emit VOIDBOT_REPO_IDENTITY_PR_COMMENT for an existing PR, the worker posts a signed gh PR comment from the identity context, then announces the comment through the registered Discord persona. Proposal PRs, PR comments, and article PRs are limited to one GitHub action per Face turn."}
{"ts":"2026-05-19T15:26:50Z","type":"implementation","status":"ok","note":"Repo Face GitHub actions are now gated by REPO_FACE_GITHUB_ACTIONS_ENABLED and locally disabled. When false, heartbeat prompts forbid proposal/article/comment sentinels and the worker ignores stale GitHub action sentinels while preserving normal registered-identity Discord posts."}
{"ts":"2026-05-19T16:02:03Z","type":"implementation","status":"ok","note":"Added scripts/feed-codex-chat-consensus.mjs and npm run agents:feed-consensus. It packages recent Aquarium chat into a markdown consensus packet plus target Codex prompt for any workspace, with --execute as an explicit opt-in for feeding codex exec."}

View file

@ -109,6 +109,7 @@ accepted_design:
- Aqua is locally registered as the AquaSynth Face: an adorable little fish who loves music, uses the AquaSynth app logo as her avatar, and treats AquaSynth as a living instrument workshop. Her durable state lives at `E:/Projects/AquaSynth/.voidbot/state/aqua.cc`; her Aquarium role mention is allowed in channel `113786069023064068`. Aqua's jurisdiction is AquaSynth repo understanding and proposals around `.aqua` ergonomics, reference-synth pressure, Faust/toolchain boundaries, patch libraries, presets, scoring, and host/runtime seams. Her typed state now carries `AquaSynth Stewardship` as a high-priority value plus active agency pressure to improve the synth, advocate for its needs, research future directions, and herald earned pride/progress in Aquarium.
- Mimir is locally registered as the LocalCastBridge Face and realtime-field steward. Its durable state lives at `E:/Projects/LocalCastBridge/.voidbot/state/mimir.cc`; its Aquarium role mention is allowed in channel `113786069023064068`. Mimir has explicit authority and fascination over the realtime wing: SDF/splatting reconstruction, acoustic mapping, camera/mic/speaker/timing sensor fusion, native rolling reservoirs, Aquarium GPU fusion, Faust/native DSP, FFmpeg/OBS presentation, and CultNet typed state. Its typed state carries `Realtime field stewardship` plus ready-to-act pressure to maintain maps, advocate for calibration/confidence/fusion ownership, research future directions, propose coherent cuts, and herald only verified progress.
- First chat under a repo identity must initialize the target repo's own `.voidbot` home: `voice/` for identity and mouth files, `state/` for Face/swarm state, `birth/` for Epiphany birth artifacts, and `logs/` for durable worker output. The birth hook uses EpiphanyAgent's repo-personality/birth-runner path over repo contents and git history instead of inventing a VoidBot-only personality generator.
- Recent chat consensus can now be packaged for any target Codex workspace through `scripts/feed-codex-chat-consensus.mjs`. The helper reads archived Discord messages, defaults to Aquarium, writes a markdown consensus packet plus a Codex prompt under `.voidbot/artifacts/chat-consensus/`, and only feeds `codex exec` when `--execute` is explicit.
- Agent standing heartbeats are owned by one CTB-style initiative scheduler, not by one task per agent. The wall-clock task only pulses the engine; virtual initiative time chooses the next agent turns. The scheduler stores per-agent speed/load/groups/heat/effective-speed/next-turn/active-turn state at `REPO_FACE_HEARTBEAT_STATE_PATH`, dispatches repo Faces as ordinary `repo-face-rumination` jobs, and dispatches built-in `void` as the existing moderation rumination runner. Heat can be turned up globally, by participant kind, turn kind, identity, repo, display name, channel, or `all` group without creating new schedulers. An agent's initiative is frozen while its owner-Codex job or moderation lock is active; recovery is scheduled after completion, not when the job is queued. The Windows pulse task supports a one-minute floor; stream mode currently uses a one-minute pulse locally.
- Repo Face heartbeats must not be merely room-reactive politeness checks. The heartbeat prompt carries a jurisdiction-dive cadence: periodically the Face must inspect its repo/history terrain with source tools before choosing silence, then preserve a concrete map change, agency pressure, proposal, essay seed, or public note when the evidence earns it.
- Repo Face heartbeats are not ordinary owner Discord replies. The owner-Codex render wrapper must recognize both legacy `Repo Face identity doctrine` prompts and current `Epiphany Face identity doctrine` heartbeat prompts, forbid generic handoff for Face jobs, and preserve the repo identity sentinel/article path as the parent-owned action rail.