graph-indexer 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +412 -0
- package/README.md +216 -596
- package/SECURITY.md +68 -0
- package/TRADEMARK.md +31 -0
- package/cli-ui.mjs +78 -0
- package/config.mjs +179 -28
- package/daemon-ctl.mjs +175 -0
- package/daemon-lock.mjs +69 -0
- package/embedders/python/mlx_embed_server.py +61 -0
- package/embedders/python/requirements-mlx.txt +5 -0
- package/embedders/setup-mlx.mjs +110 -0
- package/embeddings.mjs +489 -0
- package/engine/binary.mjs +399 -0
- package/{core-engine.mjs → engine/memory.mjs} +107 -440
- package/{sqlite-store.mjs → engine/sqlite.mjs} +137 -42
- package/enrichment.mjs +111 -32
- package/git-signals.mjs +248 -0
- package/indexer.mjs +151 -42
- package/init.mjs +1603 -171
- package/layout.mjs +123 -0
- package/mcp/format.mjs +61 -0
- package/mcp/tools.mjs +1157 -0
- package/mcp/topology.mjs +551 -0
- package/mcp-server.mjs +55 -24
- package/package.json +44 -13
- package/parse/extractor.mjs +617 -0
- package/parse/imports.mjs +365 -0
- package/parse/languages.mjs +89 -0
- package/parse/metadata.mjs +655 -0
- package/parse/routes.mjs +327 -0
- package/prompts/CORE.md +86 -0
- package/prompts/DOMAIN_TEMPLATE.md +46 -0
- package/prompts/INTEGRATION.md +123 -0
- package/prompts/frameworks/ANDROID.md +17 -0
- package/prompts/frameworks/ASPNET_CORE.md +19 -0
- package/prompts/frameworks/FASTAPI_DJANGO.md +19 -0
- package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +18 -0
- package/prompts/frameworks/RAILS.md +20 -0
- package/prompts/frameworks/REACT.md +20 -0
- package/prompts/frameworks/SPRING_BOOT.md +19 -0
- package/prompts/languages/BASH.md +22 -0
- package/prompts/languages/C.md +21 -0
- package/prompts/languages/CSHARP.md +24 -0
- package/prompts/languages/CSS_SCSS.md +22 -0
- package/prompts/languages/GO.md +23 -0
- package/prompts/languages/JAVA.md +23 -0
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
- package/prompts/languages/KOTLIN.md +24 -0
- package/prompts/languages/PHP.md +23 -0
- package/prompts/languages/PYTHON.md +23 -0
- package/prompts/languages/RUBY.md +23 -0
- package/prompts/languages/RUST.md +21 -0
- package/prompts/languages/SWIFT.md +21 -0
- package/search-core.mjs +254 -43
- package/storage.mjs +28 -3
- package/watch-daemon.mjs +69 -23
- package/PROMPT.md +0 -182
- package/mcp-tools.mjs +0 -545
- package/parser-utils.mjs +0 -1170
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file sqlite
|
|
2
|
+
* @file engine/sqlite.mjs
|
|
3
3
|
* @description Disk-backed graph store for enterprise-scale monorepos. Implements
|
|
4
4
|
* the same read contract as MemoryGraphIndex (see storage.mjs) over
|
|
5
5
|
* `node:sqlite` — built into Node, so this adds ZERO dependencies.
|
|
@@ -28,19 +28,20 @@
|
|
|
28
28
|
import fs from 'fs';
|
|
29
29
|
import {
|
|
30
30
|
tokenize, okapiIdf, bm25Score, fuseAndRank, buildLexicalDocument, embeddingKeyFor,
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
LEXICAL_FUSION_CAP, VECTOR_SCAN_RAW_N, finalizeVectorCandidates,
|
|
32
|
+
isNaturalLanguageQuery, baseEmbeddingKey,
|
|
33
|
+
} from '../search-core.mjs';
|
|
33
34
|
import {
|
|
34
35
|
writeEmbeddingBinary, appendEmbeddingBinary, scanEmbeddingBinary,
|
|
35
|
-
updateVectorSketch, searchVectorSketch,
|
|
36
|
-
} from './
|
|
36
|
+
updateVectorSketch, searchVectorSketch, SKETCH_THRESHOLD,
|
|
37
|
+
} from './binary.mjs';
|
|
37
38
|
|
|
38
39
|
// node:sqlite is loaded lazily so the default in-memory path never requires it.
|
|
39
40
|
let DatabaseSync = null;
|
|
40
41
|
try { ({ DatabaseSync } = await import('node:sqlite')); } catch { /* reported in ctor */ }
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
// SKETCH_THRESHOLD (shared with the memory store via binary.mjs) — build the binary
|
|
44
|
+
// sketch above this many vectors. Below it the exact streaming scan stays under ~20 ms
|
|
44
45
|
// AND both backends rank identically (the sketch is
|
|
45
46
|
// approximate in the candidate tail); above it the
|
|
46
47
|
// sketch caps latency at ~5–15 ms regardless of size.
|
|
@@ -53,12 +54,23 @@ CREATE TABLE IF NOT EXISTS chunks (
|
|
|
53
54
|
start_line INTEGER, end_line INTEGER,
|
|
54
55
|
calls TEXT, params TEXT, return_type TEXT, class_context TEXT,
|
|
55
56
|
type_refs TEXT, decorators TEXT, extends_ TEXT, hyde TEXT, summary TEXT, concepts TEXT,
|
|
56
|
-
doc_len INTEGER, path_tokens TEXT, vec_key TEXT, vec_offset INTEGER, vec_dim INTEGER
|
|
57
|
+
doc_len INTEGER, path_tokens TEXT, vec_key TEXT, vec_offset INTEGER, vec_dim INTEGER,
|
|
58
|
+
call_sites TEXT
|
|
57
59
|
);
|
|
58
60
|
CREATE TABLE IF NOT EXISTS postings (term TEXT, chunk_id TEXT, tf INTEGER);
|
|
59
61
|
CREATE TABLE IF NOT EXISTS terms (term TEXT PRIMARY KEY, df INTEGER);
|
|
60
62
|
CREATE TABLE IF NOT EXISTS call_edges (callee TEXT, chunk_id TEXT);
|
|
61
63
|
CREATE TABLE IF NOT EXISTS deps (file TEXT, dep TEXT);
|
|
64
|
+
CREATE TABLE IF NOT EXISTS routes (
|
|
65
|
+
id INTEGER PRIMARY KEY,
|
|
66
|
+
method TEXT NOT NULL,
|
|
67
|
+
path TEXT NOT NULL,
|
|
68
|
+
handler_name TEXT,
|
|
69
|
+
handler_chunk_id TEXT,
|
|
70
|
+
file_path TEXT NOT NULL,
|
|
71
|
+
line INTEGER,
|
|
72
|
+
framework TEXT
|
|
73
|
+
);
|
|
62
74
|
`;
|
|
63
75
|
|
|
64
76
|
const SCHEMA_INDEXES = `
|
|
@@ -71,13 +83,16 @@ CREATE INDEX IF NOT EXISTS idx_postings_chunk ON postings(chunk_id);
|
|
|
71
83
|
CREATE INDEX IF NOT EXISTS idx_calledges_callee ON call_edges(callee);
|
|
72
84
|
CREATE INDEX IF NOT EXISTS idx_deps_file ON deps(file);
|
|
73
85
|
CREATE INDEX IF NOT EXISTS idx_deps_dep ON deps(dep);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_routes_method ON routes(method);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_routes_path ON routes(path);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_routes_handler ON routes(handler_chunk_id);
|
|
74
89
|
`;
|
|
75
90
|
|
|
76
91
|
const CHUNK_COLS = [
|
|
77
92
|
'id', 'file_path', 'node_type', 'name', 'name_lower', 'docstring', 'code_snippet',
|
|
78
93
|
'content_hash', 'start_line', 'end_line', 'calls', 'params', 'return_type',
|
|
79
94
|
'class_context', 'type_refs', 'decorators', 'extends_', 'hyde', 'summary', 'concepts',
|
|
80
|
-
'doc_len', 'path_tokens', 'vec_key', 'vec_offset', 'vec_dim',
|
|
95
|
+
'doc_len', 'path_tokens', 'vec_key', 'vec_offset', 'vec_dim', 'call_sites',
|
|
81
96
|
];
|
|
82
97
|
|
|
83
98
|
function jsonArr(v) { return JSON.stringify(Array.isArray(v) ? v : []); }
|
|
@@ -145,9 +160,19 @@ export class SqliteGraphStore {
|
|
|
145
160
|
// Pre-enrichment-key indexes stored vectors under the plain content hash.
|
|
146
161
|
this.db.exec('UPDATE chunks SET vec_key = content_hash WHERE vec_key IS NULL');
|
|
147
162
|
} catch { /* exists */ }
|
|
163
|
+
// Receiver-aware call graph (precise get_call_graph) landed later; older
|
|
164
|
+
// indexes lack the column. NULL → callers fall back to name-only grouping.
|
|
165
|
+
try { this.db.exec('ALTER TABLE chunks ADD COLUMN call_sites TEXT DEFAULT NULL'); } catch { /* exists */ }
|
|
148
166
|
}
|
|
149
167
|
this.db.exec(SCHEMA_INDEXES);
|
|
150
168
|
this._reloadMeta();
|
|
169
|
+
// Does this corpus carry LLM enrichment? One cheap probe → drives the NL
|
|
170
|
+
// vector-channel weight in fuseAndRank (parity with the in-memory engine).
|
|
171
|
+
try {
|
|
172
|
+
this._corpusEnriched = Boolean(
|
|
173
|
+
this.db.prepare("SELECT 1 FROM chunks WHERE summary IS NOT NULL AND summary != '' LIMIT 1").get()
|
|
174
|
+
);
|
|
175
|
+
} catch { this._corpusEnriched = false; }
|
|
151
176
|
this._loadGraph();
|
|
152
177
|
this._openVecFd();
|
|
153
178
|
this._refreshSketch();
|
|
@@ -259,6 +284,12 @@ export class SqliteGraphStore {
|
|
|
259
284
|
this._stmtCallers = this.db.prepare(
|
|
260
285
|
'SELECT c.* FROM chunks c JOIN call_edges e ON e.chunk_id = c.id WHERE e.callee = ?'
|
|
261
286
|
);
|
|
287
|
+
// type_refs / extends_ are JSON arrays of names. LIKE is a cheap prefilter
|
|
288
|
+
// (ASCII case-insensitive in SQLite); findReferers() verifies exact, quoted
|
|
289
|
+
// membership in JS so e.g. "User" never matches "UserProfile".
|
|
290
|
+
this._stmtReferers = this.db.prepare(
|
|
291
|
+
'SELECT * FROM chunks WHERE type_refs LIKE ? ESCAPE \'\\\' OR extends_ LIKE ? ESCAPE \'\\\''
|
|
292
|
+
);
|
|
262
293
|
this._stmtPosting = this.db.prepare(
|
|
263
294
|
'SELECT p.chunk_id AS id, p.tf AS tf, c.doc_len AS doc_len '
|
|
264
295
|
+ 'FROM postings p JOIN chunks c ON c.id = p.chunk_id WHERE p.term = ?'
|
|
@@ -269,6 +300,12 @@ export class SqliteGraphStore {
|
|
|
269
300
|
// ids in a deterministic order keeps both backends rank-identical on ties.
|
|
270
301
|
this._stmtIdsByKey = this.db.prepare('SELECT id FROM chunks WHERE vec_key = ? ORDER BY id');
|
|
271
302
|
this._stmtIdsByHash = this.db.prepare('SELECT id FROM chunks WHERE content_hash = ? ORDER BY id');
|
|
303
|
+
// ORDER BY id (rowid = insertion order) keeps routes in index-build order,
|
|
304
|
+
// matching the in-memory graph.routes array order for cross-backend parity.
|
|
305
|
+
this._stmtRoutes = this.db.prepare(
|
|
306
|
+
'SELECT method, path, handler_name, handler_chunk_id, file_path, line, framework '
|
|
307
|
+
+ 'FROM routes ORDER BY id'
|
|
308
|
+
);
|
|
272
309
|
}
|
|
273
310
|
|
|
274
311
|
_prepareWrite() {
|
|
@@ -312,7 +349,7 @@ export class SqliteGraphStore {
|
|
|
312
349
|
return_type: row.return_type || '', class_context: row.class_context || '',
|
|
313
350
|
type_refs: parseArr(row.type_refs), decorators: parseArr(row.decorators),
|
|
314
351
|
extends: parseArr(row.extends_), hyde: row.hyde || '', summary: row.summary || '',
|
|
315
|
-
concepts: parseArr(row.concepts),
|
|
352
|
+
concepts: parseArr(row.concepts), call_sites: parseArr(row.call_sites),
|
|
316
353
|
};
|
|
317
354
|
}
|
|
318
355
|
|
|
@@ -330,6 +367,52 @@ export class SqliteGraphStore {
|
|
|
330
367
|
|
|
331
368
|
findCallers(funcName) { return this._stmtCallers.all(funcName).map(r => this._rowToChunk(r)); }
|
|
332
369
|
|
|
370
|
+
findReferers(symbol) {
|
|
371
|
+
const key = String(symbol).toLowerCase().trim();
|
|
372
|
+
if (!key) return [];
|
|
373
|
+
// Escape LIKE metacharacters in the symbol, then bracket with JSON quotes so
|
|
374
|
+
// the prefilter only fires on a whole array element.
|
|
375
|
+
const esc = key.replace(/[\\%_]/g, m => '\\' + m);
|
|
376
|
+
const pat = `%"${esc}"%`;
|
|
377
|
+
const out = [];
|
|
378
|
+
for (const row of this._stmtReferers.all(pat, pat)) {
|
|
379
|
+
const c = this._rowToChunk(row);
|
|
380
|
+
// A class/type lists its own name in type_refs — that self-mention is a
|
|
381
|
+
// definition, not a reference; skip it.
|
|
382
|
+
if (c.name && c.name.toLowerCase() === key) continue;
|
|
383
|
+
const hit = c.type_refs.some(t => t.toLowerCase() === key)
|
|
384
|
+
|| c.extends.some(t => t.toLowerCase() === key);
|
|
385
|
+
if (hit) out.push(c);
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* HTTP routes filtered by method and/or path prefix, each augmented with its
|
|
392
|
+
* resolved handler chunk (`chunk`, or null). Filtering logic is byte-identical
|
|
393
|
+
* to MemoryGraphIndex.findRoutes; rows arrive in index-build order (ORDER BY id)
|
|
394
|
+
* so both backends return identical records. An index built before this feature
|
|
395
|
+
* (empty routes table) yields [].
|
|
396
|
+
* @returns {object[]}
|
|
397
|
+
*/
|
|
398
|
+
findRoutes({ method = null, pathPrefix = null } = {}) {
|
|
399
|
+
this._maybeRefresh();
|
|
400
|
+
const m = method ? String(method).toUpperCase().trim() : null;
|
|
401
|
+
const p = pathPrefix ? String(pathPrefix).toLowerCase() : null;
|
|
402
|
+
const out = [];
|
|
403
|
+
for (const r of this._stmtRoutes.all()) {
|
|
404
|
+
if (m && String(r.method || '').toUpperCase() !== m) continue;
|
|
405
|
+
if (p && !String(r.path || '').toLowerCase().startsWith(p)) continue;
|
|
406
|
+
const chunk = r.handler_chunk_id ? this.getChunk(r.handler_chunk_id) : null;
|
|
407
|
+
out.push({
|
|
408
|
+
method: r.method, path: r.path, handler_name: r.handler_name,
|
|
409
|
+
handler_chunk_id: r.handler_chunk_id, file_path: r.file_path,
|
|
410
|
+
line: r.line, framework: r.framework, chunk,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
333
416
|
*iterateChunks() {
|
|
334
417
|
// Stream rows via a cursor so repo-map / call-graph passes never materialise
|
|
335
418
|
// the whole table. Falls back to paging if .iterate() is unavailable.
|
|
@@ -408,8 +491,9 @@ export class SqliteGraphStore {
|
|
|
408
491
|
searchHybrid(queryText, queryVector, topK = 5, minScore = 0.3, exactBoostName = null) {
|
|
409
492
|
this._maybeRefresh();
|
|
410
493
|
|
|
411
|
-
//
|
|
412
|
-
|
|
494
|
+
// Asymmetric stemming: stem only for natural-language queries so symbolic
|
|
495
|
+
// lookups stay exact — mirrors the in-memory engine for parity.
|
|
496
|
+
const qTokens = tokenize(queryText, isNaturalLanguageQuery(queryText));
|
|
413
497
|
const occ = new Map();
|
|
414
498
|
for (const t of qTokens) occ.set(t, (occ.get(t) || 0) + 1);
|
|
415
499
|
|
|
@@ -429,10 +513,9 @@ export class SqliteGraphStore {
|
|
|
429
513
|
.slice(0, LEXICAL_FUSION_CAP)
|
|
430
514
|
.map(([id, score], i) => ({ id, score, rank: i + 1 }));
|
|
431
515
|
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
// semantic search off exactly where it was needed.
|
|
516
|
+
// Stream the FULL embeddings bin (bounded buffers) so conceptual queries
|
|
517
|
+
// that share no tokens with the code can still match — scoring only lexical
|
|
518
|
+
// candidates silently disabled semantic search where it mattered most.
|
|
436
519
|
let vectorResults = [];
|
|
437
520
|
if (queryVector && this._vecFd >= 0) {
|
|
438
521
|
// Binary sketch above SKETCH_THRESHOLD (Hamming prefilter + exact
|
|
@@ -446,9 +529,9 @@ export class SqliteGraphStore {
|
|
|
446
529
|
});
|
|
447
530
|
const entries = [];
|
|
448
531
|
for (const { key, score } of hits) {
|
|
449
|
-
// Summary-only
|
|
450
|
-
|
|
451
|
-
|
|
532
|
+
// Summary-only (`<key>|s`) and per-window (`<key>|wN`) vectors fold onto
|
|
533
|
+
// the chunk; finalizeVectorCandidates keeps the max-sim score per chunk.
|
|
534
|
+
const baseKey = baseEmbeddingKey(key);
|
|
452
535
|
let rows = this._stmtIdsByKey.all(baseKey);
|
|
453
536
|
if (rows.length === 0) rows = this._stmtIdsByHash.all(baseKey); // legacy bins
|
|
454
537
|
for (const { id } of rows) entries.push({ id, score });
|
|
@@ -456,9 +539,6 @@ export class SqliteGraphStore {
|
|
|
456
539
|
vectorResults = finalizeVectorCandidates(entries);
|
|
457
540
|
}
|
|
458
541
|
|
|
459
|
-
// 3. Fetch candidate rows ONCE (transient, bounded by the caps above), then
|
|
460
|
-
// fuse + boost with the shared ranker. getChunk lazy-fetches stragglers
|
|
461
|
-
// (e.g. exact-boost ids) and caches them for the duration of the call.
|
|
462
542
|
const rowCache = new Map();
|
|
463
543
|
const fetchRow = (id) => {
|
|
464
544
|
if (rowCache.has(id)) return rowCache.get(id);
|
|
@@ -496,6 +576,7 @@ export class SqliteGraphStore {
|
|
|
496
576
|
return fuseAndRank({
|
|
497
577
|
lexicalResults, vectorResults, getChunk, getPathTokens, getDf,
|
|
498
578
|
docCount: this._docCount, rrfK: this.rrfK, topK, queryText, exactBoostName,
|
|
579
|
+
corpusEnriched: this._corpusEnriched,
|
|
499
580
|
resolveExact: (term) => this._stmtIdByName.all(term).map(r => r.id),
|
|
500
581
|
});
|
|
501
582
|
}
|
|
@@ -509,8 +590,9 @@ export class SqliteGraphStore {
|
|
|
509
590
|
|
|
510
591
|
/** Internal: index one chunk's lexical document + row + call edges. */
|
|
511
592
|
_insertChunk(chunk, imports, vecEntry) {
|
|
512
|
-
const
|
|
513
|
-
const
|
|
593
|
+
const doc = buildLexicalDocument(chunk, imports);
|
|
594
|
+
const tokens = tokenize(doc); // raw + additive stems
|
|
595
|
+
const docLen = tokenize(doc, false).length; // raw-only length for BM25 avgdl (parity w/ memory engine)
|
|
514
596
|
|
|
515
597
|
const termCounts = new Map();
|
|
516
598
|
for (const t of tokens) termCounts.set(t, (termCounts.get(t) || 0) + 1);
|
|
@@ -519,7 +601,7 @@ export class SqliteGraphStore {
|
|
|
519
601
|
this._stmtInsPost.run(term, chunk.id, cnt);
|
|
520
602
|
}
|
|
521
603
|
|
|
522
|
-
const pathTokens = Array.from(new Set(tokenize(chunk.file_path.replace(/[/\-_.]/g, ' '))));
|
|
604
|
+
const pathTokens = Array.from(new Set(tokenize(chunk.file_path.replace(/[/\-_.]/g, ' '), false)));
|
|
523
605
|
this._stmtInsChunk.run(
|
|
524
606
|
chunk.id, chunk.file_path, chunk.node_type ?? null, chunk.name ?? null,
|
|
525
607
|
nameLowerOf(chunk.name), chunk.docstring ?? '', chunk.code_snippet ?? '',
|
|
@@ -529,7 +611,8 @@ export class SqliteGraphStore {
|
|
|
529
611
|
jsonArr(chunk.extends), chunk.hyde ?? null, chunk.summary ?? null, jsonArr(chunk.concepts),
|
|
530
612
|
docLen, JSON.stringify(pathTokens),
|
|
531
613
|
chunk.content_hash ? embeddingKeyFor(chunk) : null,
|
|
532
|
-
vecEntry ? vecEntry.offset : -1, vecEntry ? vecEntry.dim : 0
|
|
614
|
+
vecEntry ? vecEntry.offset : -1, vecEntry ? vecEntry.dim : 0,
|
|
615
|
+
jsonArr(chunk.call_sites)
|
|
533
616
|
);
|
|
534
617
|
for (const callee of (chunk.calls || [])) this._stmtInsEdge.run(callee, chunk.id);
|
|
535
618
|
return docLen;
|
|
@@ -557,8 +640,8 @@ export class SqliteGraphStore {
|
|
|
557
640
|
this._prepareWrite();
|
|
558
641
|
this._maybeRefresh();
|
|
559
642
|
|
|
560
|
-
//
|
|
561
|
-
//
|
|
643
|
+
// Append new vectors first — orphan bytes on a failed commit are harmless
|
|
644
|
+
// and compacted away by the next full index run.
|
|
562
645
|
let appended = new Map();
|
|
563
646
|
const embCount = embeddings ? (embeddings instanceof Map ? embeddings.size : Object.keys(embeddings).length) : 0;
|
|
564
647
|
if (embCount > 0) {
|
|
@@ -574,8 +657,8 @@ export class SqliteGraphStore {
|
|
|
574
657
|
|
|
575
658
|
this.db.exec('BEGIN');
|
|
576
659
|
try {
|
|
577
|
-
//
|
|
578
|
-
//
|
|
660
|
+
// Capture reusable vector offsets BEFORE deleting old rows, which may
|
|
661
|
+
// be the only rows that hold those offsets.
|
|
579
662
|
const reuse = new Map();
|
|
580
663
|
for (const chunk of chunks) {
|
|
581
664
|
if (!chunk.content_hash) continue;
|
|
@@ -585,7 +668,6 @@ export class SqliteGraphStore {
|
|
|
585
668
|
if (row) reuse.set(key, { offset: row.vec_offset, dim: row.vec_dim });
|
|
586
669
|
}
|
|
587
670
|
|
|
588
|
-
// 3. Remove the file's old rows with exact BM25 bookkeeping.
|
|
589
671
|
let docCount = this._docCount;
|
|
590
672
|
let totalDocLen = this._totalDocLen;
|
|
591
673
|
for (const row of this._stmtByFile.all(filePath)) {
|
|
@@ -599,7 +681,6 @@ export class SqliteGraphStore {
|
|
|
599
681
|
docCount--;
|
|
600
682
|
}
|
|
601
683
|
|
|
602
|
-
// 4. Insert the new chunks.
|
|
603
684
|
for (const chunk of chunks) {
|
|
604
685
|
const key = chunk.content_hash ? embeddingKeyFor(chunk) : null;
|
|
605
686
|
const vecEntry = key ? (appended.get(key) ?? reuse.get(key) ?? null) : null;
|
|
@@ -608,7 +689,6 @@ export class SqliteGraphStore {
|
|
|
608
689
|
}
|
|
609
690
|
this._stmtPruneTerms.run();
|
|
610
691
|
|
|
611
|
-
// 5. Dependency edges + meta.
|
|
612
692
|
this._stmtDelDeps.run(filePath);
|
|
613
693
|
if (!imports || imports.length === 0) this._stmtInsDep.run(filePath, null);
|
|
614
694
|
else for (const d of imports) this._stmtInsDep.run(filePath, d);
|
|
@@ -629,7 +709,6 @@ export class SqliteGraphStore {
|
|
|
629
709
|
throw err;
|
|
630
710
|
}
|
|
631
711
|
|
|
632
|
-
// 6. Mirror the in-RAM dependency graph (same shape as updateFileGraph).
|
|
633
712
|
const oldDeps = this._graph.dependencies[filePath] || [];
|
|
634
713
|
for (const oldDep of oldDeps) {
|
|
635
714
|
if (this._graph.importedBy[oldDep]) {
|
|
@@ -672,9 +751,8 @@ export class SqliteGraphStore {
|
|
|
672
751
|
*/
|
|
673
752
|
buildFrom({ chunks, graph, embeddingCache }) {
|
|
674
753
|
this._open();
|
|
675
|
-
this.db.exec('PRAGMA synchronous = OFF;'); //
|
|
754
|
+
this.db.exec('PRAGMA synchronous = OFF;'); // safe only for a full rebuild; not used for incremental writes
|
|
676
755
|
|
|
677
|
-
// Write the embedding binary and derive each key's float offset.
|
|
678
756
|
const offsets = new Map();
|
|
679
757
|
let dim = 0;
|
|
680
758
|
const cacheSize = embeddingCache instanceof Map ? embeddingCache.size : Object.keys(embeddingCache || {}).length;
|
|
@@ -697,12 +775,13 @@ export class SqliteGraphStore {
|
|
|
697
775
|
let totalDocLen = 0, docCount = 0;
|
|
698
776
|
|
|
699
777
|
this.db.exec('BEGIN');
|
|
700
|
-
//
|
|
701
|
-
//
|
|
778
|
+
// Drop inside the transaction so concurrent readers see the old index
|
|
779
|
+
// until COMMIT, never briefly-empty tables.
|
|
702
780
|
this.db.exec(`
|
|
703
781
|
DROP TABLE IF EXISTS chunks; DROP TABLE IF EXISTS postings;
|
|
704
782
|
DROP TABLE IF EXISTS terms; DROP TABLE IF EXISTS call_edges;
|
|
705
783
|
DROP TABLE IF EXISTS deps; DROP TABLE IF EXISTS meta;
|
|
784
|
+
DROP TABLE IF EXISTS routes;
|
|
706
785
|
`);
|
|
707
786
|
this.db.exec(SCHEMA_TABLES);
|
|
708
787
|
this.db.exec(SCHEMA_INDEXES);
|
|
@@ -716,8 +795,9 @@ export class SqliteGraphStore {
|
|
|
716
795
|
|
|
717
796
|
for (const chunk of chunks) {
|
|
718
797
|
const deps = (graph?.dependencies?.[chunk.file_path]) || [];
|
|
719
|
-
const
|
|
720
|
-
const
|
|
798
|
+
const doc = buildLexicalDocument(chunk, deps);
|
|
799
|
+
const tokens = tokenize(doc); // raw + additive stems
|
|
800
|
+
const docLen = tokenize(doc, false).length; // raw-only length for BM25 avgdl (parity w/ memory engine)
|
|
721
801
|
totalDocLen += docLen; docCount++;
|
|
722
802
|
|
|
723
803
|
const termCounts = new Map();
|
|
@@ -727,7 +807,7 @@ export class SqliteGraphStore {
|
|
|
727
807
|
insPost.run(term, chunk.id, cnt);
|
|
728
808
|
}
|
|
729
809
|
|
|
730
|
-
const pathTokens = Array.from(new Set(tokenize(chunk.file_path.replace(/[/\-_.]/g, ' '))));
|
|
810
|
+
const pathTokens = Array.from(new Set(tokenize(chunk.file_path.replace(/[/\-_.]/g, ' '), false)));
|
|
731
811
|
const vecKey = chunk.content_hash ? embeddingKeyFor(chunk) : null;
|
|
732
812
|
const vec = vecKey ? (offsets.get(vecKey) ?? offsets.get(chunk.content_hash)) : null;
|
|
733
813
|
|
|
@@ -739,7 +819,8 @@ export class SqliteGraphStore {
|
|
|
739
819
|
chunk.class_context ?? '', jsonArr(chunk.type_refs), jsonArr(chunk.decorators),
|
|
740
820
|
jsonArr(chunk.extends), chunk.hyde ?? null, chunk.summary ?? null, jsonArr(chunk.concepts),
|
|
741
821
|
docLen, JSON.stringify(pathTokens), vecKey,
|
|
742
|
-
vec ? vec.offset : -1, vec ? vec.dim : 0
|
|
822
|
+
vec ? vec.offset : -1, vec ? vec.dim : 0,
|
|
823
|
+
jsonArr(chunk.call_sites)
|
|
743
824
|
);
|
|
744
825
|
for (const callee of (chunk.calls || [])) insEdge.run(callee, chunk.id);
|
|
745
826
|
}
|
|
@@ -753,6 +834,20 @@ export class SqliteGraphStore {
|
|
|
753
834
|
else for (const d of list) insDep.run(file, d);
|
|
754
835
|
}
|
|
755
836
|
|
|
837
|
+
// Inserted in array order so the autoincrement id mirrors the in-memory
|
|
838
|
+
// graph.routes order, keeping both backends rank-identical.
|
|
839
|
+
const insRoute = this.db.prepare(
|
|
840
|
+
'INSERT INTO routes (method, path, handler_name, handler_chunk_id, file_path, line, framework) '
|
|
841
|
+
+ 'VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
842
|
+
);
|
|
843
|
+
for (const r of (graph?.routes || [])) {
|
|
844
|
+
insRoute.run(
|
|
845
|
+
String(r.method || ''), String(r.path ?? ''), r.handler_name ?? null,
|
|
846
|
+
r.handler_chunk_id ?? null, String(r.file_path || ''),
|
|
847
|
+
r.line ?? null, r.framework ?? null
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
|
|
756
851
|
const insMeta = this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)');
|
|
757
852
|
insMeta.run('doc_count', String(docCount));
|
|
758
853
|
insMeta.run('total_doc_len', String(totalDocLen));
|
package/enrichment.mjs
CHANGED
|
@@ -58,32 +58,67 @@ export function attachEnrichment(chunk, entry) {
|
|
|
58
58
|
return Boolean(chunk.summary || chunk.concepts.length);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// search_document: training objective.
|
|
65
|
-
// • TAGS — comma-separated domain keywords; joined into chunk.hyde so BM25
|
|
66
|
-
// receives high-IDF concept terms instead of question stopword noise.
|
|
67
|
-
//
|
|
68
|
-
// One-shot example is mandatory for 1.5B models to reliably follow the format.
|
|
61
|
+
// Single-line SUMMARY keeps parseEnrichResponse trivial regardless of model capability.
|
|
62
|
+
// One-shot example is mandatory for small (1.5B) models to follow the format.
|
|
63
|
+
// TAGS → chunk.hyde gives BM25 high-IDF domain terms instead of question stopword noise.
|
|
69
64
|
export const buildEnrichPrompt = (chunk) => {
|
|
70
|
-
const code = (chunk.code_snippet || '').slice(0,
|
|
65
|
+
const code = (chunk.code_snippet || '').slice(0, 1000);
|
|
71
66
|
const base = path.basename(chunk.file_path);
|
|
72
|
-
const ctx = chunk.class_context ? ` (
|
|
73
|
-
const doc = chunk.docstring ? `Doc: ${chunk.docstring.slice(0,
|
|
67
|
+
const ctx = chunk.class_context ? ` (member of ${chunk.class_context})` : '';
|
|
68
|
+
const doc = chunk.docstring ? `Doc: ${chunk.docstring.slice(0, 240)}\n` : '';
|
|
74
69
|
return (
|
|
75
|
-
`
|
|
76
|
-
+ `
|
|
77
|
-
+ `
|
|
70
|
+
`You are indexing source code for semantic search. A developer will search by INTENT\n`
|
|
71
|
+
+ `("where is the code that …", "how does it …") in plain words — NOT by the symbol name.\n\n`
|
|
72
|
+
+ `Describe this ${chunk.node_type} "${chunk.name}"${ctx} in ${base} as exactly two lines:\n`
|
|
73
|
+
+ `SUMMARY: one or two sentences on a SINGLE line, in the vocabulary a developer would\n`
|
|
74
|
+
+ ` search with, covering: WHAT IT DOES and the DOMAIN it acts on; the PROBLEM it solves;\n`
|
|
75
|
+
+ ` its key INPUTS and OUTPUTS; and any notable SIDE EFFECTS (I/O, state mutation, network\n`
|
|
76
|
+
+ ` calls, errors thrown). Use concrete domain nouns and action verbs. Do NOT restate the\n`
|
|
77
|
+
+ ` symbol name and do NOT use filler words like "function", "method", "class", "handles",\n`
|
|
78
|
+
+ ` "manages", or "responsible for".\n`
|
|
79
|
+
+ `TAGS: 5-8 comma-separated domain keywords a developer would type to find this code.\n\n`
|
|
78
80
|
+ `Example:\n`
|
|
79
|
-
+ `SUMMARY: validates JWT tokens and refreshes expired
|
|
80
|
-
+ `TAGS: authentication, JWT, token validation, session refresh, middleware\n\n`
|
|
81
|
+
+ `SUMMARY: validates JWT bearer tokens against the signing key and refreshes expired sessions, taking the incoming request's Authorization header and returning the decoded principal, rejecting tampered or expired tokens with a 401 so downstream handlers only ever see authenticated requests\n`
|
|
82
|
+
+ `TAGS: authentication, JWT, token validation, session refresh, middleware, authorization\n\n`
|
|
81
83
|
+ doc
|
|
82
84
|
+ `Code:\n${code}\n`
|
|
83
85
|
+ `Output:`
|
|
84
86
|
);
|
|
85
87
|
};
|
|
86
88
|
|
|
89
|
+
// Preference ladder for `enrichment.model: "auto"` — pick the strongest code-aware
|
|
90
|
+
// model the local Ollama actually has pulled, falling back to the fast 1.5B floor.
|
|
91
|
+
// Enrichment is offline and cached, so a heavier model here costs nothing at query
|
|
92
|
+
// time; "auto" means "use the best you have" without the user naming it.
|
|
93
|
+
const ENRICH_MODEL_FLOOR = 'qwen2.5-coder:1.5b';
|
|
94
|
+
const ENRICH_MODEL_PREFERENCE = [
|
|
95
|
+
'qwen2.5-coder:7b', 'qwen2.5-coder:3b', 'deepseek-coder-v2', 'qwen2.5-coder:1.5b',
|
|
96
|
+
'codellama:13b', 'codellama:7b',
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the enrichment model. A concrete name is returned as-is; "auto" probes
|
|
101
|
+
* the Ollama model list and returns the strongest available code model (then any
|
|
102
|
+
* coder/code model, then the 1.5B floor). Best-effort and non-throwing — an
|
|
103
|
+
* unreachable Ollama yields the floor (enrichment then no-ops gracefully anyway).
|
|
104
|
+
*
|
|
105
|
+
* @param {string} model Configured model, or "auto".
|
|
106
|
+
* @param {string} ollamaHost
|
|
107
|
+
* @param {{timeoutMs?:number, fetchImpl?:typeof fetch}} [opts] fetchImpl injectable for tests.
|
|
108
|
+
*/
|
|
109
|
+
export async function resolveEnrichModel(model, ollamaHost, { timeoutMs = 2000, fetchImpl = fetch } = {}) {
|
|
110
|
+
if (model && model !== 'auto') return model;
|
|
111
|
+
try {
|
|
112
|
+
const res = await fetchImpl(`${ollamaHost}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
113
|
+
if (!res.ok) return ENRICH_MODEL_FLOOR;
|
|
114
|
+
const names = ((await res.json()).models || []).map(m => m.name);
|
|
115
|
+
const has = (p) => names.some(n => n === p || n === `${p}:latest` || n.startsWith(`${p}:`) || n.split(':')[0] === p);
|
|
116
|
+
for (const pref of ENRICH_MODEL_PREFERENCE) if (has(pref)) return pref;
|
|
117
|
+
const coder = names.find(n => /coder|code/i.test(n));
|
|
118
|
+
return coder || ENRICH_MODEL_FLOOR;
|
|
119
|
+
} catch { return ENRICH_MODEL_FLOOR; }
|
|
120
|
+
}
|
|
121
|
+
|
|
87
122
|
/**
|
|
88
123
|
* Default generator: a single non-streaming Ollama /api/generate call. Returns the
|
|
89
124
|
* response text, or null on any failure (enrichment is best-effort and never fatal).
|
|
@@ -107,11 +142,31 @@ export async function ollamaGenerate(prompt, { model, ollamaHost, timeoutMs = 30
|
|
|
107
142
|
}
|
|
108
143
|
}
|
|
109
144
|
|
|
145
|
+
/**
|
|
146
|
+
* MLX-LM generator: calls a local mlx_lm.server (OpenAI-compatible /v1/completions).
|
|
147
|
+
* Start the server with: mlx_lm.server --model <model-name> [--port 8080]
|
|
148
|
+
* Returns the completion text, or null on any failure.
|
|
149
|
+
*/
|
|
150
|
+
export async function mlxLmGenerate(prompt, { mlxLmHost = 'http://localhost:8080', timeoutMs = 60000 } = {}) {
|
|
151
|
+
try {
|
|
152
|
+
const res = await fetch(`${mlxLmHost}/v1/completions`, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: { 'Content-Type': 'application/json' },
|
|
155
|
+
body: JSON.stringify({ prompt, max_tokens: 150, temperature: 0.1 }),
|
|
156
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
157
|
+
});
|
|
158
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
159
|
+
const data = await res.json();
|
|
160
|
+
return (data.choices?.[0]?.text || '').trim() || null;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
110
166
|
/**
|
|
111
167
|
* Parse the SUMMARY + TAGS response into { summary, concepts, hyde }.
|
|
112
168
|
*
|
|
113
|
-
*
|
|
114
|
-
* Concepts are domain terms only (stopwords scrubbed), so every token has
|
|
169
|
+
* Concepts are domain terms only (stopwords scrubbed) so every token has
|
|
115
170
|
* meaningful IDF weight. Falls back to extracting non-stopword keywords from
|
|
116
171
|
* the summary when the model omits the TAGS line (graceful degradation).
|
|
117
172
|
*/
|
|
@@ -142,7 +197,7 @@ export function parseEnrichResponse(text) {
|
|
|
142
197
|
|
|
143
198
|
if (!summary && lines.length) summary = lines[0].replace(/^SUMMARY:\s*/i, '').trim();
|
|
144
199
|
|
|
145
|
-
|
|
200
|
+
|
|
146
201
|
if (concepts.length === 0 && summary) {
|
|
147
202
|
const words = summary.toLowerCase().match(/\b[a-z][a-z-]{2,}\b/g) || [];
|
|
148
203
|
concepts.push(...words.filter(w => !_stop.has(w)).slice(0, 8));
|
|
@@ -196,11 +251,19 @@ export function parseRerankResponse(text, n) {
|
|
|
196
251
|
}
|
|
197
252
|
|
|
198
253
|
/**
|
|
199
|
-
* Rerank the fused top-M results with a local LLM
|
|
200
|
-
*
|
|
254
|
+
* Rerank the fused top-M results with a local LLM: the judge's order wins outright
|
|
255
|
+
* for the head, the unjudged tail is preserved. Best-effort — any failure (model
|
|
256
|
+
* unreachable, unparseable output) returns the original order untouched.
|
|
257
|
+
*
|
|
258
|
+
* A reciprocal-rank BLEND of judge-order with retrieval-order was A/B-tested (to
|
|
259
|
+
* anchor strongly-retrieved hits against judge mistakes) and measured WORSE on
|
|
260
|
+
* both rank-1 and MRR while not recovering recall@k — so the straight reorder is
|
|
261
|
+
* what ships. Recall@k is protected upstream instead, by over-fetching a deep pool
|
|
262
|
+
* before reranking (see mcp/tools.mjs search_code) so the judge reorders real
|
|
263
|
+
* candidates rather than a thin top_k.
|
|
201
264
|
*
|
|
202
265
|
* @param {string} queryText
|
|
203
|
-
* @param {Array<{score:number, chunk:object}>} results Fused results (
|
|
266
|
+
* @param {Array<{score:number, chunk:object}>} results Fused results (copy returned).
|
|
204
267
|
* @param {object} opts
|
|
205
268
|
* @param {(prompt:string)=>Promise<string|null>} opts.generate
|
|
206
269
|
* @param {number} [opts.topM] How many leading results to rerank (default 8).
|
|
@@ -270,9 +333,24 @@ export function selectCoreChunks(chunks, graph, { coreRatio = 1.0, maxChunks = 5
|
|
|
270
333
|
* @returns {Promise<{enriched:number, attempted:number, cached:number}>}
|
|
271
334
|
*/
|
|
272
335
|
export async function enrichCoreChunks(chunks, graph, config, { generate, concurrency, cachePath } = {}) {
|
|
273
|
-
const {
|
|
336
|
+
const { coreRatio, maxChunks } = config.enrichment;
|
|
274
337
|
const ollamaHost = config.ollamaHost;
|
|
275
|
-
const
|
|
338
|
+
const mlxLmHost = config.mlxLmHost;
|
|
339
|
+
const useMlx = config.llmProvider === 'mlx';
|
|
340
|
+
// Resolve "auto" → the strongest code model the local Ollama has (1.5B floor).
|
|
341
|
+
// A pinned generate (tests) skips the network probe — model is only used to
|
|
342
|
+
// build the default generator and to stamp cache provenance.
|
|
343
|
+
const model = generate ? config.enrichment.model
|
|
344
|
+
: (useMlx ? config.enrichment.model : await resolveEnrichModel(config.enrichment.model, ollamaHost));
|
|
345
|
+
// 60 s timeout: a larger judge model on a busy/CPU-bound local Ollama can take
|
|
346
|
+
// 10–25 s per generation; the old 20 s cap made every call fail there and aborted
|
|
347
|
+
// enrichment entirely. Concurrency defaults low for the same reason — parallel
|
|
348
|
+
// requests to one local model thrash and slow each other (set higher only when
|
|
349
|
+
// OLLAMA_NUM_PARALLEL and the hardware support it).
|
|
350
|
+
const gen = generate
|
|
351
|
+
|| (useMlx
|
|
352
|
+
? (prompt) => mlxLmGenerate(prompt, { mlxLmHost, timeoutMs: 60000 })
|
|
353
|
+
: (prompt) => ollamaGenerate(prompt, { model, ollamaHost, timeoutMs: 60000 }));
|
|
276
354
|
const slots = concurrency ?? config.enrichment.concurrency ?? 12;
|
|
277
355
|
const cacheFile = cachePath === false ? null : (cachePath || config.enrichmentCachePath || null);
|
|
278
356
|
const cache = cacheFile ? loadEnrichmentCache(cacheFile) : new Map();
|
|
@@ -292,22 +370,21 @@ export async function enrichCoreChunks(chunks, graph, config, { generate, concur
|
|
|
292
370
|
|
|
293
371
|
const core = selectCoreChunks(chunks, graph, { coreRatio, maxChunks });
|
|
294
372
|
if (core.length === 0 && cached === 0) {
|
|
295
|
-
console.log('🧠 LLM enrichment: no core chunks selected (empty graph) — skipping.');
|
|
373
|
+
console.log('\n 🧠 LLM enrichment: no core chunks selected (empty graph) — skipping.');
|
|
296
374
|
return { enriched: 0, attempted: 0, cached: 0 };
|
|
297
375
|
}
|
|
298
376
|
const pending = core.filter(c => !c.summary && !(c.concepts?.length > 0));
|
|
299
377
|
|
|
300
378
|
if (pending.length === 0) {
|
|
301
|
-
console.log(
|
|
379
|
+
console.log(`\n 🧠 LLM enrichment: all ${cached} core chunks served from cache.`);
|
|
302
380
|
return { enriched: cached, attempted: 0, cached };
|
|
303
381
|
}
|
|
304
382
|
|
|
305
|
-
console.log(
|
|
383
|
+
console.log(`\n 🧠 LLM enrichment: ${pending.length} chunks via ${model} (${cached} from cache, concurrency: ${slots}) …`);
|
|
306
384
|
let enriched = 0, attempted = 0, failures = 0;
|
|
307
385
|
let aborted = false;
|
|
308
386
|
|
|
309
|
-
|
|
310
|
-
// No batch boundaries — a worker immediately takes the next item when done.
|
|
387
|
+
|
|
311
388
|
let cursor = 0;
|
|
312
389
|
async function runWorker() {
|
|
313
390
|
while (cursor < pending.length && !aborted) {
|
|
@@ -318,7 +395,7 @@ export async function enrichCoreChunks(chunks, graph, config, { generate, concur
|
|
|
318
395
|
if (parsed) {
|
|
319
396
|
chunk.summary = parsed.summary;
|
|
320
397
|
chunk.concepts = parsed.concepts;
|
|
321
|
-
chunk.hyde = parsed.hyde;
|
|
398
|
+
chunk.hyde = parsed.hyde;
|
|
322
399
|
if (chunk.content_hash) {
|
|
323
400
|
cache.set(chunk.content_hash, { summary: parsed.summary, concepts: parsed.concepts, model });
|
|
324
401
|
}
|
|
@@ -327,8 +404,10 @@ export async function enrichCoreChunks(chunks, graph, config, { generate, concur
|
|
|
327
404
|
failures++;
|
|
328
405
|
}
|
|
329
406
|
process.stdout.write(`\r enriched ${enriched}/${pending.length} (failures: ${failures}) `);
|
|
330
|
-
// Bail early if the model is clearly unreachable.
|
|
331
|
-
|
|
407
|
+
// Bail early if the model is clearly unreachable. Require a real minimum
|
|
408
|
+
// of attempts (not just slots×2) so a slow first generation at low
|
|
409
|
+
// concurrency doesn't abort a perfectly healthy run.
|
|
410
|
+
if (attempted >= Math.max(8, slots * 2) && enriched === 0) {
|
|
332
411
|
aborted = true;
|
|
333
412
|
console.log(`\n⚠️ LLM enrichment: generator unreachable (${failures} failures) — continuing without enrichment.`);
|
|
334
413
|
}
|