Chunker splits on the header sections the audit standardised, then sub-splits named-paragraph safeguards — without that a specific question about one of rsync.sh's fourteen safeguards scored below unrelated chunks, because the other thirteen dominated the vector. Index is SQLite with raw float32 blobs and is incremental on mtime; a no-op re-index takes 66ms. The stale defaults were found by asking the system a question and checking its answer: it correctly reported what mover_stop.sh's header claimed, and the header was wrong.
200 lines
7.9 KiB
JavaScript
200 lines
7.9 KiB
JavaScript
'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; }
|
|
if (!chunks.length) {
|
|
if (db) {
|
|
db.prepare('DELETE FROM vv_chunks WHERE path = ?').run(rel);
|
|
db.prepare('INSERT OR REPLACE INTO vv_files VALUES (?,?,?,?)').run(rel, mtime, 0, now);
|
|
}
|
|
continue;
|
|
}
|
|
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) {
|
|
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 };
|