'use strict'; // ═══════════════════════════════════════════════════════════════════════════════════════════════ // Indexer — chunk the repo, embed each chunk, store vectors in SQLite. // // Incremental by file mtime: a file whose mtime has not moved since its last index is skipped // entirely, so a routine re-index costs seconds rather than re-embedding the whole corpus. // // Vectors are stored as raw little-endian float32 BLOBs. nomic-embed-text returns L2-normalised // vectors, so cosine similarity is a plain dot product at query time — no normalising, no // magnitude cache. PHP can read the same blobs with unpack('f*', $blob) when the UI needs them. // ═══════════════════════════════════════════════════════════════════════════════════════════════ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const { DatabaseSync } = require('node:sqlite'); const { chunkFile, classify } = require('./chunk.js'); const SCHEMA = ` CREATE TABLE IF NOT EXISTS vv_files ( path TEXT PRIMARY KEY, mtime INTEGER NOT NULL, chunks INTEGER NOT NULL, indexed INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS vv_chunks ( id INTEGER PRIMARY KEY, path TEXT NOT NULL, kind TEXT NOT NULL, section TEXT, heading TEXT, part INTEGER, content TEXT NOT NULL, vector BLOB NOT NULL, indexed INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_chunks_path ON vv_chunks(path); CREATE INDEX IF NOT EXISTS idx_chunks_section ON vv_chunks(section); CREATE INDEX IF NOT EXISTS idx_chunks_kind ON vv_chunks(kind); CREATE TABLE IF NOT EXISTS vv_meta (k TEXT PRIMARY KEY, v TEXT); `; function openDb(dbPath) { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const db = new DatabaseSync(dbPath); db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA synchronous = NORMAL;'); db.exec(SCHEMA); return db; } // Only ever index what git tracks. Configurations/, State_Files/ and data/ are gitignored, which // is what makes it structurally impossible for a credential to reach the index — the files that // hold them were never in the repo. Do not replace this with a filesystem walk. function trackedFiles(root) { return execSync('git ls-files', { cwd: root, maxBuffer: 1 << 26 }) .toString().trim().split('\n') .filter(Boolean) .filter(f => classify(f) !== 'other'); } async function embedBatch(url, model, inputs, timeoutMs) { const ctl = AbortSignal.timeout(timeoutMs); const res = await fetch(`${url.replace(/\/$/, '')}/api/embed`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, input: inputs }), signal: ctl, }); if (!res.ok) throw new Error(`embed HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`); const j = await res.json(); if (!j.embeddings || j.embeddings.length !== inputs.length) throw new Error(`embed returned ${j.embeddings ? j.embeddings.length : 0} of ${inputs.length}`); return j.embeddings; } function toBlob(vec) { return Buffer.from(Float32Array.from(vec).buffer); } async function buildIndex(opts) { const { root, dbPath, url, model, batch = 32, timeout = 120000, force = false, dryRun = false, onProgress = () => {}, } = opts; const db = dryRun ? null : openDb(dbPath); const now = Math.floor(Date.now() / 1000); const known = new Map(); if (db) for (const r of db.prepare('SELECT path, mtime FROM vv_files').all()) known.set(r.path, r.mtime); const files = trackedFiles(root); const present = new Set(files); // Files that left the repo must leave the index with them. let removed = 0; if (db && !force) { for (const p of known.keys()) { if (!present.has(p)) { db.prepare('DELETE FROM vv_chunks WHERE path = ?').run(p); db.prepare('DELETE FROM vv_files WHERE path = ?').run(p); removed++; } } } if (db && force) { db.exec('DELETE FROM vv_chunks; DELETE FROM vv_files;'); } // ── Collect the chunks that actually need embedding ─────────────────────────────────────── const pending = []; let skipped = 0, scanned = 0; for (const rel of files) { const abs = path.join(root, rel); let st; try { st = fs.statSync(abs); } catch { continue; } const mtime = Math.floor(st.mtimeMs / 1000); scanned++; if (!force && known.has(rel) && known.get(rel) === mtime) { skipped++; continue; } let chunks = []; try { chunks = chunkFile(abs, rel); } catch (e) { continue; } // A file that yields no chunks still gets a vv_files row so it is not re-chunked every // run. Queued rather than written here, so every database change lands in the single // commit below — a run interrupted mid-embed must leave the index exactly as it was. pending.push({ rel, mtime, chunks }); } const totalChunks = pending.reduce((n, f) => n + f.chunks.length, 0); if (dryRun) { return { dryRun: true, scanned, skipped, removed, files: pending.length, chunks: totalChunks }; } // ── Embed in batches, write per file so an interrupted run leaves a consistent index ─────── const flat = []; for (const f of pending) for (const c of f.chunks) flat.push({ f, c }); let done = 0, failed = 0; for (let i = 0; i < flat.length; i += batch) { const slice = flat.slice(i, i + batch); const inputs = slice.map(x => x.c.content); let vecs; try { vecs = await embedBatch(url, model, inputs, timeout); } catch (e) { failed += slice.length; onProgress({ done, total: flat.length, error: e.message }); continue; } slice.forEach((x, k) => { x.c.__vec = vecs[k]; }); done += slice.length; onProgress({ done, total: flat.length }); } const ins = db.prepare( 'INSERT INTO vv_chunks (path,kind,section,heading,part,content,vector,indexed) VALUES (?,?,?,?,?,?,?,?)' ); const insF = db.prepare('INSERT OR REPLACE INTO vv_files VALUES (?,?,?,?)'); db.exec('BEGIN'); try { for (const f of pending) { // Nothing to index in this file at all — record it so it is not re-chunked next run. if (!f.chunks.length) { db.prepare('DELETE FROM vv_chunks WHERE path = ?').run(f.rel); insF.run(f.rel, f.mtime, 0, now); continue; } const embedded = f.chunks.filter(c => c.__vec); // A file whose chunks all failed to embed keeps its previous rows and its old mtime, // so the next run retries it rather than recording a half-indexed file as current. if (!embedded.length) continue; db.prepare('DELETE FROM vv_chunks WHERE path = ?').run(f.rel); for (const c of embedded) { ins.run(c.path, c.kind, c.section, c.heading, c.part, c.content, toBlob(c.__vec), now); } insF.run(f.rel, f.mtime, embedded.length, now); } db.prepare('INSERT OR REPLACE INTO vv_meta VALUES (?,?)').run('embed_model', model); db.prepare('INSERT OR REPLACE INTO vv_meta VALUES (?,?)').run('last_index', String(now)); db.prepare('INSERT OR REPLACE INTO vv_meta VALUES (?,?)').run('dims', '768'); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } const stats = { scanned, skipped, removed, files: pending.length, chunks: done, failed, total: db.prepare('SELECT COUNT(*) n FROM vv_chunks').get().n, }; db.close(); return stats; } module.exports = { buildIndex, openDb, toBlob, trackedFiles };