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.
Files changed (60) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/README.md +216 -596
  3. package/SECURITY.md +68 -0
  4. package/TRADEMARK.md +31 -0
  5. package/cli-ui.mjs +78 -0
  6. package/config.mjs +179 -28
  7. package/daemon-ctl.mjs +175 -0
  8. package/daemon-lock.mjs +69 -0
  9. package/embedders/python/mlx_embed_server.py +61 -0
  10. package/embedders/python/requirements-mlx.txt +5 -0
  11. package/embedders/setup-mlx.mjs +110 -0
  12. package/embeddings.mjs +489 -0
  13. package/engine/binary.mjs +399 -0
  14. package/{core-engine.mjs → engine/memory.mjs} +107 -440
  15. package/{sqlite-store.mjs → engine/sqlite.mjs} +137 -42
  16. package/enrichment.mjs +111 -32
  17. package/git-signals.mjs +248 -0
  18. package/indexer.mjs +151 -42
  19. package/init.mjs +1603 -171
  20. package/layout.mjs +123 -0
  21. package/mcp/format.mjs +61 -0
  22. package/mcp/tools.mjs +1157 -0
  23. package/mcp/topology.mjs +551 -0
  24. package/mcp-server.mjs +55 -24
  25. package/package.json +44 -13
  26. package/parse/extractor.mjs +617 -0
  27. package/parse/imports.mjs +365 -0
  28. package/parse/languages.mjs +89 -0
  29. package/parse/metadata.mjs +655 -0
  30. package/parse/routes.mjs +327 -0
  31. package/prompts/CORE.md +86 -0
  32. package/prompts/DOMAIN_TEMPLATE.md +46 -0
  33. package/prompts/INTEGRATION.md +123 -0
  34. package/prompts/frameworks/ANDROID.md +17 -0
  35. package/prompts/frameworks/ASPNET_CORE.md +19 -0
  36. package/prompts/frameworks/FASTAPI_DJANGO.md +19 -0
  37. package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
  38. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +18 -0
  39. package/prompts/frameworks/RAILS.md +20 -0
  40. package/prompts/frameworks/REACT.md +20 -0
  41. package/prompts/frameworks/SPRING_BOOT.md +19 -0
  42. package/prompts/languages/BASH.md +22 -0
  43. package/prompts/languages/C.md +21 -0
  44. package/prompts/languages/CSHARP.md +24 -0
  45. package/prompts/languages/CSS_SCSS.md +22 -0
  46. package/prompts/languages/GO.md +23 -0
  47. package/prompts/languages/JAVA.md +23 -0
  48. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
  49. package/prompts/languages/KOTLIN.md +24 -0
  50. package/prompts/languages/PHP.md +23 -0
  51. package/prompts/languages/PYTHON.md +23 -0
  52. package/prompts/languages/RUBY.md +23 -0
  53. package/prompts/languages/RUST.md +21 -0
  54. package/prompts/languages/SWIFT.md +21 -0
  55. package/search-core.mjs +254 -43
  56. package/storage.mjs +28 -3
  57. package/watch-daemon.mjs +69 -23
  58. package/PROMPT.md +0 -182
  59. package/mcp-tools.mjs +0 -545
  60. package/parser-utils.mjs +0 -1170
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @file sqlite-store.mjs
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
- SUMMARY_VEC_SUFFIX, LEXICAL_FUSION_CAP, VECTOR_SCAN_RAW_N, finalizeVectorCandidates,
32
- } from './search-core.mjs';
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 './core-engine.mjs';
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
- const SKETCH_THRESHOLD = 10000; // build the binary sketch above this many vectors.
43
- // Below it the exact streaming scan stays under ~20 ms
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
- // 1. Lexical BM25 over posting lists (one indexed lookup per query term).
412
- const qTokens = tokenize(queryText);
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
- // 2. Vector channel: stream the FULL embeddings bin (bounded buffers) so a
433
- // conceptual query that shares no tokens with the code can still match.
434
- // The old design scored only lexical candidates, which silently turned
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 vectors live under `<key>|s` — fold onto the chunk.
450
- const baseKey = key.endsWith(SUMMARY_VEC_SUFFIX)
451
- ? key.slice(0, -SUMMARY_VEC_SUFFIX.length) : key;
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 tokens = tokenize(buildLexicalDocument(chunk, imports));
513
- const docLen = tokens.length;
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
- // 1. Append new vectors first — orphan bytes on a failed commit are
561
- // harmless and compacted away by the next full index run.
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
- // 2. Capture reusable vector offsets for incoming chunks BEFORE the
578
- // file's old rows (which may hold those offsets) are deleted.
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;'); // fast bulk load; rebuilt artifact
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
- // Fresh build — drop any prior content (inside the txn: readers keep the
701
- // old index until COMMIT, instead of briefly seeing empty tables).
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 tokens = tokenize(buildLexicalDocument(chunk, deps));
720
- const docLen = tokens.length;
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
- // Two-line output format optimised for small (1.5B–3B) models:
62
- // • SUMMARY — declarative sentence in developer vocabulary; goes into the vector
63
- // embedding as the leading field, aligned with nomic-embed-text's
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, 700);
65
+ const code = (chunk.code_snippet || '').slice(0, 1000);
71
66
  const base = path.basename(chunk.file_path);
72
- const ctx = chunk.class_context ? ` (method of class ${chunk.class_context})` : '';
73
- const doc = chunk.docstring ? `Doc: ${chunk.docstring.slice(0, 200)}\n` : '';
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
- `Output exactly two lines for this ${chunk.node_type} "${chunk.name}"${ctx} in ${base}:\n`
76
- + `SUMMARY: one sentence describing what it DOES in plain developer vocabulary (the behavior, not the syntax)\n`
77
- + `TAGS: 5-8 comma-separated search keywords a developer would type to find this\n\n`
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 authentication sessions\n`
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
- * hyde = concepts.join(' ') — a space-separated keyword string fed to BM25.
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
- // Fallback: derive concepts from the summary when TAGS line is absent or empty.
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. Best-effort: any failure
200
- * (model unreachable, unparseable output) returns the original order.
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 (mutated copy returned).
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 { model, coreRatio, maxChunks } = config.enrichment;
336
+ const { coreRatio, maxChunks } = config.enrichment;
274
337
  const ollamaHost = config.ollamaHost;
275
- const gen = generate || ((prompt) => ollamaGenerate(prompt, { model, ollamaHost, timeoutMs: 20000 }));
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(`🧠 LLM enrichment: all ${cached} core chunks served from cache.`);
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(`🧠 LLM enrichment: ${pending.length} chunks via ${model} (${cached} from cache, concurrency: ${slots}) …`);
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
- // Sliding-window pool: `slots` workers each drain the queue independently.
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; // = concepts.join(' ') — domain keywords for BM25
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
- if (attempted >= slots * 2 && enriched === 0) {
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
  }