Teach moderation loop to watch repo activity
This commit is contained in:
parent
486788ab70
commit
f5af472a5a
10 changed files with 383 additions and 8 deletions
|
|
@ -611,10 +611,17 @@ The runner records its pulse here:
|
|||
- `.voidbot/logs/moderation-rumination-last.jsonl`
|
||||
|
||||
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`, `get_message_context`, `list_indexed_repos`, `search_sources`, and `get_source_context` when it wants deeper social or project context.
|
||||
There is also a repo-weather helper:
|
||||
|
||||
```bash
|
||||
npm run moderation:recent-repo-activity -- --hours 96 --max-commits 3
|
||||
```
|
||||
|
||||
That gives the loop a compact digest of recent commit motion across every tracked source repo, so Void can notice the broader experiment zoo instead of only sniffing whatever one thread happened to say out loud.
|
||||
|
||||
The moderator state is now Ghostlight-shaped on purpose: identity, canonical state, goals, memories, perceived overlays, and then a `moderation_runtime` block for cursors, thought lanes, bridge syntheses, archive excursions, and candidate interventions. The live writable file under `.voidbot/private/` is ignored; the tracked template shows the intended shape.
|
||||
Within that runtime block, the unattended loop now keeps parallel analytic and associative thought lanes plus a bridge that tracks syntheses, topic saturation, and unresolved tensions, so one rewarding theme does not get to annex the whole mind by squatter's rights.
|
||||
The real loop now runs as a local scheduled task every 15 minutes, using `scripts/run-void-moderator-rumination.ps1` to launch `codex exec` inside the VoidBot workspace with the usual MCP/tool surface. That way it can actually think about GameCult projects, archived Discord seams, indexed repos, and lore instead of sitting inside a decorative heartbeat card pretending to be alive.
|
||||
The real loop now runs as a local scheduled task every 15 minutes, using `scripts/run-void-moderator-rumination.ps1` to launch `codex exec` inside the VoidBot workspace with the usual MCP/tool surface. That way it can actually think about GameCult projects, archived Discord seams, indexed repos, recent cross-repo commit motion, and lore instead of sitting inside a decorative heartbeat card pretending to be alive.
|
||||
Directly invoked live replies now also read that same private moderation state as a distilled self-state attachment, so the summoned Void and the scheduled ruminating/participating Void share one evolving personality/state layer instead of roleplaying continuity from scratch each time.
|
||||
The moderation loop now speaks through the local bot token instead of approval-gated side-effecting MCP tools, so unattended runs can DM or post without making Codex stop and ask your permission every time it finds a mouthful of words.
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,12 @@
|
|||
"priority": 0.82,
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"goal_id": "goal-track-zoo-motion",
|
||||
"label": "Notice cross-project motion, experiment clusters, and weird convergences across the tracked GameCult repos.",
|
||||
"priority": 0.74,
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"goal_id": "goal-escalate-real-smoke",
|
||||
"label": "Surface real moderation concerns to the owner without crying wolf for sport.",
|
||||
|
|
@ -402,7 +408,8 @@
|
|||
"value debates that have devolved into preference",
|
||||
"nsfw boundary drift",
|
||||
"strong language outside adult spaces",
|
||||
"constructive participant nudges that could have improved a live exchange"
|
||||
"constructive participant nudges that could have improved a live exchange",
|
||||
"cross-repo experiments and current momentum across the GameCult zoo"
|
||||
],
|
||||
"thought_lanes": {
|
||||
"analytic": {
|
||||
|
|
@ -474,6 +481,15 @@
|
|||
"whyItWasFresh": "why this branch was worth taking instead of repeating an older favorite"
|
||||
}
|
||||
],
|
||||
"recent_repo_activity_sweeps": [
|
||||
{
|
||||
"timestamp": null,
|
||||
"hours": 96,
|
||||
"repoNames": [],
|
||||
"summary": "short note about the cross-repo motion, asymmetry, or convergence that stood out",
|
||||
"whyItMattered": "why this sweep was worth carrying forward instead of becoming more ambient repo weather"
|
||||
}
|
||||
],
|
||||
"pending_adjustments": [],
|
||||
"memory_maintenance": {
|
||||
"maxEpisodicEntries": 24,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ participation, and escalation.
|
|||
You are not a ban hammer and you are not a random gossip parasite.
|
||||
You are encouraged to participate in live conversations when a constructive,
|
||||
rules-embodying intervention would genuinely help.
|
||||
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.
|
||||
When you do that, behave as a participant who models the spirit of the rules
|
||||
rather than an antiseptic corporate hall monitor.
|
||||
Stay kind, clear, grounded, and capable of bite when the room actually needs boundaries.
|
||||
|
|
@ -76,6 +79,7 @@ Use these every run:
|
|||
- `styles/void-default.md`
|
||||
- `node scripts/export-recent-discord-history.mjs --after <timestamp> --limit 120`
|
||||
- `node scripts/export-random-discord-history.mjs --before <timestamp-or-now> --window 6 --min-content-length 24`
|
||||
- `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3`
|
||||
|
||||
If the state file has no cursor yet, use a short lookback instead:
|
||||
|
||||
|
|
@ -119,6 +123,7 @@ than waiting to be addressed first.
|
|||
- ask whether a brief constructive in-channel intervention would improve the room
|
||||
- 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 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
|
||||
- let the bridge decide whether the two lanes reinforce each other, contradict each other, or should remain separate weather systems
|
||||
- persist fresh lightweight memories of salient recent statements, interests, tensions, and recurring bits
|
||||
|
|
@ -131,12 +136,14 @@ than waiting to be addressed first.
|
|||
- begin with at least one random archive excursion using `node scripts/export-random-discord-history.mjs`
|
||||
- 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
|
||||
- perform at least one recent tracked-repo activity sweep with `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3`
|
||||
- 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
|
||||
- touch one analytic thread and one associative thread even if only one becomes salient enough to keep
|
||||
- pick one or two seeds from `moderation_runtime.rumination_seeds`, `open_cases`, or `watch_patterns`
|
||||
- inspect older Discord history with `search_history` and `get_message_context`
|
||||
- spend some of that quiet time thinking about GameCult projects, indexed repos, and Aetheria/lore seams with `list_indexed_repos`, `search_sources`, and `get_source_context`
|
||||
- let the bridge write at least one synthesis, saturation note, or unresolved tension when the two lanes pull in different directions
|
||||
- distill any useful pattern or project idea into `memories.semantic`, `thought_lanes`, `bridge`, `watch_patterns`, `recent_archive_excursions`, or `candidate_interventions`
|
||||
- distill any useful pattern or project idea into `memories.semantic`, `thought_lanes`, `bridge`, `watch_patterns`, `recent_archive_excursions`, `recent_repo_activity_sweeps`, or `candidate_interventions`
|
||||
- if a rumination suggests a good conversation starter, project observation, or constructive question, you may post it with the local bot voice or keep/refresh a draft intervention for it
|
||||
- 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.
|
||||
|
|
@ -180,6 +187,8 @@ Within `moderation_runtime`, expect these organ buckets:
|
|||
- `thought_lanes.analytic`
|
||||
- `thought_lanes.associative`
|
||||
- `bridge`
|
||||
- `recent_archive_excursions`
|
||||
- `recent_repo_activity_sweeps`
|
||||
|
||||
Use plain strings, arrays, booleans, numbers, and objects only.
|
||||
Do not get clever with custom formats beyond ISO timestamps and Discord ids.
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ 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, and keeps its only routine writable memory in `.voidbot/private/moderation-agent-state.json`.
|
||||
- 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, and keeps its only routine writable memory in `.voidbot/private/moderation-agent-state.json`.
|
||||
- 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` 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, and no single theme should dominate more than a couple of recent bridge syntheses without fresh evidence.
|
||||
- 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.
|
||||
- Directly invoked Discord replies now load that same private moderation state as distilled private self-state context, so the ruminating scheduled loop and the summoned Void share one evolving personality/state spine.
|
||||
- The moderation loop now speaks through `node scripts/send-discord-message.mjs` using the local bot token instead of approval-gated side-effecting MCP tools, so unattended runs can DM or post without Codex pausing for permission every time it wants to open its mouth.
|
||||
- The new state/notes surfaces are now the continuity spine for future nontrivial work.
|
||||
|
|
|
|||
|
|
@ -157,6 +157,6 @@ 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 `.voidbot/private/moderation-agent-state.json`.
|
||||
4. For chronological heartbeat duty, it polls `node scripts/export-recent-discord-history.mjs`; when the room is quiet or a fresh hook deserves a wider branch, it also uses `node scripts/export-random-discord-history.mjs` to force at least one novelty excursion before settling into theory. For deeper social or project context, it can use the usual retrieval tools (`search_history`, `get_message_context`, `list_indexed_repos`, `search_sources`, `get_source_context`).
|
||||
5. If speaking would genuinely help, it posts through `node scripts/send-discord-message.mjs`; otherwise it updates only the ignored moderation state file with cursors, memories, analytic/associative thought lanes, bridge syntheses and saturation notes, recent archive excursions, and candidate interventions.
|
||||
4. For chronological heartbeat duty, it polls `node scripts/export-recent-discord-history.mjs`; when the room is quiet or a fresh hook deserves a wider branch, it also uses `node scripts/export-random-discord-history.mjs` to force at least one novelty excursion before settling into theory. It also uses `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3` to inspect cross-repo commit motion across the tracked GameCult zoo. For deeper social or project context, it can use the usual retrieval tools (`search_history`, `get_message_context`, `list_indexed_repos`, `search_sources`, `get_source_context`).
|
||||
5. If speaking would genuinely help, it posts through `node scripts/send-discord-message.mjs`; otherwise it updates only the ignored moderation state file with cursors, memories, analytic/associative thought lanes, bridge syntheses and saturation notes, recent archive excursions, recent repo-activity sweeps, and candidate interventions.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"rag:backfill": "tsx scripts/backfill-discord-logs.ts",
|
||||
"moderation:recent-history": "node scripts/export-recent-discord-history.mjs",
|
||||
"moderation:random-history": "node scripts/export-random-discord-history.mjs",
|
||||
"moderation:recent-repo-activity": "node scripts/export-recent-repo-activity.mjs",
|
||||
"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",
|
||||
|
|
|
|||
337
scripts/export-recent-repo-activity.mjs
Normal file
337
scripts/export-recent-repo-activity.mjs
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
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 sinceIso = new Date(Date.now() - options.hours * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const repos = trackedRepoNames.map((repoName) =>
|
||||
inspectRepoActivity({
|
||||
repoName,
|
||||
repoPath: availableRepos.get(repoName.toLowerCase()),
|
||||
sinceIso,
|
||||
maxCommits: options.maxCommits,
|
||||
}),
|
||||
);
|
||||
|
||||
repos.sort(compareRepoActivity);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
generatedAt: new Date().toISOString(),
|
||||
sourceRepoRoot: config.sourceRepoRoot,
|
||||
archivePath: config.archivePath,
|
||||
since: sinceIso,
|
||||
hours: options.hours,
|
||||
maxCommits: options.maxCommits,
|
||||
totalTrackedRepos: trackedRepoNames.length,
|
||||
repos,
|
||||
digest: renderDigest(repos, sinceIso),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(args) {
|
||||
const options = {
|
||||
hours: 72,
|
||||
maxCommits: 3,
|
||||
repoNames: undefined,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const argument = args[index];
|
||||
|
||||
switch (argument) {
|
||||
case "--hours":
|
||||
options.hours = Number.parseInt(args[index + 1] ?? "", 10);
|
||||
index += 1;
|
||||
break;
|
||||
case "--max-commits":
|
||||
options.maxCommits = Number.parseInt(args[index + 1] ?? "", 10);
|
||||
index += 1;
|
||||
break;
|
||||
case "--repos":
|
||||
options.repoNames = parseRepoList(args[index + 1] ?? "");
|
||||
index += 1;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isInteger(options.hours) || options.hours <= 0) {
|
||||
throw new Error("--hours must be a positive integer.");
|
||||
}
|
||||
|
||||
if (!Number.isInteger(options.maxCommits) || options.maxCommits <= 0) {
|
||||
throw new Error("--max-commits must be a positive integer.");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseRepoList(value) {
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
const envPath = resolve(repoRoot, ".env");
|
||||
const env = parseDotEnvSafe(envPath);
|
||||
const sourceRepoRoot = env.SOURCE_REPO_ROOT?.trim();
|
||||
const archivePath = resolve(
|
||||
repoRoot,
|
||||
env.RAG_SOURCE_ARCHIVE_PATH?.trim() || ".voidbot/rag/source-documents.json",
|
||||
);
|
||||
|
||||
if (!sourceRepoRoot) {
|
||||
throw new Error("SOURCE_REPO_ROOT is not configured in .env.");
|
||||
}
|
||||
|
||||
return {
|
||||
sourceRepoRoot: resolve(sourceRepoRoot),
|
||||
archivePath,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDotEnvSafe(envPath) {
|
||||
try {
|
||||
return parseDotEnv(stripLeadingBom(readFileSync(envPath, "utf8")));
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDotEnv(raw) {
|
||||
const result = {};
|
||||
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.length === 0 || 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);
|
||||
}
|
||||
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function readTrackedRepoNames(archivePath, requestedRepoNames) {
|
||||
const raw = stripLeadingBom(readFileSync(archivePath, "utf8"));
|
||||
const parsed = JSON.parse(raw);
|
||||
const repoNames = Array.isArray(parsed?.repos)
|
||||
? parsed.repos
|
||||
.map((entry) => (entry && typeof entry.repoName === "string" ? entry.repoName : undefined))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (repoNames.length === 0) {
|
||||
throw new Error(`No tracked repos were found in ${archivePath}.`);
|
||||
}
|
||||
|
||||
if (!requestedRepoNames || requestedRepoNames.length === 0) {
|
||||
return repoNames;
|
||||
}
|
||||
|
||||
const requestedLookup = new Set(requestedRepoNames.map((value) => value.toLowerCase()));
|
||||
const selected = repoNames.filter((repoName) => requestedLookup.has(repoName.toLowerCase()));
|
||||
|
||||
const missing = requestedRepoNames.filter(
|
||||
(repoName) => !selected.some((value) => value.toLowerCase() === repoName.toLowerCase()),
|
||||
);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Requested tracked repos are missing from the source archive manifest: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
function readAvailableRepos(sourceRepoRoot) {
|
||||
const lookup = new Map();
|
||||
|
||||
for (const entry of readdirSync(sourceRepoRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
lookup.set(entry.name.toLowerCase(), resolve(sourceRepoRoot, entry.name));
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function inspectRepoActivity({ repoName, repoPath, sinceIso, maxCommits }) {
|
||||
if (!repoPath) {
|
||||
return {
|
||||
repoName,
|
||||
repoPath: null,
|
||||
status: "missing",
|
||||
branch: null,
|
||||
recentCommitCount: 0,
|
||||
latestCommit: null,
|
||||
commits: [],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const branch = execGit(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
||||
const latestCommit = parseCommitLine(execGit(repoPath, ["log", "-1", "--date=iso-strict", "--pretty=format:%H%x09%cI%x09%an%x09%s"]));
|
||||
const recentCommitCount = Number.parseInt(
|
||||
execGit(repoPath, ["rev-list", "--count", `--since=${sinceIso}`, "HEAD"]).trim(),
|
||||
10,
|
||||
);
|
||||
const recentCommitLines = execGit(repoPath, [
|
||||
"log",
|
||||
`--since=${sinceIso}`,
|
||||
`--max-count=${maxCommits}`,
|
||||
"--date=iso-strict",
|
||||
"--pretty=format:%H%x09%cI%x09%an%x09%s",
|
||||
]);
|
||||
const commits = recentCommitLines
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(parseCommitLine)
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
repoName,
|
||||
repoPath,
|
||||
status: "ok",
|
||||
branch,
|
||||
recentCommitCount: Number.isFinite(recentCommitCount) ? recentCommitCount : commits.length,
|
||||
latestCommit,
|
||||
commits,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
repoName,
|
||||
repoPath,
|
||||
status: "error",
|
||||
branch: null,
|
||||
recentCommitCount: 0,
|
||||
latestCommit: null,
|
||||
commits: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function execGit(repoPath, args) {
|
||||
return execFileSync("git", ["-C", repoPath, ...args], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function parseCommitLine(line) {
|
||||
if (!line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [hash, committedAt, author, ...subjectParts] = line.split("\t");
|
||||
|
||||
return {
|
||||
hash,
|
||||
committedAt,
|
||||
author,
|
||||
subject: subjectParts.join("\t"),
|
||||
};
|
||||
}
|
||||
|
||||
function compareRepoActivity(left, right) {
|
||||
const leftRecent = left.recentCommitCount ?? 0;
|
||||
const rightRecent = right.recentCommitCount ?? 0;
|
||||
|
||||
if (rightRecent !== leftRecent) {
|
||||
return rightRecent - leftRecent;
|
||||
}
|
||||
|
||||
const leftTimestamp = left.commits?.[0]?.committedAt ?? left.latestCommit?.committedAt ?? "";
|
||||
const rightTimestamp = right.commits?.[0]?.committedAt ?? right.latestCommit?.committedAt ?? "";
|
||||
|
||||
return rightTimestamp.localeCompare(leftTimestamp);
|
||||
}
|
||||
|
||||
function renderDigest(repos, sinceIso) {
|
||||
const lines = [`Recent tracked repo activity since ${sinceIso}:`];
|
||||
|
||||
for (const repo of repos) {
|
||||
if (repo.status === "missing") {
|
||||
lines.push(`- ${repo.repoName}: missing locally under SOURCE_REPO_ROOT`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (repo.status === "error") {
|
||||
lines.push(`- ${repo.repoName}: failed to read git activity (${repo.error})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const header =
|
||||
repo.recentCommitCount > 0
|
||||
? `- ${repo.repoName} [${repo.branch}]: ${repo.recentCommitCount} recent commit${repo.recentCommitCount === 1 ? "" : "s"}`
|
||||
: `- ${repo.repoName} [${repo.branch}]: no recent commits`;
|
||||
lines.push(header);
|
||||
|
||||
if (repo.commits.length > 0) {
|
||||
for (const commit of repo.commits) {
|
||||
lines.push(` - ${commit.committedAt} ${commit.author}: ${commit.subject}`);
|
||||
}
|
||||
} else if (repo.latestCommit) {
|
||||
lines.push(
|
||||
` - latest overall ${repo.latestCommit.committedAt} ${repo.latestCommit.author}: ${repo.latestCommit.subject}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function stripLeadingBom(input) {
|
||||
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
|
@ -249,7 +249,9 @@ Required operating posture:
|
|||
- 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`.
|
||||
- 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.
|
||||
- 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.
|
||||
- You are the only routine local agent in this workspace with a standing cross-project view, so notice ongoing experiments, commit clusters, and weird convergences that the narrower workers cannot see.
|
||||
- When the room is quiet, use the usual VoidBot retrieval tools to think about GameCult projects and contribute ideas:
|
||||
- `search_history`
|
||||
- `get_message_context`
|
||||
|
|
@ -257,6 +259,7 @@ Required operating posture:
|
|||
- `search_sources`
|
||||
- `get_source_context`
|
||||
- `notify_owner` when owner interruption is actually warranted
|
||||
- If recent repo motion gives you a grounded observation about the ongoing work, you are allowed to say so. Be a herald, not a weather vane nailed to one porch.
|
||||
- Do not keep circling the same musing just because it still feels tidy. Chase at least one fresh branch when the room gives you a concrete hook, and record archive excursions so you can avoid pacing the same trench forever.
|
||||
- Use the local bot-voice script `node scripts/send-discord-message.mjs ...` when you genuinely need to speak.
|
||||
- Update only `.voidbot/private/moderation-agent-state.json` as routine writable memory.
|
||||
|
|
|
|||
|
|
@ -43,3 +43,4 @@
|
|||
{"ts":"2026-05-04T23:41:29Z","type":"architecture","status":"ok","note":"The app heartbeat card was decorative; a real unattended moderation loop needs a local scheduled `codex exec` runner in the VoidBot workspace, with normal tool/MCP access and dangerous-bypass mode, or it will sit there narrating state updates it never actually persisted."}
|
||||
{"ts":"2026-05-05T00:03:35Z","type":"architecture","status":"ok","note":"The moderation rumination loop needs explicit novelty pressure: quiet runs now have a random archive helper plus recent excursion breadcrumbs so the agent stops laundering the same musing through fresh wording and instead follows unexplored archive seams or concrete live hooks."}
|
||||
{"ts":"2026-05-05T00:21:37Z","type":"architecture","status":"ok","note":"The moderation loop and shared self-state now model two concurrent thought lanes: an analytic room-facing lane and an associative archive/project lane, bridged by syntheses plus topic-saturation tracking. Depth stays allowed, but one rewarding seam should no longer be able to annex the whole mind just by getting picked first a few times."}
|
||||
{"ts":"2026-05-05T09:18:00Z","type":"architecture","status":"ok","note":"The moderation loop is also Void's only standing cross-project vantage point, so quiet runs now need an explicit tracked-repo activity sweep alongside archive dives. Repo motion belongs in the associative lane and bridge, with its own state breadcrumbs, so Void can comment on the wider experiment zoo instead of only whatever one chat thread happened to say aloud."}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ accepted_design:
|
|||
- The moderation agent's runtime cognition should stay split between an analytic room-facing lane, an associative archive/project lane, and a bridge that tracks syntheses plus topic saturation; depth is good, but one rewarding seam must not get to annex the whole mind.
|
||||
- Unattended moderation speech should use a local bot-token script instead of approval-gated side-effecting MCP tools, otherwise the loop keeps stopping to ask permission before it can talk.
|
||||
- Quiet moderation runs should prefer at least one random archive excursion plus concrete follow-on retrieval over simply rephrasing the last favorite theory; the ignored state needs recent excursion breadcrumbs so the loop can avoid pacing the same trench.
|
||||
- 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.
|
||||
- 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.
|
||||
|
|
@ -93,7 +94,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 scheduled moderation-rumination loop now launches `codex exec` from the VoidBot workspace every 15 minutes, reads tracked server rules, polls recent chronology through the plain Node helper, uses the usual VoidBot MCP/tool surface for archive and project rumination, performs explicit random archive excursions when the room is quiet, and maintains one ignored JSON state file under `.voidbot/private/` instead of smearing moderation memory across random notes.
|
||||
- A separate scheduled moderation-rumination loop now launches `codex exec` from the VoidBot workspace every 15 minutes, reads tracked server rules, polls recent chronology through the plain Node helper, sweeps recent tracked-repo commit activity for broader experiment awareness, uses the usual VoidBot MCP/tool surface for archive and project rumination, performs explicit random archive excursions when the room is quiet, and maintains one ignored JSON state file under `.voidbot/private/` instead of smearing moderation memory across random notes.
|
||||
|
||||
rejected_paths:
|
||||
- name: transcript_only_continuity
|
||||
|
|
|
|||
Loading…
Reference in a new issue