Files
Varaverk/AI/lib/index.js
T
Gmer4Lfe 6a959fb5e4 Add AI entry points, conf schema, and folder docs
ai_index.sh and ai_query.sh follow the usual conventions — fail-closed gate,
root check, lock, dry-run, status — with Node doing only the vector maths and
SQLite blobs, the same split api_cache_writer.sh uses for PHP.

AI_* and HOST*_OLLAMA_* land in both confs and both templates in this pass.
Everything ships off: AI_ENABLED false, every AI_ASSIST_* false, conf writes
disabled with an empty whitelist. Nothing in the ecosystem consults it.
2026-08-02 01:21:24 -04:00

202 lines
8.2 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; }
// 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 };