mirror of
https://github.com/PawanOsman/ChatGPT.git
synced 2026-07-18 08:05:57 +02:00
d47e978be7
- Updated the `semanticIndex` module to support GPU acceleration with ONNX Runtime, improving performance on compatible devices. - Introduced new functions to determine preferred embedding devices and handle device status, enhancing the flexibility of the embedding process. - Expanded the `IndexStatus` interface to include backend type, device information, and runtime details for better tracking of indexing status. - Enhanced the UI in the settings panel to display runtime and device information, providing users with clearer insights into the indexing process and model usage.
651 lines
23 KiB
TypeScript
651 lines
23 KiB
TypeScript
/*
|
|
* Copyright (c) 2026 Pawan Osman <https://github.com/PawanOsman>
|
|
*
|
|
* This file is part of OpenCursor — AI coding agent chat inside VS Code.
|
|
* https://github.com/PawanOsman/OpenCursor
|
|
*
|
|
* Licensed under the MIT License. See LICENSE file in the project root.
|
|
*/
|
|
|
|
// Real local semantic codebase index.
|
|
// - Embeddings: @huggingface/transformers + onnxruntime-node
|
|
// (Xenova/all-MiniLM-L6-v2, q8). GPU when available (DML/CUDA/CoreML/WebGPU),
|
|
// else CPU. 100% local; model downloads once to globalStorage.
|
|
// - Chunking: sliding line-window per file (simple, language-agnostic).
|
|
// ponytail: line-window chunking; upgrade to tree-sitter AST chunks when
|
|
// ranking quality on large funcs matters.
|
|
// - Store: plain JSON in globalStorage + in-memory cosine top-k.
|
|
// ponytail: O(n) cosine scan; swap for sqlite-vec/HNSW when repo > ~50k chunks.
|
|
// - Incremental: per-file mtime hash skips unchanged files on re-index.
|
|
|
|
import * as fs from "fs/promises";
|
|
import * as path from "path";
|
|
import * as crypto from "crypto";
|
|
import { walk } from "./tools/shared";
|
|
import { importRuntimeDep } from "../runtimeDeps";
|
|
|
|
// Selectable local embedding models. Add entries here to offer more choices.
|
|
export interface EmbedModel {
|
|
id: string;
|
|
name: string;
|
|
repo: string;
|
|
dtype: "fp32" | "fp16" | "q8";
|
|
pooling: "mean" | "last_token";
|
|
dim: number;
|
|
}
|
|
export const EMBED_MODELS: EmbedModel[] = [
|
|
{ id: "minilm", name: "all-MiniLM-L6-v2 (fast, default)", repo: "Xenova/all-MiniLM-L6-v2", dtype: "q8", pooling: "mean", dim: 384 },
|
|
];
|
|
|
|
let activeModel: EmbedModel = EMBED_MODELS[0];
|
|
|
|
/** Remote (provider API) embedding model, e.g. OpenAI text-embedding-3-small. */
|
|
export interface RemoteEmbedConfig {
|
|
id: string;
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
}
|
|
let remoteCfg: RemoteEmbedConfig | null = null;
|
|
|
|
export function getEmbedModelId(): string {
|
|
return remoteCfg ? remoteCfg.id : activeModel.id;
|
|
}
|
|
|
|
/** Switch to a LOCAL embedding model. Invalidates the loaded extractor (re-index needed). */
|
|
export function setEmbedModel(id: string): void {
|
|
const m = EMBED_MODELS.find((x) => x.id === id);
|
|
if (!m) return;
|
|
const changed = remoteCfg !== null || m.id !== activeModel.id;
|
|
remoteCfg = null;
|
|
if (m.id !== activeModel.id) {
|
|
activeModel = m;
|
|
extractorP = null; // force reload with new repo/dtype
|
|
embedDevice = null;
|
|
}
|
|
if (changed) {
|
|
memIndex = null; // index built with old model is stale
|
|
memRoot = null;
|
|
}
|
|
}
|
|
|
|
/** Switch to a REMOTE (provider) embedding model via the OpenAI-compatible /embeddings API. */
|
|
export function setRemoteEmbedModel(cfg: RemoteEmbedConfig): void {
|
|
if (remoteCfg?.id === cfg.id && remoteCfg.baseUrl === cfg.baseUrl) {
|
|
remoteCfg = cfg; // refresh key silently
|
|
return;
|
|
}
|
|
remoteCfg = cfg;
|
|
memIndex = null;
|
|
memRoot = null;
|
|
}
|
|
|
|
const CHUNK_LINES = 40;
|
|
const CHUNK_OVERLAP = 10;
|
|
/** Source / doc extensions worth embedding. No binaries, lockfiles, or assets. */
|
|
const EMBED_EXTS = new Set([
|
|
".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs",
|
|
".py", ".pyi", ".rs", ".go", ".java", ".kt", ".kts", ".scala",
|
|
".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".cs",
|
|
".rb", ".php", ".swift", ".m", ".mm",
|
|
".vue", ".svelte", ".astro",
|
|
".css", ".scss", ".less", ".sass",
|
|
".html", ".htm", ".sql", ".graphql", ".gql",
|
|
".md", ".mdx", ".rst", ".txt",
|
|
".sh", ".bash", ".zsh", ".ps1", ".bat", ".cmd",
|
|
".toml", ".yaml", ".yml", ".ini", ".cfg", ".conf",
|
|
".json", ".jsonc",
|
|
".proto", ".thrift", ".r", ".lua", ".ex", ".exs", ".erl", ".hs", ".clj", ".cljs",
|
|
".zig", ".nim", ".dart", ".tf", ".hcl",
|
|
]);
|
|
/** Path segment names to never index (modules, build, caches, VCS). */
|
|
const SKIP_DIR_SEGMENTS = new Set([
|
|
"node_modules", ".git", "dist", "out", "build", ".next", ".nuxt", ".output",
|
|
".turbo", ".cache", "coverage", ".venv", "venv", "__pycache__", ".tox",
|
|
".mypy_cache", ".pytest_cache", ".ruff_cache", "target", "vendor", "Pods",
|
|
".gradle", ".idea", ".vscode", "bower_components", "jspm_packages",
|
|
".pnpm-store", ".yarn", "site-packages", ".svn", ".hg", ".hgcheck",
|
|
"DerivedData", "xcuserdata", ".terraform", ".serverless", ".parcel-cache",
|
|
".svelte-kit", ".angular", "storybook-static", "cypress", "playwright-report",
|
|
"test-results", ".nyc_output", "htmlcov",
|
|
]);
|
|
/** Exact basenames that are never source to embed. */
|
|
const SKIP_BASENAMES = new Set([
|
|
"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock",
|
|
"composer.lock", "Cargo.lock", "Gemfile.lock", "poetry.lock", "Pipfile.lock",
|
|
"go.sum", "flake.lock", "uv.lock",
|
|
".DS_Store", "Thumbs.db", "desktop.ini",
|
|
"LICENSE", "LICENSE.txt", "LICENSE.md", "COPYING", "CHANGELOG.md", "CHANGELOG",
|
|
"package-lock.json",
|
|
]);
|
|
const MAX_FILE_BYTES = 512 * 1024;
|
|
|
|
export interface Chunk {
|
|
path: string; // workspace-relative, posix
|
|
start: number; // 1-based
|
|
end: number;
|
|
text: string;
|
|
vec: number[]; // normalized, length = model dim
|
|
}
|
|
interface IndexFile {
|
|
model: string; // EmbedModel.id the vectors were built with
|
|
files: Record<string, string>; // relPath -> mtime+size hash
|
|
chunks: Chunk[];
|
|
}
|
|
|
|
let storageDir: string | undefined;
|
|
export function setIndexStorageDir(dir: string): void {
|
|
storageDir = dir;
|
|
}
|
|
|
|
// ---- Embedder (lazy, singleton) ----
|
|
// ONNX Runtime Node EPs: Win→dml, Linux x64→cuda, macOS→coreml, then webgpu, then cpu.
|
|
// transformers.js defaults to CPU only; we try GPU when available and fall back.
|
|
function preferredEmbedDevices(): string[] {
|
|
const order: string[] = [];
|
|
switch (process.platform) {
|
|
case "win32":
|
|
order.push("dml"); // DirectML (any DX12 GPU)
|
|
break;
|
|
case "linux":
|
|
if (process.arch === "x64") order.push("cuda"); // needs CUDA 12 + cuDNN
|
|
break;
|
|
case "darwin":
|
|
order.push("coreml");
|
|
break;
|
|
}
|
|
order.push("webgpu", "cpu");
|
|
return order;
|
|
}
|
|
|
|
let extractorP: Promise<any> | null = null;
|
|
let embedDevice: string | null = null;
|
|
|
|
/** Device the live embedder is using (null until first load). */
|
|
export function getEmbedDevice(): string | null {
|
|
return embedDevice;
|
|
}
|
|
|
|
async function getExtractor(): Promise<any | null> {
|
|
if (!storageDir) return null;
|
|
if (!extractorP) {
|
|
const m = activeModel;
|
|
extractorP = (async () => {
|
|
const t = await importRuntimeDep("@huggingface/transformers");
|
|
t.env.allowRemoteModels = true;
|
|
t.env.cacheDir = path.join(storageDir!, "models");
|
|
// Try GPU EPs first; ORT often fails hard if a provider's libs are missing,
|
|
// so probe one device at a time instead of device:"auto".
|
|
const devices = preferredEmbedDevices();
|
|
let lastErr: unknown;
|
|
for (const device of devices) {
|
|
try {
|
|
const pipe = await t.pipeline("feature-extraction", m.repo, {
|
|
dtype: m.dtype,
|
|
device,
|
|
});
|
|
embedDevice = device;
|
|
console.log(`[semanticIndex] embedder on ${device} (${m.id})`);
|
|
// UI may already be open; push device once the pipeline is ready.
|
|
if (memRoot) emitStatus(memRoot);
|
|
return pipe;
|
|
} catch (e) {
|
|
lastErr = e;
|
|
console.warn(`[semanticIndex] embedder device "${device}" unavailable, trying next…`);
|
|
}
|
|
}
|
|
throw lastErr ?? new Error("no embedder device available");
|
|
})().catch((e) => {
|
|
console.error("[semanticIndex] embedder load failed:", e);
|
|
extractorP = null;
|
|
embedDevice = null;
|
|
return null;
|
|
});
|
|
}
|
|
return extractorP;
|
|
}
|
|
|
|
/** Embed via a provider's OpenAI-compatible /embeddings endpoint. */
|
|
async function embedRemote(texts: string[]): Promise<number[][] | null> {
|
|
if (!remoteCfg) return null;
|
|
try {
|
|
const res = await fetch(`${remoteCfg.baseUrl.replace(/\/$/, "")}/embeddings`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", authorization: `Bearer ${remoteCfg.apiKey}` },
|
|
body: JSON.stringify({ model: remoteCfg.id, input: texts }),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
const json: any = await res.json();
|
|
const vecs: number[][] = json.data.sort((a: any, b: any) => a.index - b.index).map((d: any) => d.embedding);
|
|
// Normalize (cosine expects unit vectors; some APIs don't normalize).
|
|
return vecs.map((v) => {
|
|
let n = 0;
|
|
for (const x of v) n += x * x;
|
|
n = Math.sqrt(n) || 1;
|
|
return v.map((x) => x / n);
|
|
});
|
|
} catch (e) {
|
|
console.error("[semanticIndex] remote embed failed:", e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function embed(texts: string[]): Promise<number[][] | null> {
|
|
if (remoteCfg) return embedRemote(texts);
|
|
const ex = await getExtractor();
|
|
if (!ex) return null;
|
|
const out = await ex(texts, { pooling: activeModel.pooling, normalize: true });
|
|
const data = out.tolist ? out.tolist() : out;
|
|
return data as number[][];
|
|
}
|
|
|
|
export async function embedQuery(q: string): Promise<number[] | null> {
|
|
const r = await embed([q]);
|
|
return r ? r[0] : null;
|
|
}
|
|
|
|
/** Batch-embed arbitrary texts with the active local model (docs index reuses this). */
|
|
export async function embedTexts(texts: string[]): Promise<number[][] | null> {
|
|
return embed(texts);
|
|
}
|
|
|
|
// ---- Index lifecycle ----
|
|
/** Stable workspace key so Windows drive-letter case / trailing slashes don't orphan the index. */
|
|
function normRoot(root: string): string {
|
|
const r = path.resolve(root);
|
|
return process.platform === "win32" ? r.toLowerCase() : r;
|
|
}
|
|
|
|
function indexPath(root: string): string {
|
|
const id = crypto.createHash("sha1").update(normRoot(root)).digest("hex").slice(0, 16);
|
|
const mid = getEmbedModelId().replace(/[^\w.-]+/g, "_");
|
|
return path.join(storageDir!, `index-${id}-${mid}.json`);
|
|
}
|
|
|
|
let memIndex: IndexFile | null = null;
|
|
let memRoot: string | null = null; // normRoot key
|
|
let indexingEnabled = true;
|
|
|
|
export function setIndexingEnabled(on: boolean): void {
|
|
indexingEnabled = on;
|
|
}
|
|
|
|
export function isIndexingEnabled(): boolean {
|
|
return indexingEnabled;
|
|
}
|
|
|
|
async function load(root: string): Promise<IndexFile> {
|
|
const key = normRoot(root);
|
|
if (memIndex && memRoot === key) return memIndex;
|
|
try {
|
|
const raw = await fs.readFile(indexPath(root), "utf8");
|
|
const parsed = JSON.parse(raw) as IndexFile;
|
|
if (parsed.model === getEmbedModelId() && parsed.files && Array.isArray(parsed.chunks)) {
|
|
memIndex = parsed;
|
|
memRoot = key;
|
|
return parsed;
|
|
}
|
|
} catch {}
|
|
memIndex = { model: getEmbedModelId(), files: {}, chunks: [] };
|
|
memRoot = key;
|
|
return memIndex;
|
|
}
|
|
|
|
async function save(root: string, idx: IndexFile): Promise<void> {
|
|
if (!storageDir) return;
|
|
await fs.mkdir(storageDir, { recursive: true });
|
|
await fs.writeFile(indexPath(root), JSON.stringify(idx), "utf8");
|
|
memIndex = idx;
|
|
memRoot = normRoot(root);
|
|
}
|
|
|
|
/** Load persisted index into memory (no embed work). Call on activate so status/UI show prior work. */
|
|
export async function warmIndex(root: string): Promise<IndexStatus> {
|
|
if (!storageDir || !root) return getStatus(root);
|
|
await load(root);
|
|
emitStatus(root);
|
|
return getStatus(root);
|
|
}
|
|
|
|
function chunkFile(rel: string, text: string): { start: number; end: number; text: string }[] {
|
|
const lines = text.split("\n");
|
|
const out: { start: number; end: number; text: string }[] = [];
|
|
for (let i = 0; i < lines.length; i += CHUNK_LINES - CHUNK_OVERLAP) {
|
|
const slice = lines.slice(i, i + CHUNK_LINES);
|
|
const body = slice.join("\n").trim();
|
|
if (!body) continue;
|
|
out.push({ start: i + 1, end: Math.min(i + CHUNK_LINES, lines.length), text: body });
|
|
if (i + CHUNK_LINES >= lines.length) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
let indexing = false;
|
|
export function isIndexing(): boolean {
|
|
return indexing;
|
|
}
|
|
|
|
// Progress reported to UI subscribers (e.g. settings panel).
|
|
export interface IndexStatus {
|
|
indexing: boolean;
|
|
done: number;
|
|
total: number;
|
|
files: number; // indexed files in store
|
|
chunks: number;
|
|
model: string; // active EmbedModel.id or remote model id
|
|
/** "local" = onnxruntime-node; "remote" = provider /embeddings API. */
|
|
backend: "local" | "remote";
|
|
/** ONNX EP in use: dml | cuda | coreml | webgpu | cpu. Null until first load / remote. */
|
|
device: string | null;
|
|
/** GPU-class EP vs CPU (remote counts as neither — uses provider). */
|
|
accelerator: "gpu" | "cpu" | "remote" | "pending";
|
|
/** Human-readable device label for the UI. */
|
|
deviceLabel: string;
|
|
/** Active local model technical fields (undefined when remote). */
|
|
modelRepo?: string;
|
|
modelDtype?: string;
|
|
modelPooling?: string;
|
|
modelDim?: number;
|
|
/** Remote endpoint host when using a provider embedding model. */
|
|
remoteBaseUrl?: string;
|
|
runtime: string;
|
|
platform: string;
|
|
}
|
|
let progress = { done: 0, total: 0 };
|
|
const statusSubs = new Set<(s: IndexStatus) => void>();
|
|
export function onIndexStatus(fn: (s: IndexStatus) => void): () => void {
|
|
statusSubs.add(fn);
|
|
return () => statusSubs.delete(fn);
|
|
}
|
|
|
|
function deviceLabelOf(device: string | null, backend: "local" | "remote"): string {
|
|
if (backend === "remote") return "Remote API";
|
|
if (!device) return "Not loaded yet";
|
|
switch (device) {
|
|
case "dml":
|
|
return "GPU · DirectML";
|
|
case "cuda":
|
|
return "GPU · CUDA";
|
|
case "coreml":
|
|
return "GPU · CoreML";
|
|
case "webgpu":
|
|
return "GPU · WebGPU";
|
|
case "cpu":
|
|
return "CPU";
|
|
default:
|
|
return device;
|
|
}
|
|
}
|
|
|
|
function acceleratorOf(device: string | null, backend: "local" | "remote"): IndexStatus["accelerator"] {
|
|
if (backend === "remote") return "remote";
|
|
if (!device) return "pending";
|
|
return device === "cpu" ? "cpu" : "gpu";
|
|
}
|
|
|
|
export function getStatus(root: string): IndexStatus {
|
|
const idx = memRoot === normRoot(root) ? memIndex : null;
|
|
const backend: "local" | "remote" = remoteCfg ? "remote" : "local";
|
|
const device = backend === "remote" ? null : embedDevice;
|
|
return {
|
|
indexing,
|
|
done: progress.done,
|
|
total: progress.total,
|
|
files: idx ? Object.keys(idx.files).length : 0,
|
|
chunks: idx ? idx.chunks.length : 0,
|
|
model: getEmbedModelId(),
|
|
backend,
|
|
device,
|
|
accelerator: acceleratorOf(device, backend),
|
|
deviceLabel: deviceLabelOf(device, backend),
|
|
modelRepo: backend === "local" ? activeModel.repo : undefined,
|
|
modelDtype: backend === "local" ? activeModel.dtype : undefined,
|
|
modelPooling: backend === "local" ? activeModel.pooling : undefined,
|
|
modelDim: backend === "local" ? activeModel.dim : undefined,
|
|
remoteBaseUrl: remoteCfg?.baseUrl,
|
|
runtime: backend === "local" ? "onnxruntime-node + @huggingface/transformers" : "OpenAI-compatible /embeddings",
|
|
platform: `${process.platform}-${process.arch}`,
|
|
};
|
|
}
|
|
function emitStatus(root: string): void {
|
|
const s = getStatus(root);
|
|
for (const fn of statusSubs) fn(s);
|
|
}
|
|
|
|
/** Delete the persisted index for a workspace. */
|
|
export async function deleteIndex(root: string): Promise<void> {
|
|
memIndex = { model: getEmbedModelId(), files: {}, chunks: [] };
|
|
memRoot = normRoot(root);
|
|
progress = { done: 0, total: 0 };
|
|
try { await fs.unlink(indexPath(root)); } catch {}
|
|
emitStatus(root);
|
|
}
|
|
|
|
/** True when a workspace-relative path is real source (not vendor/build/junk). */
|
|
function isIndexableRel(rel: string): boolean {
|
|
if (!rel || rel.startsWith("..")) return false;
|
|
const parts = rel.split("/");
|
|
const base = parts[parts.length - 1] || "";
|
|
// Hidden files at any depth except common source dots (.env.example etc. skipped too).
|
|
if (base.startsWith(".") && base !== ".gitignore" && base !== ".editorconfig") return false;
|
|
for (const seg of parts) {
|
|
if (!seg) continue;
|
|
if (SKIP_DIR_SEGMENTS.has(seg)) return false;
|
|
// Nested deps / generated trees often use these prefixes.
|
|
if (seg.startsWith(".") && (seg === ".git" || seg.endsWith("_cache") || seg.endsWith("-cache"))) return false;
|
|
}
|
|
if (SKIP_BASENAMES.has(base)) return false;
|
|
// Minified / bundled / source maps (not author source).
|
|
if (/\.(min|bundle|chunk)\.(js|css|mjs|cjs)$/i.test(base)) return false;
|
|
if (/\.map$/i.test(base)) return false;
|
|
if (/\.(png|jpe?g|gif|webp|ico|svg|woff2?|ttf|eot|mp[34]|wav|zip|gz|tgz|7z|rar|pdf|wasm|exe|dll|so|dylib|bin|o|a|class|jar|war|ear|pyc|pyo|whl|lock)$/i.test(base)) {
|
|
return false;
|
|
}
|
|
const ext = path.extname(base).toLowerCase();
|
|
if (!EMBED_EXTS.has(ext)) return false;
|
|
// package.json etc. OK; skip huge generated JSON dumps by name pattern.
|
|
if (ext === ".json" && /(^|[-_.])(lock|bundle|manifest|sourcemap)([-_.]|$)/i.test(base)) return false;
|
|
return true;
|
|
}
|
|
|
|
async function embedFileInto(idx: IndexFile, root: string, rel: string): Promise<boolean> {
|
|
const abs = path.join(root, rel);
|
|
let st;
|
|
try { st = await fs.stat(abs); } catch { return false; }
|
|
if (st.size > MAX_FILE_BYTES) return false;
|
|
let text: string;
|
|
try { text = await fs.readFile(abs, "utf8"); } catch { return false; }
|
|
// Drop old chunks for this path first.
|
|
idx.chunks = idx.chunks.filter((c) => c.path !== rel);
|
|
const pieces = chunkFile(rel, text);
|
|
if (pieces.length) {
|
|
const vecs = await embed(pieces.map((p) => p.text));
|
|
if (vecs) {
|
|
pieces.forEach((p, i) => idx.chunks.push({ path: rel, start: p.start, end: p.end, text: p.text, vec: vecs[i] }));
|
|
}
|
|
}
|
|
idx.files[rel] = `${Math.round(st.mtimeMs)}:${st.size}`;
|
|
return true;
|
|
}
|
|
|
|
/** Pending single-file updates while a full build runs (or coalesced watcher queue). */
|
|
const pendingUpserts = new Map<string, Set<string>>(); // rootKey -> rel paths
|
|
const pendingDeletes = new Map<string, Set<string>>();
|
|
let drainRunning = false;
|
|
|
|
function queueKey(root: string): string {
|
|
return normRoot(root);
|
|
}
|
|
|
|
/** Index/update one file immediately (or queue if full build busy). */
|
|
export async function upsertFile(root: string, absOrRel: string): Promise<void> {
|
|
if (!storageDir || !indexingEnabled || !root) return;
|
|
const abs = path.isAbsolute(absOrRel) ? absOrRel : path.join(root, absOrRel);
|
|
const rel = path.relative(root, abs).split(path.sep).join("/");
|
|
if (!rel || rel.startsWith("..") || !isIndexableRel(rel)) return;
|
|
const key = queueKey(root);
|
|
if (indexing || drainRunning) {
|
|
if (!pendingUpserts.has(key)) pendingUpserts.set(key, new Set());
|
|
pendingUpserts.get(key)!.add(rel);
|
|
pendingDeletes.get(key)?.delete(rel);
|
|
return;
|
|
}
|
|
const idx = await load(root);
|
|
let st;
|
|
try { st = await fs.stat(abs); } catch {
|
|
await removeFile(root, abs);
|
|
return;
|
|
}
|
|
const hash = `${Math.round(st.mtimeMs)}:${st.size}`;
|
|
if (idx.files[rel] === hash || idx.files[rel] === `${st.mtimeMs}:${st.size}`) return;
|
|
await embedFileInto(idx, root, rel);
|
|
await save(root, idx);
|
|
emitStatus(root);
|
|
}
|
|
|
|
/** Remove a file from the index (delete/rename). */
|
|
export async function removeFile(root: string, absOrRel: string): Promise<void> {
|
|
if (!storageDir || !indexingEnabled || !root) return;
|
|
const abs = path.isAbsolute(absOrRel) ? absOrRel : path.join(root, absOrRel);
|
|
const rel = path.relative(root, abs).split(path.sep).join("/");
|
|
if (!rel || rel.startsWith("..")) return;
|
|
const key = queueKey(root);
|
|
if (indexing || drainRunning) {
|
|
if (!pendingDeletes.has(key)) pendingDeletes.set(key, new Set());
|
|
pendingDeletes.get(key)!.add(rel);
|
|
pendingUpserts.get(key)?.delete(rel);
|
|
return;
|
|
}
|
|
const idx = await load(root);
|
|
if (!idx.files[rel] && !idx.chunks.some((c) => c.path === rel)) return;
|
|
idx.chunks = idx.chunks.filter((c) => c.path !== rel);
|
|
delete idx.files[rel];
|
|
await save(root, idx);
|
|
emitStatus(root);
|
|
}
|
|
|
|
async function drainPending(root: string): Promise<void> {
|
|
if (drainRunning || indexing || !indexingEnabled) return;
|
|
const key = queueKey(root);
|
|
const ups = pendingUpserts.get(key);
|
|
const dels = pendingDeletes.get(key);
|
|
if ((!ups || !ups.size) && (!dels || !dels.size)) return;
|
|
drainRunning = true;
|
|
try {
|
|
const idx = await load(root);
|
|
if (dels?.size) {
|
|
for (const rel of dels) {
|
|
idx.chunks = idx.chunks.filter((c) => c.path !== rel);
|
|
delete idx.files[rel];
|
|
}
|
|
dels.clear();
|
|
}
|
|
if (ups?.size) {
|
|
const list = [...ups];
|
|
ups.clear();
|
|
progress = { done: 0, total: list.length };
|
|
indexing = true;
|
|
emitStatus(root);
|
|
let done = 0;
|
|
for (const rel of list) {
|
|
await embedFileInto(idx, root, rel);
|
|
done++;
|
|
progress = { done, total: list.length };
|
|
emitStatus(root);
|
|
}
|
|
indexing = false;
|
|
}
|
|
await save(root, idx);
|
|
emitStatus(root);
|
|
} finally {
|
|
drainRunning = false;
|
|
indexing = false;
|
|
// More events may have arrived.
|
|
if ((pendingUpserts.get(key)?.size || 0) + (pendingDeletes.get(key)?.size || 0) > 0) {
|
|
void drainPending(root);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** (Re)build the index incrementally. Only re-embeds changed files. No-op if disabled/busy. */
|
|
export async function buildIndex(root: string, onProgress?: (done: number, total: number) => void): Promise<void> {
|
|
if (!storageDir || !root || indexing || !indexingEnabled) return;
|
|
indexing = true;
|
|
progress = { done: 0, total: 0 };
|
|
emitStatus(root);
|
|
try {
|
|
const idx = await load(root);
|
|
const all: string[] = [];
|
|
await walk(root, all, 0);
|
|
const targets: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const f of all) {
|
|
const rel = path.relative(root, f).split(path.sep).join("/");
|
|
if (!isIndexableRel(rel)) continue;
|
|
let st;
|
|
try { st = await fs.stat(f); } catch { continue; }
|
|
if (st.size > MAX_FILE_BYTES) continue;
|
|
seen.add(rel);
|
|
const hash = `${Math.round(st.mtimeMs)}:${st.size}`;
|
|
const prev = idx.files[rel];
|
|
// Accept either rounded or raw mtime strings from older indexes.
|
|
if (prev !== hash && prev !== `${st.mtimeMs}:${st.size}`) targets.push(rel);
|
|
}
|
|
const changed = new Set(targets);
|
|
idx.chunks = idx.chunks.filter((c) => seen.has(c.path) && !changed.has(c.path));
|
|
for (const rel of Object.keys(idx.files)) {
|
|
if (!seen.has(rel)) delete idx.files[rel];
|
|
}
|
|
|
|
// Nothing to do — still save cleaned deletions if any, emit status.
|
|
if (!targets.length) {
|
|
await save(root, idx);
|
|
return;
|
|
}
|
|
|
|
let done = 0;
|
|
progress = { done: 0, total: targets.length };
|
|
emitStatus(root);
|
|
for (const rel of targets) {
|
|
await embedFileInto(idx, root, rel);
|
|
// Prefer stable rounded hash going forward.
|
|
const st = await fs.stat(path.join(root, rel)).catch(() => null);
|
|
if (st) idx.files[rel] = `${Math.round(st.mtimeMs)}:${st.size}`;
|
|
done++;
|
|
progress = { done, total: targets.length };
|
|
onProgress?.(done, targets.length);
|
|
emitStatus(root);
|
|
// Persist periodically so reopen mid-index keeps progress.
|
|
if (done % 25 === 0) await save(root, idx);
|
|
}
|
|
await save(root, idx);
|
|
} finally {
|
|
indexing = false;
|
|
emitStatus(root);
|
|
void drainPending(root);
|
|
}
|
|
}
|
|
|
|
/** Cosine top-k. Returns [] if index empty / embedder unavailable. */
|
|
export async function search(
|
|
root: string,
|
|
query: string,
|
|
k = 12,
|
|
filter?: (rel: string) => boolean
|
|
): Promise<{ path: string; start: number; end: number; text: string; score: number }[]> {
|
|
const qv = await embedQuery(query);
|
|
if (!qv) return [];
|
|
const idx = await load(root);
|
|
if (!idx.chunks.length) return [];
|
|
const dim = qv.length;
|
|
const scored: { c: Chunk; score: number }[] = [];
|
|
for (const c of idx.chunks) {
|
|
if (filter && !filter(c.path)) continue;
|
|
if (c.vec.length !== dim) continue;
|
|
let dot = 0;
|
|
for (let i = 0; i < dim; i++) dot += qv[i] * c.vec[i]; // both normalized → cosine
|
|
scored.push({ c, score: dot });
|
|
}
|
|
scored.sort((a, b) => b.score - a.score);
|
|
return scored.slice(0, k).map(({ c, score }) => ({ path: c.path, start: c.start, end: c.end, text: c.text, score }));
|
|
}
|