Compile the source indexing command into the runtime

This commit is contained in:
Meta Vens 2026-08-25 18:54:36 +02:00
commit 11568671a0
9 changed files with 54 additions and 24 deletions

View file

@ -126,7 +126,7 @@ async function runWorker() {
const child = spawn(
process.execPath,
[tsxCliPath, join(voidbotRoot, "scripts", "index-source-repos.ts"), "--repo", args.repoName],
[tsxCliPath, join(voidbotRoot, "apps", "worker", "src", "source-index-main.ts"), "--repo", args.repoName],
{
cwd: voidbotRoot,
windowsHide: true,

View file

@ -1,69 +0,0 @@
import "dotenv/config";
import { loadConfig } from "@voidbot/config";
import { discoverSourceRepos, selectSourceRepos } from "./source-repo-discovery";
import { indexSourceRepos } from "./source-repo-indexing";
interface ScriptOptions {
repos?: string[];
forceReindex?: boolean;
}
async function main(): Promise<void> {
if (!process.env.DISCORD_OWNER_ID) {
process.env.DISCORD_OWNER_ID = "__source_reindex__";
}
const options = parseArgs(process.argv.slice(2));
const config = loadConfig();
if (!config.sourceRepoRoot) {
throw new Error("SOURCE_REPO_ROOT is not configured.");
}
const repoNames =
await discoverSourceRepos(config.sourceRepoRoot, config.sourceRepoPatterns);
const repos = selectSourceRepos(repoNames, options.repos);
if (repos.length === 0) {
throw new Error("No source repositories matched SOURCE_REPO_PATTERNS.");
}
const summary = await indexSourceRepos(config, repos, {
forceReindex: options.forceReindex,
logger: (line) => console.log(line),
});
console.log(`Indexed repositories: ${summary.indexedRepositories}`);
console.log(`Indexed documents: ${summary.totalDocuments}`);
console.log(`Indexed chunks: ${summary.totalChunks}`);
}
function parseArgs(argv: string[]): ScriptOptions {
const options: ScriptOptions = {};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if ((argument === "--repo" || argument === "--repos") && argv[index + 1]) {
options.repos = argv[index + 1]
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
index += 1;
continue;
}
if (argument === "--force") {
options.forceReindex = true;
}
}
return options;
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -1,7 +1,7 @@
import "dotenv/config";
import { loadConfig } from "@voidbot/config";
import { discoverSourceRepos, selectSourceRepos } from "./source-repo-discovery";
import { discoverSourceRepos, selectSourceRepos } from "@voidbot/rag";
import { installSourceIndexPushHooks } from "./source-repo-hooks";
interface ScriptOptions {

View file

@ -5,11 +5,16 @@ import { fileURLToPath } from "node:url";
import { config as loadDotEnv } from "dotenv";
import { loadConfig } from "@voidbot/config";
import { FileSourceDocumentArchiveRepository } from "@voidbot/rag";
import {
createSourceVectorStore,
discoverSourceRepos,
FileSourceDocumentArchiveRepository,
indexSourceRepos,
selectSourceRepos,
type SourceRepoMatch,
} from "@voidbot/rag";
import { discoverSourceRepos, selectSourceRepos, type SourceRepoMatch } from "./source-repo-discovery";
import { installSourceIndexPushHooks } from "./source-repo-hooks";
import { createSourceVectorStore, indexSourceRepos } from "./source-repo-indexing";
interface ScriptOptions {
repos?: string[];

View file

@ -1,191 +0,0 @@
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { readdir, readFile, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
const execFile = promisify(execFileCallback);
const GAMECULT_ORIGIN_PATTERN = /github\.com[:/]GameCult\//i;
export interface SourceRepoMatch {
/** Canonical GameCult upstream slug: the source archive identity. */
repoName: string;
/** Local checkout label; diagnostic only, never an archive key. */
localRepoName: string;
repoPath: string;
gitDir: string;
}
export async function discoverSourceRepos(
root: string,
patterns: string[],
): Promise<SourceRepoMatch[]> {
const entries = await readdir(root, { withFileTypes: true });
const repoMatches: SourceRepoMatch[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
if (patterns.length > 0 && !patterns.some((pattern) => matchesPattern(entry.name, pattern))) {
continue;
}
const repoPath = join(root, entry.name);
const gitDir = await resolveGitDir(repoPath);
if (!gitDir) {
continue;
}
repoMatches.push({
repoName: entry.name,
localRepoName: entry.name,
repoPath,
gitDir,
});
}
const approved = await Promise.all(
repoMatches.map(async (repo) => ({
repo,
originUrl: await readOriginUrl(repo.repoPath),
})),
);
return selectCanonicalGameCultRepos(approved);
}
async function readOriginUrl(repoPath: string): Promise<string | undefined> {
try {
const { stdout } = await execFile("git", ["-C", repoPath, "remote", "get-url", "origin"], {
windowsHide: true,
});
const originUrl = stdout.trim();
return originUrl.length > 0 ? originUrl : undefined;
} catch {
return undefined;
}
}
function selectCanonicalGameCultRepos(
candidates: Array<{ repo: SourceRepoMatch; originUrl?: string }>,
): SourceRepoMatch[] {
const byOrigin = new Map<string, Array<{ repo: SourceRepoMatch; originUrl: string }>>();
for (const candidate of candidates) {
if (!candidate.originUrl || !GAMECULT_ORIGIN_PATTERN.test(candidate.originUrl)) {
continue;
}
const originKey = normalizeOrigin(candidate.originUrl);
const entries = byOrigin.get(originKey) ?? [];
entries.push({ repo: candidate.repo, originUrl: candidate.originUrl });
byOrigin.set(originKey, entries);
}
return [...byOrigin.values()]
.map((entries) => {
const canonical = chooseCanonicalRepo(entries);
return {
...canonical,
repoName: repoNameFromOrigin(entries[0].originUrl),
};
})
.sort((left, right) => left.repoName.localeCompare(right.repoName));
}
function chooseCanonicalRepo(entries: Array<{ repo: SourceRepoMatch; originUrl: string }>): SourceRepoMatch {
const remoteName = repoNameFromOrigin(entries[0].originUrl);
return entries
.slice()
.sort((left, right) => canonicalRank(left.repo, remoteName) - canonicalRank(right.repo, remoteName)
|| left.repo.repoName.localeCompare(right.repo.repoName))[0].repo;
}
function canonicalRank(repo: SourceRepoMatch, remoteName: string): number {
if (repo.localRepoName.localeCompare(remoteName, undefined, { sensitivity: "accent" }) === 0) {
return 0;
}
return repo.gitDir.replace(/\\/g, "/").toLowerCase().endsWith("/.git") ? 1 : 2;
}
function normalizeOrigin(originUrl: string): string {
return originUrl.trim().replace(/\\/g, "/").replace(/\.git$/i, "").toLowerCase();
}
function repoNameFromOrigin(originUrl: string): string {
const normalized = originUrl.trim().replace(/\\/g, "/").replace(/\.git$/i, "");
return normalized.slice(normalized.lastIndexOf("/") + 1);
}
export function selectSourceRepos(
availableRepos: SourceRepoMatch[],
requestedRepoNames?: string[],
): SourceRepoMatch[] {
if (!requestedRepoNames || requestedRepoNames.length === 0) {
return availableRepos;
}
const lookup = new Map(
availableRepos.flatMap((repo) => [
[repo.repoName.toLowerCase(), repo] as const,
[repo.localRepoName.toLowerCase(), repo] as const,
]),
);
const selectedRepos: SourceRepoMatch[] = [];
const missingRepoNames: string[] = [];
for (const requestedRepoName of requestedRepoNames) {
const repo = lookup.get(requestedRepoName.toLowerCase());
if (!repo) {
missingRepoNames.push(requestedRepoName);
continue;
}
if (!selectedRepos.some((entry) => entry.repoName === repo.repoName)) {
selectedRepos.push(repo);
}
}
if (missingRepoNames.length > 0) {
throw new Error(
`Requested source repos were not found under SOURCE_REPO_ROOT: ${missingRepoNames.join(", ")}`,
);
}
return selectedRepos;
}
export async function resolveGitDir(repoPath: string): Promise<string | undefined> {
const dotGitPath = join(repoPath, ".git");
try {
const dotGitStats = await stat(dotGitPath);
if (dotGitStats.isDirectory()) {
return dotGitPath;
}
if (dotGitStats.isFile()) {
const pointer = await readFile(dotGitPath, "utf8");
const match = pointer.match(/^gitdir:\s*(.+)$/im);
if (match?.[1]) {
return resolve(repoPath, match[1].trim());
}
}
} catch {
return undefined;
}
return undefined;
}
function matchesPattern(value: string, pattern: string): boolean {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
const regex = new RegExp(`^${escaped}$`, "i");
return regex.test(value);
}

View file

@ -1,7 +1,7 @@
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { type SourceRepoMatch } from "./source-repo-discovery";
import { type SourceRepoMatch } from "@voidbot/rag";
export const HOOK_MARKER = "# voidbot-source-index-push-hook";

View file

@ -1,114 +0,0 @@
import { type AppConfig } from "@voidbot/config";
import {
crawlRepositoryDocuments,
createTextEmbedder,
createVectorStores,
FileSourceDocumentArchiveRepository,
SourceDocumentIngester,
SourceRagPipeline,
} from "@voidbot/rag";
import { type SourceRepoMatch } from "./source-repo-discovery";
export interface IndexedRepoResult {
repoName: string;
documents: number;
createdDocuments: number;
updatedDocuments: number;
unchangedDocuments: number;
deletedDocuments: number;
indexedChunks: number;
skippedFiles: number;
}
export interface SourceIndexRunSummary {
indexedRepositories: number;
totalDocuments: number;
totalChunks: number;
results: IndexedRepoResult[];
}
export function createSourceVectorStore(config: AppConfig) {
const embedder = createTextEmbedder({
backend: config.ragEmbeddingBackend,
hashDimensions: config.ragEmbeddingDimensions,
ollamaBaseUrl: config.ragOllamaBaseUrl,
ollamaModel: config.ragOllamaModel,
ollamaTimeoutMs: config.ragOllamaTimeoutMs,
queryInstruction: config.ragQueryInstruction,
});
return createVectorStores({
kind: config.vectorStore.kind,
historyPath: config.vectorStore.path,
personaMemoryPath: config.vectorStore.personaMemoryPath,
sourceRoot: config.sourceVectorStoreRoot,
qdrant: config.qdrant,
historyEmbedder: embedder,
sourceEmbedder: embedder,
personaMemoryEmbedder: embedder,
}).source;
}
export async function indexSourceRepos(
config: AppConfig,
repos: SourceRepoMatch[],
options: {
forceReindex?: boolean;
logger?: (line: string) => void;
} = {},
): Promise<SourceIndexRunSummary> {
const vectorStore = createSourceVectorStore(config);
const archiveRepository = new FileSourceDocumentArchiveRepository(config.ragSourceArchivePath);
const sourceIngester = new SourceDocumentIngester();
const pipeline = new SourceRagPipeline(archiveRepository, sourceIngester, vectorStore);
const results: IndexedRepoResult[] = [];
let indexedRepositories = 0;
let totalDocuments = 0;
let totalChunks = 0;
for (const repo of repos) {
const scan = await crawlRepositoryDocuments(repo.repoPath, repo.repoName, {
includePathPrefixes: config.sourceRepoIncludePrefixes[repo.repoName]
?? config.sourceRepoIncludePrefixes[repo.localRepoName],
});
const result = await pipeline.syncRepoDocuments(repo.repoName, scan.documents, {
forceReindex: options.forceReindex,
});
const repoResult: IndexedRepoResult = {
repoName: repo.repoName,
documents: scan.documents.length,
createdDocuments: result.createdDocuments,
updatedDocuments: result.updatedDocuments,
unchangedDocuments: result.unchangedDocuments,
deletedDocuments: result.deletedDocuments,
indexedChunks: result.indexedChunks,
skippedFiles: scan.skippedFiles.length,
};
results.push(repoResult);
indexedRepositories += 1;
totalDocuments += scan.documents.length;
totalChunks += result.indexedChunks;
options.logger?.(
[
`Repo ${repo.repoName}:`,
`documents=${repoResult.documents}`,
`created=${repoResult.createdDocuments}`,
`updated=${repoResult.updatedDocuments}`,
`unchanged=${repoResult.unchangedDocuments}`,
`deleted=${repoResult.deletedDocuments}`,
`indexedChunks=${repoResult.indexedChunks}`,
`skippedFiles=${repoResult.skippedFiles}`,
].join(" "),
);
}
return {
indexedRepositories,
totalDocuments,
totalChunks,
results,
};
}