Auto-reconcile source repo indexing

This commit is contained in:
Meta Vens 2026-05-02 12:49:23 +01:00
commit de223f0fff
16 changed files with 1125 additions and 286 deletions

View file

@ -235,6 +235,46 @@ function Invoke-CommandWithTimeout {
}
}
function Invoke-SourceRepoReconcile {
param(
[switch] $CheckOnly,
[switch] $Detached
)
$tsxCommand = Join-Path $repoRoot "node_modules\.bin\tsx.cmd"
if (-not (Test-Path -LiteralPath $tsxCommand)) {
throw "Local tsx launcher is missing at $tsxCommand."
}
$arguments = @("scripts/reconcile-source-repos.ts", "--json")
if ($CheckOnly) {
$arguments += "--check"
}
if ($Detached) {
$arguments += "--detached"
}
$stdout = (& $tsxCommand @arguments 2>&1 | Out-String).Trim()
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
if ([string]::IsNullOrWhiteSpace($stdout)) {
$stdout = "Source repo reconcile exited with code $exitCode."
}
throw $stdout
}
if ([string]::IsNullOrWhiteSpace($stdout)) {
throw "Source repo reconcile returned no JSON output."
}
return $stdout | ConvertFrom-Json
}
function Ensure-DockerCliOnPath {
$dockerCliDir = "C:\Program Files\Docker\Docker\resources\bin"
@ -652,6 +692,7 @@ try {
$vectorStoreKind = if ($config.ContainsKey("VECTOR_STORE_KIND")) { $config["VECTOR_STORE_KIND"] } else { "local_json" }
$historyCollection = if ($config.ContainsKey("QDRANT_HISTORY_COLLECTION")) { $config["QDRANT_HISTORY_COLLECTION"] } else { "voidbot_discord_history_chunks" }
$sourceCollection = if ($config.ContainsKey("QDRANT_SOURCE_COLLECTION")) { $config["QDRANT_SOURCE_COLLECTION"] } else { "voidbot_repository_source_chunks" }
$qdrantReady = $vectorStoreKind -ne "qdrant"
Set-WatchdogStep -CurrentStep "qdrant_checks" -Detail "Verifying Qdrant reachability and required collections."
if ($vectorStoreKind -eq "qdrant") {
@ -659,6 +700,7 @@ try {
try {
$collectionsResponse = Get-Json -Url "$($qdrantUrl.TrimEnd('/'))/collections"
$collectionNames = @($collectionsResponse.result.collections | ForEach-Object { [string]$_.name })
$qdrantReady = $true
Add-Check -Name "qdrant.reachable" -Status "passed" -Detail "Qdrant is reachable at $qdrantUrl." -Data @{
url = $qdrantUrl
collections = $collectionNames
@ -742,6 +784,7 @@ try {
$ragOllamaUrl = if ($config.ContainsKey("RAG_OLLAMA_BASE_URL")) { $config["RAG_OLLAMA_BASE_URL"] } else { "http://127.0.0.1:11434" }
$ragOllamaModel = if ($config.ContainsKey("RAG_OLLAMA_MODEL")) { $config["RAG_OLLAMA_MODEL"] } else { "qwen3-embedding:0.6b" }
$ragOllamaReady = $false
Set-WatchdogStep -CurrentStep "ollama_checks" -Detail "Checking embedding and local LLM Ollama endpoints."
try {
@ -749,6 +792,7 @@ try {
$ragModels = @($ragTags.models | ForEach-Object { [string]$_.name })
if ($ragModels -contains $ragOllamaModel) {
$ragOllamaReady = $true
Add-Check -Name "rag_ollama.model" -Status "passed" -Detail "Embedding Ollama is reachable and has $ragOllamaModel." -Data @{
url = $ragOllamaUrl
model = $ragOllamaModel
@ -805,6 +849,54 @@ try {
}
}
$sourceRepoRoot = if ($config.ContainsKey("SOURCE_REPO_ROOT")) { $config["SOURCE_REPO_ROOT"] } else { "" }
if ([string]::IsNullOrWhiteSpace($sourceRepoRoot)) {
Add-Check -Name "source.repos" -Status "passed" -Detail "Source repo reconciliation check skipped because SOURCE_REPO_ROOT is blank."
} else {
Set-WatchdogStep -CurrentStep "source_repo_checks" -Detail "Reconciling discovered source repos against the indexed source archive."
try {
$sourceRepoPreview = Invoke-SourceRepoReconcile -CheckOnly
$missingRepos = @($sourceRepoPreview.missingRepos | ForEach-Object { [string]$_ })
$staleIndexedRepos = @($sourceRepoPreview.staleIndexedRepos | ForEach-Object { [string]$_ })
if ($missingRepos.Count -eq 0) {
Add-Check -Name "source.repos" -Status "passed" -Detail "All discovered source repos are represented in the indexed source archive." -Data @{
discoveredRepoCount = [int]$sourceRepoPreview.discoveredRepoCount
indexedRepoCount = [int]$sourceRepoPreview.indexedRepoCount
selectedRepoCount = [int]$sourceRepoPreview.selectedRepoCount
}
} elseif ($qdrantReady -and $ragOllamaReady) {
$sourceRepoRepair = Invoke-SourceRepoReconcile -Detached
$launchedRepos = @($sourceRepoRepair.launchedRepos | ForEach-Object { [string]$_ })
Add-Check -Name "source.repos" -Status "warning" -Detail "Detected $($missingRepos.Count) unindexed source repos and launched detached reconciliation." -Data @{
missingRepos = $missingRepos
launchedRepos = $launchedRepos
discoveredRepoCount = [int]$sourceRepoPreview.discoveredRepoCount
indexedRepoCount = [int]$sourceRepoPreview.indexedRepoCount
}
} else {
Add-Check -Name "source.repos" -Status "failed" -Detail "Detected $($missingRepos.Count) unindexed source repos, but source indexing dependencies are unhealthy." -Data @{
missingRepos = $missingRepos
qdrantReady = $qdrantReady
ragOllamaReady = $ragOllamaReady
}
}
if ($staleIndexedRepos.Count -eq 0) {
Add-Check -Name "source.stale_index" -Status "passed" -Detail "Indexed source archive has no repo summaries for repos that are no longer present."
} else {
Add-Check -Name "source.stale_index" -Status "warning" -Detail "Indexed source archive still carries repo summaries for repos that are no longer present." -Data @{
staleIndexedRepos = $staleIndexedRepos
}
}
} catch {
Add-Check -Name "source.repos" -Status "failed" -Detail "Source repo reconciliation check failed: $($_.Exception.Message)" -Data @{
sourceRepoRoot = $sourceRepoRoot
}
}
}
if ($config.ContainsKey("DISCORD_BOT_TOKEN") -and -not [string]::IsNullOrWhiteSpace($config["DISCORD_BOT_TOKEN"])) {
Set-WatchdogStep -CurrentStep "discord_checks" -Detail "Validating Discord bot credentials."
try {

View file

@ -1,17 +1,9 @@
import "dotenv/config";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { loadConfig } from "@voidbot/config";
import {
crawlRepositoryDocuments,
createTextEmbedder,
createVectorStores,
FileSourceDocumentArchiveRepository,
SourceDocumentIngester,
SourceRagPipeline,
} from "@voidbot/rag";
import { discoverSourceRepos, selectSourceRepos } from "./source-repo-discovery";
import { indexSourceRepos } from "./source-repo-indexing";
interface ScriptOptions {
repos?: string[];
@ -31,67 +23,21 @@ async function main(): Promise<void> {
}
const repoNames =
options.repos && options.repos.length > 0
? options.repos
: await resolveRepoNames(config.sourceRepoRoot, config.sourceRepoPatterns);
await discoverSourceRepos(config.sourceRepoRoot, config.sourceRepoPatterns);
const repos = selectSourceRepos(repoNames, options.repos);
if (repoNames.length === 0) {
if (repos.length === 0) {
throw new Error("No source repositories matched SOURCE_REPO_PATTERNS.");
}
const embedder = createTextEmbedder({
backend: config.ragEmbeddingBackend,
hashDimensions: config.ragEmbeddingDimensions,
ollamaBaseUrl: config.ragOllamaBaseUrl,
ollamaModel: config.ragOllamaModel,
ollamaTimeoutMs: config.ragOllamaTimeoutMs,
queryInstruction: config.ragQueryInstruction,
const summary = await indexSourceRepos(config, repos, {
forceReindex: options.forceReindex,
logger: (line) => console.log(line),
});
const vectorStore = createVectorStores({
kind: config.vectorStore.kind,
historyPath: config.vectorStore.path,
sourceRoot: config.sourceVectorStoreRoot,
qdrant: config.qdrant,
historyEmbedder: embedder,
sourceEmbedder: embedder,
}).source;
const archiveRepository = new FileSourceDocumentArchiveRepository(config.ragSourceArchivePath);
const sourceIngester = new SourceDocumentIngester();
const pipeline = new SourceRagPipeline(archiveRepository, sourceIngester, vectorStore);
let indexedRepositories = 0;
let totalDocuments = 0;
let totalChunks = 0;
for (const repoName of repoNames) {
const repoPath = join(config.sourceRepoRoot, repoName);
const scan = await crawlRepositoryDocuments(repoPath, repoName, {
includePathPrefixes: config.sourceRepoIncludePrefixes[repoName],
});
const result = await pipeline.syncRepoDocuments(repoName, scan.documents, {
forceReindex: options.forceReindex,
});
indexedRepositories += 1;
totalDocuments += scan.documents.length;
totalChunks += result.indexedChunks;
console.log(
[
`Repo ${repoName}:`,
`documents=${scan.documents.length}`,
`created=${result.createdDocuments}`,
`updated=${result.updatedDocuments}`,
`unchanged=${result.unchangedDocuments}`,
`deleted=${result.deletedDocuments}`,
`indexedChunks=${result.indexedChunks}`,
`skippedFiles=${scan.skippedFiles.length}`,
].join(" "),
);
}
console.log(`Indexed repositories: ${indexedRepositories}`);
console.log(`Indexed documents: ${totalDocuments}`);
console.log(`Indexed chunks: ${totalChunks}`);
console.log(`Indexed repositories: ${summary.indexedRepositories}`);
console.log(`Indexed documents: ${summary.totalDocuments}`);
console.log(`Indexed chunks: ${summary.totalChunks}`);
}
function parseArgs(argv: string[]): ScriptOptions {
@ -117,23 +63,6 @@ function parseArgs(argv: string[]): ScriptOptions {
return options;
}
async function resolveRepoNames(root: string, patterns: string[]): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true });
const repoNames = entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => patterns.length === 0 || patterns.some((pattern) => matchesPattern(name, pattern)))
.sort((left, right) => left.localeCompare(right));
return repoNames;
}
function matchesPattern(value: string, pattern: string): boolean {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
const regex = new RegExp(`^${escaped}$`, "i");
return regex.test(value);
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;

View file

@ -1,11 +1,8 @@
import "dotenv/config";
import { readdir, chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { loadConfig } from "@voidbot/config";
const HOOK_MARKER = "# voidbot-source-index-push-hook";
import { discoverSourceRepos, selectSourceRepos } from "./source-repo-discovery";
import { installSourceIndexPushHooks } from "./source-repo-hooks";
interface ScriptOptions {
repos?: string[];
@ -23,77 +20,20 @@ async function main(): Promise<void> {
}
const options = parseArgs(process.argv.slice(2));
const repoNames =
options.repos && options.repos.length > 0
? options.repos
: await resolveRepoNames(config.sourceRepoRoot, config.sourceRepoPatterns);
const discoveredRepos = await discoverSourceRepos(config.sourceRepoRoot, config.sourceRepoPatterns);
const selectedRepos = selectSourceRepos(discoveredRepos, options.repos);
const summary = await installSourceIndexPushHooks(process.cwd(), selectedRepos);
let installed = 0;
let updated = 0;
let backedUp = 0;
let skipped = 0;
for (const repoName of repoNames) {
const repoPath = join(config.sourceRepoRoot, repoName);
const gitDir = await resolveGitDir(repoPath);
if (!gitDir) {
console.log(`Skipping ${repoName}: no .git directory found.`);
skipped += 1;
continue;
}
const hooksDir = join(gitDir, "hooks");
const hookPath = join(hooksDir, "pre-push");
const backupHookPath = join(hooksDir, "pre-push.voidbot.prev");
await mkdir(hooksDir, { recursive: true });
let existingContent: string | undefined;
try {
existingContent = await readFile(hookPath, "utf8");
} catch {
existingContent = undefined;
}
if (
existingContent &&
!existingContent.includes(HOOK_MARKER) &&
!(await pathExists(backupHookPath))
) {
await writeFile(backupHookPath, existingContent, "utf8");
await chmod(backupHookPath, 0o755);
backedUp += 1;
} else if (
existingContent &&
!existingContent.includes(HOOK_MARKER) &&
(await pathExists(backupHookPath))
) {
console.log(`Skipping ${repoName}: existing pre-push hook already has a Void backup; not clobbering it.`);
skipped += 1;
continue;
}
const hookScript = renderHookScript(process.cwd(), repoName);
await writeFile(hookPath, hookScript, "utf8");
await chmod(hookPath, 0o755);
if (existingContent?.includes(HOOK_MARKER)) {
updated += 1;
console.log(`Updated pre-push hook for ${repoName}.`);
} else {
installed += 1;
console.log(`Installed pre-push hook for ${repoName}.`);
}
for (const message of summary.messages) {
console.log(message);
}
console.log(
[
`installed=${installed}`,
`updated=${updated}`,
`backedUp=${backedUp}`,
`skipped=${skipped}`,
`installed=${summary.installed}`,
`updated=${summary.updated}`,
`backedUp=${summary.backedUp}`,
`skipped=${summary.skipped}`,
].join(" "),
);
}
@ -116,89 +56,6 @@ function parseArgs(argv: string[]): ScriptOptions {
return options;
}
async function resolveRepoNames(root: string, patterns: string[]): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => patterns.length === 0 || patterns.some((pattern) => matchesPattern(name, pattern)))
.sort((left, right) => left.localeCompare(right));
}
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 renderHookScript(voidbotRoot: string, repoName: string): string {
const normalizedRoot = voidbotRoot.replace(/\\/g, "/");
const launcherPath = `${normalizedRoot}/scripts/git-post-push-index.mjs`;
const nodePath = process.execPath.replace(/\\/g, "/");
const quotedNodePath = escapeForDoubleQuotedShell(nodePath);
const quotedLauncherPath = escapeForDoubleQuotedShell(launcherPath);
const quotedRoot = escapeForDoubleQuotedShell(normalizedRoot);
const quotedRepoName = escapeForDoubleQuotedShell(repoName);
return [
"#!/bin/sh",
HOOK_MARKER,
"HOOK_DIR=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\"",
"PREV_HOOK=\"$HOOK_DIR/pre-push.voidbot.prev\"",
"",
"if [ -x \"$PREV_HOOK\" ]; then",
" \"$PREV_HOOK\" \"$@\"",
" PREV_STATUS=$?",
" if [ \"$PREV_STATUS\" -ne 0 ]; then",
" exit \"$PREV_STATUS\"",
" fi",
"fi",
"",
`\"${quotedNodePath}\" \"${quotedLauncherPath}\" --voidbot-root \"${quotedRoot}\" --repo-name \"${quotedRepoName}\" >/dev/null 2>&1 || true`,
"",
"exit 0",
"",
].join("\n");
}
function escapeForDoubleQuotedShell(value: string): string {
return value.replace(/(["`$\\])/g, "\\$1");
}
function matchesPattern(value: string, pattern: string): boolean {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
const regex = new RegExp(`^${escaped}$`, "i");
return regex.test(value);
}
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;

View file

@ -0,0 +1,267 @@
import "dotenv/config";
import { spawn } from "node:child_process";
import { join } from "node:path";
import { loadConfig } from "@voidbot/config";
import { FileSourceDocumentArchiveRepository } 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[];
forceReindex?: boolean;
detached?: boolean;
json?: boolean;
checkOnly?: boolean;
}
interface ReconcileSummary {
discoveredRepoCount: number;
indexedRepoCount: number;
selectedRepoCount: number;
missingRepos: string[];
staleIndexedRepos: string[];
hookInstall: {
installed: number;
updated: number;
backedUp: number;
skipped: number;
};
prunedRepos: string[];
normalizedArchive: boolean;
detached: boolean;
forceReindex: boolean;
checkOnly: boolean;
indexedRepos: string[];
launchedRepos: string[];
}
async function main(): Promise<void> {
if (!process.env.DISCORD_OWNER_ID) {
process.env.DISCORD_OWNER_ID = "__source_reconcile__";
}
const options = parseArgs(process.argv.slice(2));
const config = loadConfig();
if (!config.sourceRepoRoot) {
throw new Error("SOURCE_REPO_ROOT is not configured.");
}
const discoveredRepos = await discoverSourceRepos(config.sourceRepoRoot, config.sourceRepoPatterns);
const selectedRepos = selectSourceRepos(discoveredRepos, options.repos);
if (selectedRepos.length === 0) {
throw new Error("No source repositories matched SOURCE_REPO_PATTERNS.");
}
const archiveRepository = new FileSourceDocumentArchiveRepository(config.ragSourceArchivePath);
if (!options.checkOnly) {
await archiveRepository.normalizeStore();
}
const indexedRepoSummaries = await archiveRepository.listRepoSummaries();
const indexedRepoNames = new Set(indexedRepoSummaries.map((summary) => summary.repoName));
const missingRepos = selectedRepos
.filter((repo) => options.forceReindex || !indexedRepoNames.has(repo.repoName))
.map((repo) => repo.repoName);
const staleIndexedRepos = indexedRepoSummaries
.map((summary) => summary.repoName)
.filter((repoName) => !discoveredRepos.some((repo) => repo.repoName === repoName))
.sort((left, right) => left.localeCompare(right));
let hookInstall = {
installed: 0,
updated: 0,
backedUp: 0,
skipped: 0,
};
if (!options.checkOnly) {
const hookSummary = await installSourceIndexPushHooks(process.cwd(), selectedRepos);
hookInstall = {
installed: hookSummary.installed,
updated: hookSummary.updated,
backedUp: hookSummary.backedUp,
skipped: hookSummary.skipped,
};
if (!options.json) {
for (const message of hookSummary.messages) {
console.log(message);
}
}
}
const reposToIndex = selectedRepos.filter((repo) => missingRepos.includes(repo.repoName));
const indexedRepos: string[] = [];
const launchedRepos: string[] = [];
const prunedRepos: string[] = [];
if (!options.checkOnly && reposToIndex.length > 0) {
if (options.detached) {
for (const repo of reposToIndex) {
await launchDetachedRepoIndex(process.cwd(), repo.repoName);
launchedRepos.push(repo.repoName);
}
} else {
const runSummary = await indexSourceRepos(config, reposToIndex, {
forceReindex: options.forceReindex,
logger: options.json ? undefined : (line) => console.log(line),
});
indexedRepos.push(...runSummary.results.map((result) => result.repoName));
if (!options.json) {
console.log(`Indexed repositories: ${runSummary.indexedRepositories}`);
console.log(`Indexed documents: ${runSummary.totalDocuments}`);
console.log(`Indexed chunks: ${runSummary.totalChunks}`);
}
}
}
if (!options.checkOnly && staleIndexedRepos.length > 0) {
const sourceVectorStore = createSourceVectorStore(config);
for (const repoName of staleIndexedRepos) {
await sourceVectorStore.deleteByFilters({
corpusKind: "repository_source",
repoName,
});
await archiveRepository.removeRepo(repoName);
prunedRepos.push(repoName);
}
}
const summary: ReconcileSummary = {
discoveredRepoCount: discoveredRepos.length,
indexedRepoCount: indexedRepoSummaries.length,
selectedRepoCount: selectedRepos.length,
missingRepos,
staleIndexedRepos,
hookInstall,
prunedRepos,
normalizedArchive: !options.checkOnly,
detached: options.detached,
forceReindex: options.forceReindex,
checkOnly: options.checkOnly,
indexedRepos,
launchedRepos,
};
if (options.json) {
process.stdout.write(`${JSON.stringify(summary)}\n`);
return;
}
console.log(
[
`discoveredRepos=${summary.discoveredRepoCount}`,
`indexedRepos=${summary.indexedRepoCount}`,
`selectedRepos=${summary.selectedRepoCount}`,
`missingRepos=${summary.missingRepos.length}`,
`staleIndexedRepos=${summary.staleIndexedRepos.length}`,
`installedHooks=${summary.hookInstall.installed}`,
`updatedHooks=${summary.hookInstall.updated}`,
`backedUpHooks=${summary.hookInstall.backedUp}`,
`skippedHooks=${summary.hookInstall.skipped}`,
`prunedStaleRepos=${summary.prunedRepos.length}`,
`indexedNow=${summary.indexedRepos.length}`,
`launchedDetached=${summary.launchedRepos.length}`,
].join(" "),
);
}
function parseArgs(argv: string[]): ScriptOptions {
const options: ScriptOptions = {
forceReindex: false,
detached: false,
json: false,
checkOnly: false,
};
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;
continue;
}
if (argument === "--detached") {
options.detached = true;
continue;
}
if (argument === "--json") {
options.json = true;
continue;
}
if (argument === "--check") {
options.checkOnly = true;
}
}
return options;
}
async function launchDetachedRepoIndex(voidbotRoot: string, repoName: string): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const child = spawn(
process.execPath,
[
join(voidbotRoot, "scripts", "git-post-push-index.mjs"),
"--voidbot-root",
voidbotRoot,
"--repo-name",
repoName,
],
{
cwd: voidbotRoot,
env: process.env,
stdio: ["ignore", "ignore", "pipe"],
windowsHide: true,
},
);
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", rejectPromise);
child.on("close", (code) => {
if ((code ?? 1) !== 0) {
rejectPromise(
new Error(
stderr.trim().length > 0
? stderr.trim()
: `Detached source-index launcher failed for ${repoName}.`,
),
);
return;
}
resolvePromise();
});
});
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -0,0 +1,109 @@
import { readdir, readFile, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
export interface SourceRepoMatch {
repoName: 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,
repoPath,
gitDir,
});
}
repoMatches.sort((left, right) => left.repoName.localeCompare(right.repoName));
return repoMatches;
}
export function selectSourceRepos(
availableRepos: SourceRepoMatch[],
requestedRepoNames?: string[],
): SourceRepoMatch[] {
if (!requestedRepoNames || requestedRepoNames.length === 0) {
return availableRepos;
}
const lookup = new Map(
availableRepos.map((repo) => [repo.repoName.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

@ -0,0 +1,124 @@
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { type SourceRepoMatch } from "./source-repo-discovery";
export const HOOK_MARKER = "# voidbot-source-index-push-hook";
export interface SourceHookInstallSummary {
installed: number;
updated: number;
backedUp: number;
skipped: number;
messages: string[];
}
export async function installSourceIndexPushHooks(
voidbotRoot: string,
repos: SourceRepoMatch[],
): Promise<SourceHookInstallSummary> {
let installed = 0;
let updated = 0;
let backedUp = 0;
let skipped = 0;
const messages: string[] = [];
for (const repo of repos) {
const hooksDir = join(repo.gitDir, "hooks");
const hookPath = join(hooksDir, "pre-push");
const backupHookPath = join(hooksDir, "pre-push.voidbot.prev");
await mkdir(hooksDir, { recursive: true });
let existingContent: string | undefined;
try {
existingContent = await readFile(hookPath, "utf8");
} catch {
existingContent = undefined;
}
if (
existingContent &&
!existingContent.includes(HOOK_MARKER) &&
!(await pathExists(backupHookPath))
) {
await writeFile(backupHookPath, existingContent, "utf8");
await chmod(backupHookPath, 0o755);
backedUp += 1;
} else if (
existingContent &&
!existingContent.includes(HOOK_MARKER) &&
(await pathExists(backupHookPath))
) {
skipped += 1;
messages.push(
`Skipping ${repo.repoName}: existing pre-push hook already has a Void backup; not clobbering it.`,
);
continue;
}
const hookScript = renderHookScript(voidbotRoot, repo.repoName);
await writeFile(hookPath, hookScript, "utf8");
await chmod(hookPath, 0o755);
if (existingContent?.includes(HOOK_MARKER)) {
updated += 1;
messages.push(`Updated pre-push hook for ${repo.repoName}.`);
} else {
installed += 1;
messages.push(`Installed pre-push hook for ${repo.repoName}.`);
}
}
return {
installed,
updated,
backedUp,
skipped,
messages,
};
}
function renderHookScript(voidbotRoot: string, repoName: string): string {
const normalizedRoot = voidbotRoot.replace(/\\/g, "/");
const launcherPath = `${normalizedRoot}/scripts/git-post-push-index.mjs`;
const nodePath = process.execPath.replace(/\\/g, "/");
const quotedNodePath = escapeForDoubleQuotedShell(nodePath);
const quotedLauncherPath = escapeForDoubleQuotedShell(launcherPath);
const quotedRoot = escapeForDoubleQuotedShell(normalizedRoot);
const quotedRepoName = escapeForDoubleQuotedShell(repoName);
return [
"#!/bin/sh",
HOOK_MARKER,
"HOOK_DIR=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\"",
"PREV_HOOK=\"$HOOK_DIR/pre-push.voidbot.prev\"",
"",
"if [ -x \"$PREV_HOOK\" ]; then",
" \"$PREV_HOOK\" \"$@\"",
" PREV_STATUS=$?",
" if [ \"$PREV_STATUS\" -ne 0 ]; then",
" exit \"$PREV_STATUS\"",
" fi",
"fi",
"",
`\"${quotedNodePath}\" \"${quotedLauncherPath}\" --voidbot-root \"${quotedRoot}\" --repo-name \"${quotedRepoName}\" >/dev/null 2>&1 || true`,
"",
"exit 0",
"",
].join("\n");
}
function escapeForDoubleQuotedShell(value: string): string {
return value.replace(/(["`$\\])/g, "\\$1");
}
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}

View file

@ -0,0 +1,111 @@
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,
sourceRoot: config.sourceVectorStoreRoot,
qdrant: config.qdrant,
historyEmbedder: embedder,
sourceEmbedder: 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],
});
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,
};
}

View file

@ -532,6 +532,16 @@ if ($LASTEXITCODE -ne 0) {
Stop-StaleRuntimeProcesses -RepoRoot $repoRoot
$status.stage = "source_reconcile"
Write-StatusFile -Path $statusPath -Status $status
Write-Host "Reconciling source repos..."
& $npmCommand.Source run rag:reconcile-sources
if ($LASTEXITCODE -ne 0) {
throw "npm run rag:reconcile-sources failed."
}
$botOut = Join-Path $logDir "bot.log"
$botErr = Join-Path $logDir "bot.err.log"
$workerOut = Join-Path $logDir "worker.log"