836 lines
24 KiB
JavaScript
836 lines
24 KiB
JavaScript
import { execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const defaultModerationStatePath = resolve(
|
|
repoRoot,
|
|
".voidbot/private/void-self-state.cc",
|
|
);
|
|
const voidSelfStateScriptPath = resolve(repoRoot, "scripts/void-self-state.mjs");
|
|
const coreDistPath = resolve(repoRoot, "packages/core/dist/index.js");
|
|
|
|
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 canonicalStatePath = resolveCanonicalStatePath(options);
|
|
const repoActivityCursor = canonicalStatePath
|
|
? readRepoActivityCursorFromCanonicalState(canonicalStatePath)
|
|
: new Map();
|
|
const sinceIso = new Date(Date.now() - options.hours * 60 * 60 * 1000).toISOString();
|
|
const generatedAt = new Date().toISOString();
|
|
|
|
const repos = trackedRepoNames.map((repoName) =>
|
|
inspectRepoActivity({
|
|
repoName,
|
|
repoPath: availableRepos.get(repoName.toLowerCase()),
|
|
sinceIso,
|
|
maxCommits: options.maxCommits,
|
|
cursorEntry: repoActivityCursor.get(repoName.toLowerCase()) ?? null,
|
|
}),
|
|
);
|
|
|
|
repos.sort(compareRepoActivity);
|
|
const cursorUpdate = canonicalStatePath
|
|
? writeRepoActivityCursor({
|
|
canonicalStatePath,
|
|
repos,
|
|
generatedAt,
|
|
readOnly: options.readOnly,
|
|
})
|
|
: { mode: "stateless", updated: false, repoCount: 0 };
|
|
|
|
process.stdout.write(
|
|
`${JSON.stringify(
|
|
{
|
|
generatedAt,
|
|
sourceRepoRoot: config.sourceRepoRoot,
|
|
archivePath: config.archivePath,
|
|
since: sinceIso,
|
|
hours: options.hours,
|
|
maxCommits: options.maxCommits,
|
|
cursorMode: cursorUpdate.mode,
|
|
statePath: canonicalStatePath,
|
|
cursorUpdated: cursorUpdate.updated,
|
|
cursorRepoCount: cursorUpdate.repoCount,
|
|
totalTrackedRepos: trackedRepoNames.length,
|
|
repos,
|
|
digest: renderDigest(repos, sinceIso, cursorUpdate.mode),
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
}
|
|
|
|
function parseArgs(args) {
|
|
const options = {
|
|
hours: 72,
|
|
maxCommits: 3,
|
|
repoNames: undefined,
|
|
statePath: defaultModerationStatePath,
|
|
cursorFile: undefined,
|
|
readOnly: false,
|
|
};
|
|
|
|
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;
|
|
case "--cursor-file":
|
|
options.cursorFile = args[index + 1] ?? "";
|
|
index += 1;
|
|
break;
|
|
case "--state-path":
|
|
options.statePath = args[index + 1] ?? "";
|
|
index += 1;
|
|
break;
|
|
case "--stateless":
|
|
options.statePath = null;
|
|
options.cursorFile = null;
|
|
break;
|
|
case "--read-only":
|
|
options.readOnly = true;
|
|
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 resolveCanonicalStatePath(options) {
|
|
if (options.statePath === null) {
|
|
return null;
|
|
}
|
|
|
|
if (options.cursorFile) {
|
|
const cursorFilePath = resolve(repoRoot, options.cursorFile);
|
|
const derived = deriveCanonicalModerationStatePath(cursorFilePath);
|
|
if (derived) {
|
|
return derived;
|
|
}
|
|
return cursorFilePath.toLowerCase().endsWith(".cc") ? cursorFilePath : null;
|
|
}
|
|
|
|
return options.statePath ? resolve(repoRoot, options.statePath) : null;
|
|
}
|
|
|
|
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();
|
|
const canonicalCandidates = new Map();
|
|
|
|
for (const entry of readdirSync(sourceRepoRoot, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) {
|
|
continue;
|
|
}
|
|
|
|
const repoPath = resolve(sourceRepoRoot, entry.name);
|
|
lookup.set(entry.name.toLowerCase(), repoPath);
|
|
|
|
const canonicalRepoName = readCanonicalGameCultRepoName(repoPath);
|
|
if (!canonicalRepoName) {
|
|
continue;
|
|
}
|
|
|
|
const key = canonicalRepoName.toLowerCase();
|
|
const candidates = canonicalCandidates.get(key) ?? [];
|
|
candidates.push({ localRepoName: entry.name, repoPath, canonicalRepoName });
|
|
canonicalCandidates.set(key, candidates);
|
|
}
|
|
|
|
for (const [key, candidates] of canonicalCandidates) {
|
|
candidates.sort((left, right) => {
|
|
const leftExact = left.localRepoName.toLowerCase() === left.canonicalRepoName.toLowerCase() ? 0 : 1;
|
|
const rightExact = right.localRepoName.toLowerCase() === right.canonicalRepoName.toLowerCase() ? 0 : 1;
|
|
return leftExact - rightExact || left.localRepoName.localeCompare(right.localRepoName);
|
|
});
|
|
lookup.set(key, candidates[0].repoPath);
|
|
}
|
|
|
|
return lookup;
|
|
}
|
|
|
|
function readCanonicalGameCultRepoName(repoPath) {
|
|
try {
|
|
const originUrl = execFileSync(
|
|
"git",
|
|
["-C", repoPath, "remote", "get-url", "origin"],
|
|
{ encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "ignore"] },
|
|
).trim();
|
|
const match = originUrl.match(/github\.com[:/]GameCult\/([^/]+?)(?:\.git)?$/i);
|
|
return match?.[1] || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function inspectRepoActivity({ repoName, repoPath, sinceIso, maxCommits, cursorEntry }) {
|
|
if (!repoPath) {
|
|
return {
|
|
repoName,
|
|
repoPath: null,
|
|
status: "missing",
|
|
branch: null,
|
|
recentCommitCount: 0,
|
|
latestCommit: null,
|
|
commits: [],
|
|
windowRecentCommitCount: 0,
|
|
suppressedRecentCommitCount: 0,
|
|
};
|
|
}
|
|
|
|
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((line) => enrichCommit(repoPath, parseCommitLine(line)))
|
|
.filter(Boolean);
|
|
const freshCommits = filterCommitsSinceCursor(commits, cursorEntry);
|
|
const freshCommitCount = freshCommits.length;
|
|
const windowRecentCommitCount = Number.isFinite(recentCommitCount) ? recentCommitCount : commits.length;
|
|
|
|
return {
|
|
repoName,
|
|
repoPath,
|
|
status: "ok",
|
|
branch,
|
|
recentCommitCount: freshCommitCount,
|
|
windowRecentCommitCount,
|
|
suppressedRecentCommitCount: Math.max(0, windowRecentCommitCount - freshCommitCount),
|
|
latestCommit,
|
|
commits: freshCommits,
|
|
cursorEntry,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
repoName,
|
|
repoPath,
|
|
status: "error",
|
|
branch: null,
|
|
recentCommitCount: 0,
|
|
latestCommit: null,
|
|
commits: [],
|
|
windowRecentCommitCount: 0,
|
|
suppressedRecentCommitCount: 0,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
function filterCommitsSinceCursor(commits, cursorEntry) {
|
|
if (!Array.isArray(commits) || commits.length === 0 || !cursorEntry || typeof cursorEntry !== "object") {
|
|
return commits;
|
|
}
|
|
|
|
const lastSeenHash = typeof cursorEntry.lastSeenHash === "string" ? cursorEntry.lastSeenHash.trim() : "";
|
|
if (lastSeenHash) {
|
|
const seenIndex = commits.findIndex((commit) => commit?.hash === lastSeenHash);
|
|
if (seenIndex !== -1) {
|
|
return commits.slice(0, seenIndex);
|
|
}
|
|
}
|
|
|
|
const lastSeenCommittedAt =
|
|
typeof cursorEntry.lastSeenCommittedAt === "string" ? cursorEntry.lastSeenCommittedAt.trim() : "";
|
|
const lastSeenMs = lastSeenCommittedAt ? Date.parse(lastSeenCommittedAt) : Number.NaN;
|
|
if (Number.isFinite(lastSeenMs)) {
|
|
return commits.filter((commit) => Date.parse(commit.committedAt) > lastSeenMs);
|
|
}
|
|
|
|
return commits;
|
|
}
|
|
|
|
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 enrichCommit(repoPath, commit) {
|
|
if (!commit?.hash) {
|
|
return commit;
|
|
}
|
|
|
|
try {
|
|
const statLines = execGit(repoPath, [
|
|
"show",
|
|
"--shortstat",
|
|
"--format=",
|
|
"--name-only",
|
|
commit.hash,
|
|
])
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
const summaryLine = statLines.find((line) => /files? changed|insertions?|deletions?/.test(line));
|
|
const allChangedPaths = statLines.filter((line) => !/files? changed|insertions?|deletions?/.test(line));
|
|
const changedPaths = allChangedPaths.slice(0, 12);
|
|
|
|
return {
|
|
...commit,
|
|
diffstat: parseDiffstat(summaryLine),
|
|
changedPaths,
|
|
contentHints: buildCommitContentHints(repoPath, commit.hash, changedPaths),
|
|
doctrineDocuments: buildDoctrineDocuments(repoPath, commit.hash, allChangedPaths, commit.subject),
|
|
};
|
|
} catch {
|
|
return {
|
|
...commit,
|
|
diffstat: null,
|
|
changedPaths: [],
|
|
contentHints: [],
|
|
};
|
|
}
|
|
}
|
|
|
|
function buildCommitContentHints(repoPath, commitHash, changedPaths) {
|
|
return changedPaths
|
|
.filter((path) => isHintableTextPath(path))
|
|
.slice(0, 4)
|
|
.map((path) => buildFileContentHint(repoPath, commitHash, path))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function buildDoctrineDocuments(repoPath, commitHash, changedPaths, subject) {
|
|
const doctrinePaths = changedPaths
|
|
.filter((path) => isDoctrineDocumentPath(path, subject))
|
|
.slice(0, 4);
|
|
|
|
return doctrinePaths
|
|
.map((path) => buildDoctrineDocument(repoPath, commitHash, path))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function isHintableTextPath(path) {
|
|
return /\.(md|mdx|txt|json|jsonc|ya?ml|toml|ts|tsx|js|jsx|mjs|cjs|rs|cs|ps1|py|html|css|scss)$/i.test(path);
|
|
}
|
|
|
|
function isDoctrineDocumentPath(path, subject) {
|
|
if (!/\.(md|mdx|txt)$/i.test(path)) {
|
|
return false;
|
|
}
|
|
|
|
const normalized = path.replace(/\\/g, "/");
|
|
const pathLooksDoctrinal =
|
|
/(^|\/)(readme|docs?|notes|architecture|design|worldbuilding|lore|reference)\b/i.test(normalized) ||
|
|
/(architecture|doctrine|authority|policy|contract|conformance|ownership|lowering|renderless|runtime|daemon|cultmesh|eve|verse|client|surface|custody)/i.test(normalized);
|
|
const subjectLooksDoctrinal =
|
|
/(architecture|doctrine|authority|policy|contract|conformance|ownership|lowering|renderless|runtime|daemon|cultmesh|eve|verse|client|surface|custody)/i.test(subject ?? "");
|
|
|
|
return pathLooksDoctrinal || subjectLooksDoctrinal;
|
|
}
|
|
|
|
function buildDoctrineDocument(repoPath, commitHash, path) {
|
|
const currentText = readCommittedFile(repoPath, commitHash, path);
|
|
if (!currentText.trim()) {
|
|
return undefined;
|
|
}
|
|
|
|
const addedLines = readAddedLines(repoPath, commitHash, path);
|
|
const headings = readMarkdownHeadings(currentText).slice(0, 8);
|
|
const committedExcerpt = compactDoctrineExcerpt(currentText);
|
|
|
|
return {
|
|
path,
|
|
headings,
|
|
addedExcerpts: addedLines
|
|
.filter((line) => line.trim().length > 0 && !line.trim().startsWith("---"))
|
|
.slice(0, 16),
|
|
committedExcerpt,
|
|
};
|
|
}
|
|
|
|
function compactDoctrineExcerpt(text) {
|
|
const lines = text
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trimEnd())
|
|
.filter((line) => line.trim().length > 0)
|
|
.slice(0, 160);
|
|
const excerpt = lines.join("\n");
|
|
return excerpt.length > 6000 ? `${excerpt.slice(0, 6000).trimEnd()}\n[...]` : excerpt;
|
|
}
|
|
|
|
function buildFileContentHint(repoPath, commitHash, path) {
|
|
const currentText = readCommittedFile(repoPath, commitHash, path);
|
|
const addedLines = readAddedLines(repoPath, commitHash, path);
|
|
const frontmatter = parseFrontmatter(currentText);
|
|
const headings = readMarkdownHeadings(currentText).slice(0, 4);
|
|
const excerpts = addedLines
|
|
.filter((line) => line.trim().length > 0 && !line.trim().startsWith("---"))
|
|
.slice(0, 6);
|
|
|
|
if (!frontmatter && headings.length === 0 && excerpts.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
path,
|
|
frontmatter,
|
|
headings,
|
|
addedExcerpts: excerpts,
|
|
};
|
|
}
|
|
|
|
function readCommittedFile(repoPath, commitHash, path) {
|
|
try {
|
|
return execGit(repoPath, ["show", `${commitHash}:${path}`]);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function readAddedLines(repoPath, commitHash, path) {
|
|
try {
|
|
return execGit(repoPath, ["show", commitHash, "--format=", "--unified=0", "--", path])
|
|
.split(/\r?\n/)
|
|
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
|
|
.map((line) => line.slice(1).trim())
|
|
.filter(Boolean)
|
|
.slice(0, 12);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function parseFrontmatter(text) {
|
|
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
if (!match) {
|
|
return undefined;
|
|
}
|
|
|
|
const result = {};
|
|
let activeListKey;
|
|
for (const rawLine of match[1].split(/\r?\n/)) {
|
|
const line = rawLine.trimEnd();
|
|
const listItem = line.match(/^\s*-\s+(.*)$/);
|
|
if (listItem && activeListKey) {
|
|
result[activeListKey].push(stripWrappingQuotes(listItem[1].trim()));
|
|
continue;
|
|
}
|
|
|
|
activeListKey = undefined;
|
|
const pair = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
if (!pair) {
|
|
continue;
|
|
}
|
|
|
|
const [, key, rawValue] = pair;
|
|
if (rawValue.trim().length === 0) {
|
|
result[key] = [];
|
|
activeListKey = key;
|
|
continue;
|
|
}
|
|
|
|
if (["title", "description", "author", "date", "socialDeck"].includes(key)) {
|
|
result[key] = stripWrappingQuotes(rawValue.trim());
|
|
}
|
|
}
|
|
|
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
}
|
|
|
|
function readMarkdownHeadings(text) {
|
|
return text
|
|
.split(/\r?\n/)
|
|
.map((line) => line.match(/^(#{1,3})\s+(.+)$/)?.[2]?.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function stripWrappingQuotes(value) {
|
|
return value.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
|
|
}
|
|
|
|
function parseDiffstat(summaryLine) {
|
|
if (!summaryLine) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
filesChanged: readFirstInt(summaryLine, /(\d+)\s+files?\s+changed/i),
|
|
insertions: readFirstInt(summaryLine, /(\d+)\s+insertions?\(\+\)/i),
|
|
deletions: readFirstInt(summaryLine, /(\d+)\s+deletions?\(-\)/i),
|
|
};
|
|
}
|
|
|
|
function readFirstInt(input, pattern) {
|
|
const match = input.match(pattern);
|
|
return match ? Number.parseInt(match[1], 10) : 0;
|
|
}
|
|
|
|
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, cursorMode) {
|
|
const isIncremental = cursorMode === "incremental";
|
|
const lines = [
|
|
isIncremental
|
|
? `New tracked repo activity since the saved repo cursor (bounded to ${sinceIso}):`
|
|
: `Recent tracked repo activity since ${sinceIso}:`,
|
|
];
|
|
let mentionedRepoCount = 0;
|
|
|
|
for (const repo of repos) {
|
|
if (repo.status === "missing") {
|
|
lines.push(`- ${repo.repoName}: missing locally under SOURCE_REPO_ROOT`);
|
|
mentionedRepoCount += 1;
|
|
continue;
|
|
}
|
|
|
|
if (repo.status === "error") {
|
|
lines.push(`- ${repo.repoName}: failed to read git activity (${repo.error})`);
|
|
mentionedRepoCount += 1;
|
|
continue;
|
|
}
|
|
|
|
if (isIncremental && repo.recentCommitCount <= 0) {
|
|
continue;
|
|
}
|
|
|
|
const header =
|
|
repo.recentCommitCount > 0
|
|
? `- ${repo.repoName} [${repo.branch}]: ${repo.recentCommitCount} ${isIncremental ? "new" : "recent"} commit${repo.recentCommitCount === 1 ? "" : "s"}`
|
|
: `- ${repo.repoName} [${repo.branch}]: no recent commits`;
|
|
lines.push(header);
|
|
mentionedRepoCount += 1;
|
|
|
|
if (repo.commits.length > 0) {
|
|
for (const commit of repo.commits) {
|
|
const statBits = [];
|
|
if (commit.diffstat?.filesChanged) {
|
|
statBits.push(`${commit.diffstat.filesChanged} files`);
|
|
}
|
|
if (commit.diffstat?.insertions) {
|
|
statBits.push(`+${commit.diffstat.insertions}`);
|
|
}
|
|
if (commit.diffstat?.deletions) {
|
|
statBits.push(`-${commit.diffstat.deletions}`);
|
|
}
|
|
|
|
lines.push(
|
|
` - ${commit.committedAt} ${commit.author}: ${commit.subject}${statBits.length > 0 ? ` (${statBits.join(", ")})` : ""}`,
|
|
);
|
|
if (Array.isArray(commit.changedPaths) && commit.changedPaths.length > 0) {
|
|
lines.push(` paths: ${commit.changedPaths.join(", ")}`);
|
|
}
|
|
}
|
|
} else if (!isIncremental && repo.latestCommit) {
|
|
lines.push(
|
|
` - latest overall ${repo.latestCommit.committedAt} ${repo.latestCommit.author}: ${repo.latestCommit.subject}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mentionedRepoCount === 0) {
|
|
lines.push(
|
|
isIncremental
|
|
? "- No new tracked repo commits crossed the saved repo-activity cursor."
|
|
: "- No recent tracked repo commits.",
|
|
);
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function readRepoActivityCursorFromCanonicalState(canonicalStatePath) {
|
|
if (!existsSync(canonicalStatePath)) {
|
|
return new Map();
|
|
}
|
|
|
|
if (!existsSync(coreDistPath)) {
|
|
throw new Error(`Missing built core package at ${coreDistPath}. Run npm run build first.`);
|
|
}
|
|
|
|
const typedCursor = readTypedRepoActivityCursor(canonicalStatePath);
|
|
const lookup = new Map();
|
|
|
|
for (const cursorEntry of typedCursor) {
|
|
if (!cursorEntry?.repo) {
|
|
continue;
|
|
}
|
|
|
|
lookup.set(cursorEntry.repo.toLowerCase(), {
|
|
lastSeenHash: cursorEntry.lastCommitSha ?? null,
|
|
lastSeenCommittedAt: cursorEntry.lastCommitAt ?? null,
|
|
});
|
|
}
|
|
|
|
return lookup;
|
|
}
|
|
|
|
function writeRepoActivityCursor({ canonicalStatePath, repos, generatedAt, readOnly }) {
|
|
let updated = false;
|
|
let repoCount = 0;
|
|
|
|
if (readOnly) {
|
|
return {
|
|
mode: "incremental_read_only",
|
|
updated: false,
|
|
repoCount: repos.filter((repo) => repo.status === "ok" && repo.recentCommitCount > 0).length,
|
|
};
|
|
}
|
|
|
|
for (const repo of repos) {
|
|
if (repo.status !== "ok" || !repo.latestCommit?.hash || repo.recentCommitCount <= 0) {
|
|
continue;
|
|
}
|
|
|
|
const nextEntry = {
|
|
lastSeenHash: repo.latestCommit.hash,
|
|
lastSeenCommittedAt: repo.latestCommit.committedAt ?? null,
|
|
lastInjectedAt: generatedAt,
|
|
};
|
|
applyRepoActivityCursorOperation({
|
|
canonicalStatePath,
|
|
repoName: repo.repoName,
|
|
nextEntry,
|
|
});
|
|
updated = true;
|
|
repoCount += 1;
|
|
}
|
|
|
|
return { mode: "incremental", updated, repoCount };
|
|
}
|
|
|
|
function applyRepoActivityCursorOperation({ canonicalStatePath, repoName, nextEntry }) {
|
|
if (!existsSync(voidSelfStateScriptPath)) {
|
|
throw new Error(`Missing typed self-state CLI at ${voidSelfStateScriptPath}.`);
|
|
}
|
|
|
|
const operation = {
|
|
operation: "update_repo_activity_cursor",
|
|
cursor: {
|
|
repo: repoName,
|
|
lastCommitAt: nextEntry.lastSeenCommittedAt ?? undefined,
|
|
lastCommitSha: nextEntry.lastSeenHash ?? undefined,
|
|
updatedAt: nextEntry.lastInjectedAt,
|
|
},
|
|
};
|
|
|
|
execFileSync(
|
|
process.execPath,
|
|
[
|
|
voidSelfStateScriptPath,
|
|
"apply-operation",
|
|
"--canonical",
|
|
canonicalStatePath,
|
|
"--operation",
|
|
JSON.stringify(operation),
|
|
],
|
|
{
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
}
|
|
|
|
function readTypedRepoActivityCursor(canonicalStatePath) {
|
|
const marker = "__VOIDBOT_ASYNC_RESULT__";
|
|
const script = `
|
|
const core = require(${JSON.stringify(coreDistPath)});
|
|
core.loadVoidSelfStateTypedDocuments({ canonicalPath: ${JSON.stringify(canonicalStatePath)} })
|
|
.then((state) => {
|
|
console.log(${JSON.stringify(marker)} + JSON.stringify(state.moderationCursor.repoActivityCursor ?? []));
|
|
})
|
|
.catch((error) => { console.error(error); process.exit(1); });
|
|
`;
|
|
const output = execFileSync(process.execPath, ["-e", script], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const line = output.split(/\r?\n/).find((entry) => entry.startsWith(marker));
|
|
if (!line) {
|
|
throw new Error("Failed to read moderation state through core loader.");
|
|
}
|
|
return JSON.parse(line.slice(marker.length));
|
|
}
|
|
|
|
function deriveCanonicalModerationStatePath(cursorFilePath) {
|
|
if (typeof cursorFilePath !== "string" || !cursorFilePath.toLowerCase().endsWith(".json")) {
|
|
return null;
|
|
}
|
|
|
|
const candidate = `${cursorFilePath.slice(0, -".json".length)}.cc`;
|
|
return existsSync(candidate) ? candidate : null;
|
|
}
|
|
|
|
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;
|
|
}
|