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
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue