graph-indexer 1.2.0 → 2.1.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 (70) hide show
  1. package/CHANGELOG.md +854 -0
  2. package/README.md +258 -595
  3. package/SECURITY.md +85 -0
  4. package/TRADEMARK.md +31 -0
  5. package/cli-ui.mjs +78 -0
  6. package/colbert.mjs +168 -0
  7. package/config.mjs +408 -32
  8. package/daemon-ctl.mjs +175 -0
  9. package/daemon-lock.mjs +69 -0
  10. package/embedders/python/mlx_embed_server.py +61 -0
  11. package/embedders/python/requirements-mlx.txt +5 -0
  12. package/embedders/setup-mlx.mjs +110 -0
  13. package/embeddings.mjs +571 -0
  14. package/engine/binary.mjs +437 -0
  15. package/{core-engine.mjs → engine/memory.mjs} +321 -441
  16. package/{sqlite-store.mjs → engine/sqlite.mjs} +413 -45
  17. package/enrichment.mjs +206 -32
  18. package/git-signals.mjs +248 -0
  19. package/indexer.mjs +399 -45
  20. package/init.mjs +1626 -171
  21. package/layout.mjs +123 -0
  22. package/mcp/centrality.mjs +117 -0
  23. package/mcp/format.mjs +61 -0
  24. package/mcp/resolver.mjs +116 -0
  25. package/mcp/symbolgraph.mjs +137 -0
  26. package/mcp/taint.mjs +225 -0
  27. package/mcp/tools.mjs +1582 -0
  28. package/mcp/topology.mjs +676 -0
  29. package/mcp-server.mjs +80 -25
  30. package/package.json +66 -27
  31. package/parse/extractor.mjs +628 -0
  32. package/parse/imports.mjs +365 -0
  33. package/parse/interprocedural.mjs +134 -0
  34. package/parse/languages.mjs +253 -0
  35. package/parse/metadata.mjs +701 -0
  36. package/parse/routes.mjs +327 -0
  37. package/parse/scip.mjs +391 -0
  38. package/parse/taint-patterns.mjs +119 -0
  39. package/prompts/CORE.md +91 -0
  40. package/prompts/DOMAIN_TEMPLATE.md +46 -0
  41. package/prompts/INTEGRATION.md +123 -0
  42. package/prompts/frameworks/ANDROID.md +17 -0
  43. package/prompts/frameworks/ASPNET_CORE.md +19 -0
  44. package/prompts/frameworks/FASTAPI_DJANGO.md +20 -0
  45. package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
  46. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +19 -0
  47. package/prompts/frameworks/RAILS.md +20 -0
  48. package/prompts/frameworks/REACT.md +20 -0
  49. package/prompts/frameworks/SPRING_BOOT.md +20 -0
  50. package/prompts/languages/BASH.md +22 -0
  51. package/prompts/languages/C.md +21 -0
  52. package/prompts/languages/CSHARP.md +24 -0
  53. package/prompts/languages/CSS_SCSS.md +22 -0
  54. package/prompts/languages/GO.md +24 -0
  55. package/prompts/languages/JAVA.md +23 -0
  56. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
  57. package/prompts/languages/KOTLIN.md +24 -0
  58. package/prompts/languages/PHP.md +23 -0
  59. package/prompts/languages/PYTHON.md +23 -0
  60. package/prompts/languages/RUBY.md +23 -0
  61. package/prompts/languages/RUST.md +21 -0
  62. package/prompts/languages/SWIFT.md +21 -0
  63. package/seal.mjs +400 -0
  64. package/search-core.mjs +405 -44
  65. package/search-sparse.mjs +183 -0
  66. package/storage.mjs +33 -3
  67. package/watch-daemon.mjs +75 -24
  68. package/PROMPT.md +0 -182
  69. package/mcp-tools.mjs +0 -545
  70. package/parser-utils.mjs +0 -1170
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @file search-sparse.mjs
3
+ * @description B3 — opt-in LEARNED-SPARSE vocabulary-expansion channel. A learned-sparse retriever
4
+ * (SPLADE / uniCOIL family) expands a query into a WEIGHTED term set — original terms
5
+ * plus learned associates — and fuses that as an extra sparse channel, closing
6
+ * lexical-gap misses the Porter-stemming bridge structurally cannot (stemming only
7
+ * normalises morphology; it cannot connect "auth" → "credential"/"token").
8
+ *
9
+ * THE AIR-GAPPED, ZERO-DEPENDENCY REALISATION: rather than require a neural MLM
10
+ * (which needs a downloaded model + @huggingface/transformers and cannot be measured
11
+ * air-gapped), the SHIPPED provider LEARNS the term associations FROM THE CORPUS at
12
+ * index time via positive pointwise mutual information (PMI) over chunk co-occurrence.
13
+ * It is genuine learned-sparse — learned per-term weights + vocabulary expansion — with
14
+ * ZERO new dependency, fully deterministic, and measurable on the honest `test:eval`.
15
+ * A neural SPLADE provider is a clean future drop-in behind the same `expandSparseQuery`
16
+ * seam (see docs/internals/IMPROVEMENT_LEARNED_SPARSE.md).
17
+ *
18
+ * SOUNDNESS / SACRED DEFAULT: the model is built ONLY with --learned-sparse and
19
+ * serialized as an ISOLATED index artifact (like centrality/taint/scip_refs), so it
20
+ * cannot perturb the lexical postings or the default path. The channel is
21
+ * NL-ASYMMETRIC — it fires only for natural-language queries with a real expansion —
22
+ * so symbolic/exact lookups stay byte-identical. Scoring is delegated to a SHARED
23
+ * core (search-core.scoreSparseExpanded) that both backends call through their own
24
+ * postings accessors → parity by construction.
25
+ * @author MaquinaTech <https://github.com/MaquinaTech>
26
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
27
+ * @license MIT
28
+ */
29
+ import { tokenize } from './search-core.mjs';
30
+
31
+ // ── Model-build knobs (structural, not tuned to any query) ──────────────────────────────────
32
+ const MIN_TOKEN_LEN = 3; // ignore 1-2 char noise
33
+ const MAX_TOKENS_PER_CHUNK = 40; // cap co-occurrence fan-out per chunk (most-discriminative first)
34
+ const MIN_DF = 2; // a term must occur in ≥2 chunks to carry association signal
35
+ const MAX_DF_RATIO = 0.30; // skip ultra-common terms (in >30% of chunks → no discriminative value)
36
+ const MIN_COOC = 2; // a pair must co-occur in ≥2 chunks (kills incidental pairs)
37
+ const TOPK_ASSOC = 8; // keep the strongest K associates per term
38
+ const PMI_NORM = 6; // PMI (log2) normaliser → stored weight = clamp(pmi/6, 0..1)
39
+
40
+ // ── Query-expansion knobs ───────────────────────────────────────────────────────────────────
41
+ const EXPANSION_SCALE = 0.35; // an associate term contributes ≤0.35× a real query term
42
+ const MAX_QUERY_EXPANSIONS = 12; // total associate terms added across the whole query
43
+ const MIN_EXPANSION_WEIGHT = 0.05;// drop near-zero associates (no point fusing them)
44
+
45
+ /** Distinct, length-filtered RAW tokens of a text (no stems — associations are surface-form). */
46
+ function distinctTokens(text) {
47
+ const out = new Set();
48
+ for (const t of tokenize(text, false)) if (t.length >= MIN_TOKEN_LEN) out.add(t);
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Learn the sparse association model from the corpus. Two passes:
54
+ * 1. document frequency per token (over distinct per-chunk tokens);
55
+ * 2. co-occurrence counts over each chunk's top-MAX_TOKENS_PER_CHUNK most-discriminative tokens
56
+ * (rarest first by df), then positive PMI per pair, keeping the top-K associates per term.
57
+ *
58
+ * Deterministic: every map is materialised through SORTED keys, and stored weights are rounded, so
59
+ * the serialized model is byte-identical run to run and the two backends read identical bytes.
60
+ *
61
+ * @param {Iterable<{code_snippet?:string,name?:string,docstring?:string}>} chunks the index chunks
62
+ * @param {object} [opts]
63
+ * @returns {{ assoc: Record<string, Array<[string, number]>>, meta: object } | null} null if no signal
64
+ */
65
+ export function buildSparseModel(chunks, opts = {}) {
66
+ const minDf = opts.minDf ?? MIN_DF;
67
+ const maxDfRatio = opts.maxDfRatio ?? MAX_DF_RATIO;
68
+ const topK = opts.topKAssoc ?? TOPK_ASSOC;
69
+
70
+ // The text a chunk contributes its vocabulary from — name + docstring + the snippet.
71
+ const chunkText = (c) => `${c.name || ''} ${c.docstring || ''} ${c.summary || ''} ${c.code_snippet || ''}`;
72
+
73
+ // Pass 1: per-chunk distinct tokens + document frequency.
74
+ const perChunk = [];
75
+ const df = new Map();
76
+ for (const c of chunks) {
77
+ const toks = distinctTokens(chunkText(c));
78
+ if (toks.size < 2) { perChunk.push(null); continue; }
79
+ perChunk.push(toks);
80
+ for (const t of toks) df.set(t, (df.get(t) || 0) + 1);
81
+ }
82
+ const docCount = perChunk.length;
83
+ if (!docCount) return null;
84
+ const maxDf = Math.max(minDf, Math.floor(maxDfRatio * docCount));
85
+
86
+ // A token carries association signal only inside the df band [minDf, maxDf].
87
+ const eligible = (t) => { const d = df.get(t) || 0; return d >= minDf && d <= maxDf; };
88
+
89
+ // Pass 2: co-occurrence over each chunk's most-discriminative (rarest) eligible tokens.
90
+ const cooc = new Map(); // term → Map<term, count>
91
+ const bump = (a, b) => {
92
+ let m = cooc.get(a); if (!m) { m = new Map(); cooc.set(a, m); }
93
+ m.set(b, (m.get(b) || 0) + 1);
94
+ };
95
+ for (const toks of perChunk) {
96
+ if (!toks) continue;
97
+ const elig = [...toks].filter(eligible);
98
+ if (elig.length < 2) continue;
99
+ // rarest-first (df asc, then lexical) → cap fan-out deterministically
100
+ elig.sort((a, b) => (df.get(a) - df.get(b)) || (a < b ? -1 : a > b ? 1 : 0));
101
+ const top = elig.slice(0, MAX_TOKENS_PER_CHUNK);
102
+ for (let i = 0; i < top.length; i++) {
103
+ for (let j = i + 1; j < top.length; j++) {
104
+ bump(top[i], top[j]); bump(top[j], top[i]); // symmetric
105
+ }
106
+ }
107
+ }
108
+
109
+ // Positive PMI per pair → top-K associates per term, weight = clamp(pmi / PMI_NORM, 0..1).
110
+ const log2 = (x) => Math.log(x) / Math.LN2;
111
+ const assoc = {};
112
+ let pairs = 0;
113
+ for (const term of [...cooc.keys()].sort()) {
114
+ const dfa = df.get(term);
115
+ const row = [];
116
+ for (const [other, c] of cooc.get(term)) {
117
+ if (c < MIN_COOC) continue;
118
+ const dfb = df.get(other);
119
+ // PMI = log2( P(a,b) / (P(a) P(b)) ) = log2( c·N / (dfa·dfb) )
120
+ const pmi = log2((c * docCount) / (dfa * dfb));
121
+ if (pmi <= 0) continue;
122
+ const w = Math.min(1, pmi / PMI_NORM);
123
+ if (w < MIN_EXPANSION_WEIGHT) continue;
124
+ row.push([other, Number(w.toFixed(4))]);
125
+ }
126
+ if (!row.length) continue;
127
+ // strongest first; lexical tie-break for determinism; keep top-K
128
+ row.sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1));
129
+ assoc[term] = row.slice(0, topK);
130
+ pairs += assoc[term].length;
131
+ }
132
+ if (!pairs) return null;
133
+
134
+ return {
135
+ assoc,
136
+ meta: { terms: Object.keys(assoc).length, pairs, docs: docCount, metric: 'pmi', version: 1 },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Expand a natural-language query into a WEIGHTED term set for the sparse channel: the learned
142
+ * ASSOCIATE terms only (the original query terms already drive the lexical channel, so the sparse
143
+ * channel is a pure vocabulary-EXPANSION view — it surfaces chunks that match the semantically
144
+ * associated vocabulary the literal query never mentions). Deterministic insertion order (query-token
145
+ * order, then per-token associate order) so both backends accumulate scores identically.
146
+ *
147
+ * Returns null when nothing fires (no model, no eligible token, no association) — the caller then
148
+ * runs ZERO sparse channel and the result is byte-identical to the default path.
149
+ *
150
+ * @param {string} queryText
151
+ * @param {{assoc:Record<string,Array<[string,number]>>}} model
152
+ * @param {object} [opts]
153
+ * @returns {Map<string, number> | null} associate term → fusion weight, or null
154
+ */
155
+ export function expandSparseQuery(queryText, model, opts = {}) {
156
+ if (!model || !model.assoc) return null;
157
+ const scale = opts.scale ?? EXPANSION_SCALE;
158
+ const maxExp = opts.maxExpansions ?? MAX_QUERY_EXPANSIONS;
159
+ const assoc = model.assoc;
160
+
161
+ const qTokens = [...distinctTokens(queryText)]; // raw surface forms, query order via Set
162
+ const weights = new Map(); // associate term → best weight seen
163
+ for (const qt of qTokens) {
164
+ const row = assoc[qt];
165
+ if (!row) continue;
166
+ for (const [term, w] of row) {
167
+ if (qTokens.includes(term)) continue; // don't re-add a literal query term
168
+ const ew = scale * w;
169
+ if (ew < MIN_EXPANSION_WEIGHT) continue;
170
+ const prev = weights.get(term);
171
+ if (prev === undefined || ew > prev) weights.set(term, ew);
172
+ }
173
+ }
174
+ if (!weights.size) return null;
175
+
176
+ // Keep the strongest MAX_QUERY_EXPANSIONS, but REBUILD the map in deterministic order
177
+ // (weight desc, then term asc) so both backends iterate — and float-accumulate — identically.
178
+ const ranked = [...weights.entries()]
179
+ .sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1))
180
+ .slice(0, maxExp);
181
+ if (!ranked.length) return null;
182
+ return new Map(ranked);
183
+ }
package/storage.mjs CHANGED
@@ -17,13 +17,43 @@
17
17
  * getChunksByFile(path) → chunk[]
18
18
  * resolveSymbol(name) → chunk[] (exact, case-insensitive)
19
19
  * findCallers(funcName) → chunk[]
20
+ * findReferers(symbol) → chunk[] (type_refs / extends matches)
21
+ * getEdges(chunkId, {kind?, direction}) → edge[] (A4 resolved symbol graph; [] when off)
22
+ * hasSymbolGraph() / hasCentrality() → boolean (opt-in A4 / A5 layers present?)
23
+ * getCentrality(chunkId) → { score, rank, total } | null (A5 symbol PageRank)
24
+ * topCentral(limit) → [{ chunk, score, rank }] (A5, rank-ascending)
25
+ * hasTaint() / getTaintFlows() → boolean / { flows, meta } | null (C2 serialized taint, --taint)
26
+ * findRoutes({method, pathPrefix}) → route[] (HTTP route → handler chunk)
20
27
  * iterateChunks() → Iterable<chunk> (cursor on SQLite)
21
28
  * getDependencies(path) / getImportedBy(path) → string[]
22
29
  * chunkCount() / fileCount() / symbolCount() / vectorCount() → number
23
30
  * stats() → engine health facts
24
31
  * close() Release fds / db handles.
25
32
  */
26
- import { MemoryGraphIndex } from './core-engine.mjs';
33
+ import fs from 'fs';
34
+ import { MemoryGraphIndex } from './engine/memory.mjs';
35
+
36
+ // 'auto' storage keeps the index in memory until a repo is large enough that the
37
+ // disk-backed SQLite store earns its slightly higher per-query latency. The indexer
38
+ // decides by the real chunk count it just built; readers decide by which artifact
39
+ // exists on disk (see resolveBackend).
40
+ export const AUTO_SQLITE_CHUNK_THRESHOLD = 15000;
41
+
42
+ /**
43
+ * Resolve the abstract storage setting ('auto'|'memory'|'sqlite') to a concrete
44
+ * read backend. Explicit settings win; 'auto' picks SQLite when a SQLite artifact
45
+ * is present (the indexer writes one only for large repos and removes the other
46
+ * backend's artifact, so presence is unambiguous), else the in-memory JSON index.
47
+ *
48
+ * @param {object} config Resolved config from config.mjs.
49
+ * @returns {'memory'|'sqlite'}
50
+ */
51
+ export function resolveBackend(config) {
52
+ if (config.storage === 'sqlite') return 'sqlite';
53
+ if (config.storage === 'memory') return 'memory';
54
+ try { if (fs.existsSync(config.sqlitePath)) return 'sqlite'; } catch { /* fall through to memory */ }
55
+ return 'memory';
56
+ }
27
57
 
28
58
  /**
29
59
  * Construct (but do not yet load) the configured store.
@@ -34,9 +64,9 @@ import { MemoryGraphIndex } from './core-engine.mjs';
34
64
  * @returns {Promise<object>} a store implementing the contract above.
35
65
  */
36
66
  export async function createStore(config, { cacheEmbeddings = false } = {}) {
37
- if (config.storage === 'sqlite') {
67
+ if (resolveBackend(config) === 'sqlite') {
38
68
  // Imported lazily so the default path never loads node:sqlite.
39
- const { SqliteGraphStore } = await import('./sqlite-store.mjs');
69
+ const { SqliteGraphStore } = await import('./engine/sqlite.mjs');
40
70
  return new SqliteGraphStore(config.sqlitePath, { embeddingPath: config.embeddingPath });
41
71
  }
42
72
  return new MemoryGraphIndex(config.indexPath, { cacheEmbeddings });
package/watch-daemon.mjs CHANGED
@@ -27,24 +27,53 @@ import fs from 'fs';
27
27
  import path from 'path';
28
28
  import chokidar from 'chokidar';
29
29
  import { resolveConfig } from './config.mjs';
30
- import { createStore } from './storage.mjs';
30
+ import { ensureDataDir, migrateLegacyLayout } from './layout.mjs';
31
+ import { acquireLock, releaseLock, readPid } from './daemon-lock.mjs';
32
+ import { createStore, resolveBackend } from './storage.mjs';
31
33
  import {
32
34
  embeddingKeyFor, computePageRank, TEST_FILE_RE, EXAMPLE_DIR_RE,
33
- summaryEmbeddingText, SUMMARY_VEC_SUFFIX,
35
+ summaryEmbeddingText, SUMMARY_VEC_SUFFIX, WINDOW_VEC_SUFFIX, embeddingWindows,
34
36
  } from './search-core.mjs';
35
- import {
36
- MAX_FILE_SIZE_BYTES, getParserForFile, buildIgnoreFilter,
37
- extractImportsFromAST, extractSemanticChunks, resolveLocalImports, getLocalEmbeddingsBatch,
38
- buildEmbeddingPayload,
39
- } from './parser-utils.mjs';
37
+ import { MAX_FILE_SIZE_BYTES, buildIgnoreFilter, extractImportsFromAST, extractSemanticChunks } from './parse/extractor.mjs';
38
+ import { getParserForFile, ensureLanguagesReady } from './parse/languages.mjs';
39
+ import { resolveLocalImports, buildEmbeddingPayload, fullBodyForEmbedding } from './parse/imports.mjs';
40
+ import { createEmbedder, readEmbedMeta } from './embeddings.mjs';
40
41
  import {
41
42
  loadEnrichmentCache, saveEnrichmentCache, attachEnrichment,
42
- ollamaGenerate, parseEnrichResponse, buildEnrichPrompt,
43
+ ollamaGenerate, parseEnrichResponse, buildEnrichPrompt, resolveEnrichModel,
43
44
  } from './enrichment.mjs';
44
45
 
45
46
  const config = resolveConfig();
46
47
  const PROJECT_ROOT = config.projectRoot;
47
48
 
49
+ // Embed incremental updates with the SAME provider/model the index was built with
50
+ // (stamped in the embeddings-bin sidecar), so live vectors match the bootstrap
51
+ // ones. Built lazily and cached for the daemon's lifetime.
52
+ let _embedder = null;
53
+ async function getEmbedder() {
54
+ if (_embedder) return _embedder;
55
+ const m = readEmbedMeta(config.embeddingPath);
56
+ _embedder = await createEmbedder(config, m?.provider
57
+ ? { provider: m.provider, model: m.model }
58
+ : { provider: config.embeddingsEnabled ? 'ollama' : 'off', model: config.embedModel });
59
+ return _embedder;
60
+ }
61
+
62
+ // ─── Single-instance guard ──────────────────────────────────────────────────────
63
+ // Exactly one daemon may run per project. Acquire the PID lock BEFORE doing any
64
+ // work (loading the store, opening the db) so a redundant launch — via the MCP
65
+ // server, `idx-daemon start`, or a stray `idx-watch` — exits immediately instead
66
+ // of racing a second watcher onto the same index.
67
+ ensureDataDir(PROJECT_ROOT);
68
+ migrateLegacyLayout(PROJECT_ROOT);
69
+ if (!acquireLock(config.pidFile)) {
70
+ process.stderr.write(
71
+ `✋ A graph-indexer daemon is already running for this project `
72
+ + `(PID ${readPid(config.pidFile)}). Exiting — only one runs per project.\n`
73
+ );
74
+ process.exit(0);
75
+ }
76
+
48
77
  const ignoreFilter = buildIgnoreFilter(PROJECT_ROOT);
49
78
  const db = await createStore(config, { cacheEmbeddings: true });
50
79
  db.load();
@@ -69,6 +98,14 @@ function scheduleEnrichCacheSave() {
69
98
  }, 3000);
70
99
  }
71
100
 
101
+ // "auto" resolves to the strongest available local code model, same policy as the bootstrap indexer.
102
+ let _enrichModel = null;
103
+ async function getEnrichModel() {
104
+ if (_enrichModel) return _enrichModel;
105
+ _enrichModel = await resolveEnrichModel(config.enrichment.model, config.ollamaHost);
106
+ return _enrichModel;
107
+ }
108
+
72
109
  let _coreFiles = null;
73
110
  function isEnrichableFile(filename) {
74
111
  if (TEST_FILE_RE.test(filename) || EXAMPLE_DIR_RE.test(filename)) return false;
@@ -94,10 +131,11 @@ async function enrichChunks(filename, chunks) {
94
131
  // Live enrichment of changed core chunks: sequential and best-effort — a file
95
132
  // save touches a handful of chunks, so latency stays low and failures only
96
133
  // mean "no enrichment until the next full index run".
134
+ const model = await getEnrichModel();
97
135
  for (const chunk of pending) {
98
136
  if ((chunk.end_line - chunk.start_line) < 4) continue; // trivial stubs
99
137
  const raw = await ollamaGenerate(buildEnrichPrompt(chunk), {
100
- model: config.enrichment.model, ollamaHost: config.ollamaHost, timeoutMs: 20000,
138
+ model, ollamaHost: config.ollamaHost, timeoutMs: 20000,
101
139
  });
102
140
  const parsed = parseEnrichResponse(raw);
103
141
  if (!parsed) break; // model unreachable — stop trying for this batch
@@ -105,7 +143,7 @@ async function enrichChunks(filename, chunks) {
105
143
  chunk.concepts = parsed.concepts;
106
144
  chunk.hyde = parsed.hyde;
107
145
  enrichCache.set(chunk.content_hash, {
108
- summary: parsed.summary, concepts: parsed.concepts, model: config.enrichment.model,
146
+ summary: parsed.summary, concepts: parsed.concepts, model,
109
147
  });
110
148
  enrichCacheDirty = true;
111
149
  }
@@ -118,7 +156,8 @@ async function enrichChunks(filename, chunks) {
118
156
  // not modified since the index artifact was last written are already indexed —
119
157
  // skipping them turns daemon start from O(repo) parse work into O(changed files),
120
158
  // while still picking up edits made while the daemon was down.
121
- const ARTIFACT_PATH = config.storage === 'sqlite' ? config.sqlitePath : config.indexPath;
159
+ const BACKEND = resolveBackend(config); // 'auto' → concrete backend by artifact presence.
160
+ const ARTIFACT_PATH = BACKEND === 'sqlite' ? config.sqlitePath : config.indexPath;
122
161
  let indexBuiltAt = 0;
123
162
  try { indexBuiltAt = fs.statSync(ARTIFACT_PATH).mtimeMs; } catch { /* no index yet → full scan */ }
124
163
  let initialScan = true;
@@ -158,25 +197,29 @@ async function processFileChange(absolutePath) {
158
197
  const newChunks = extractSemanticChunks(tree.rootNode, filename, content, ext);
159
198
  _coreFiles = null; // dependency graph may change → PageRank core set is stale
160
199
 
161
- // Enrichment BEFORE embedding so the summary rides the vector payload
162
- // (embeddingKeyFor accounts for it, so cache lookups stay correct).
200
+ // Enrichment BEFORE embedding: summary rides the vector payload and
201
+ // embeddingKeyFor accounts for it, so cache lookups stay correct.
163
202
  await enrichChunks(filename, newChunks);
164
203
 
165
- // 🥇 STRICT PAYLOAD PARITY: identical embedding payloads to indexer.mjs
166
- // (shared helpers) so incremental updates match the bootstrap index —
167
- // including the summary-only second vector for enriched chunks.
204
+ // Identical embedding payloads to indexer.mjs (shared helpers) so incremental
205
+ // updates match the bootstrap index, including summary-only vectors for enriched chunks.
168
206
  const chunksToEmbed = newChunks.filter(c => !db.hasEmbedding(embeddingKeyFor(c)));
169
207
  const embeddings = new Map();
170
208
  if (chunksToEmbed.length > 0) {
171
209
  const entries = [];
172
210
  for (const c of chunksToEmbed) {
173
- entries.push({ key: embeddingKeyFor(c), text: buildEmbeddingPayload(c, imports) });
211
+ const baseKey = embeddingKeyFor(c);
212
+ // Oversized definitions embed their full body (windowed) while the lexical path
213
+ // keeps the 3000-char snippet — parity with how indexer.mjs builds payloads.
214
+ const payload = buildEmbeddingPayload(c, imports, fullBodyForEmbedding(c, content));
215
+ entries.push({ key: baseKey, text: payload });
216
+ const windows = embeddingWindows(payload); // tail windows for oversized chunks
217
+ for (let i = 1; i < windows.length; i++) entries.push({ key: baseKey + WINDOW_VEC_SUFFIX + i, text: windows[i] });
174
218
  const sText = summaryEmbeddingText(c);
175
- if (sText) entries.push({ key: embeddingKeyFor(c) + SUMMARY_VEC_SUFFIX, text: sText });
219
+ if (sText) entries.push({ key: baseKey + SUMMARY_VEC_SUFFIX, text: sText });
176
220
  }
177
- const embeddingsMatrix = await getLocalEmbeddingsBatch(entries.map(e => e.text), true, {
178
- ollamaHost: config.ollamaHost, model: config.embedModel,
179
- });
221
+ const embedder = await getEmbedder();
222
+ const embeddingsMatrix = await embedder.embedDocuments(entries.map(e => e.text));
180
223
  if (embeddingsMatrix && embeddingsMatrix.length === entries.length) {
181
224
  entries.forEach((entry, j) => {
182
225
  if (embeddingsMatrix[j]) embeddings.set(entry.key, new Float32Array(embeddingsMatrix[j]));
@@ -187,7 +230,7 @@ async function processFileChange(absolutePath) {
187
230
  db.applyFileUpdate(filename, { chunks: newChunks, imports, embeddings });
188
231
  process.stderr.write(
189
232
  `[daemon] 🔄 Synced: ${filename} (${newChunks.length} chunks, `
190
- + `${newChunks.length - chunksToEmbed.length} cached vectors, ${config.storage})\n`
233
+ + `${newChunks.length - chunksToEmbed.length} cached vectors, ${BACKEND})\n`
191
234
  );
192
235
  } catch (err) {
193
236
  process.stderr.write(`[daemon] ❌ Error in ${filename}: ${err.message}\n`);
@@ -196,12 +239,17 @@ async function processFileChange(absolutePath) {
196
239
 
197
240
  // Serialize updates: chokidar can fire bursts (git checkout, format-on-save across
198
241
  // files); per-file work must not interleave inside the store.
199
- let queue = Promise.resolve();
242
+ // Seeds the update chain: the daemon inherits its language set from whatever the
243
+ // last `idx-index` run resolved, and won't autoInstall (grammars are provisioned
244
+ // at index time, not silently mid-watch) — a language added after the daemon
245
+ // started is picked up on the next full `idx-index`, same as other whole-program
246
+ // passes (see interprocedural.mjs).
247
+ let queue = ensureLanguagesReady({ projectRoot: PROJECT_ROOT, enabledLangs: config.languages, autoInstall: false, log: (msg) => process.stderr.write(msg + '\n') });
200
248
  function enqueue(absolutePath) {
201
249
  queue = queue.then(() => processFileChange(absolutePath)).catch(() => {});
202
250
  }
203
251
 
204
- process.stderr.write(`🚀 Watcher Daemon started in: ${PROJECT_ROOT} (backend: ${config.storage})\n`);
252
+ process.stderr.write(`🚀 Watcher Daemon started in: ${PROJECT_ROOT} (backend: ${BACKEND})\n`);
205
253
 
206
254
  // Only watch source files we can actually index. Critically, this prevents
207
255
  // chokidar from descending into node_modules / dist / .git / .gitignored dirs —
@@ -236,3 +284,6 @@ function shutdown() {
236
284
  }
237
285
  process.on('SIGTERM', shutdown);
238
286
  process.on('SIGINT', shutdown);
287
+ // Always relinquish the single-instance lock on the way out (covers shutdown(),
288
+ // uncaught exits and normal termination) so the next launch can take over cleanly.
289
+ process.on('exit', () => releaseLock(config.pidFile));
package/PROMPT.md DELETED
@@ -1,182 +0,0 @@
1
- # graph-indexer — Agent Instructions
2
-
3
- ---
4
-
5
- ## Prime directive
6
-
7
- This codebase is **pre-indexed**. The `graph-indexer` MCP server answers code
8
- questions from an AST-precise search index in tens of tokens, with cross-file
9
- topology your file tools cannot see.
10
-
11
- **Every code discovery task starts with a graph-indexer tool. Never start with
12
- a native file tool** — no `read file`, `list directory`, `grep`, `codebase
13
- search`, `find`, or equivalent — regardless of what your IDE calls them.
14
- Reading a file to "look around" costs 5,000–15,000 tokens; the same answer via
15
- the index costs 50–700. The difference compounds over every step of your
16
- session.
17
-
18
- Native tools are a **last resort**, permitted only under the fallback rules at
19
- the end of this document. Editing files is unaffected — write code with your
20
- normal tools; *discovery* belongs to the index.
21
-
22
- ---
23
-
24
- ## The five rules
25
-
26
- 1. **Search before you read.** First step of any discovery task is
27
- `search_code`, `resolve_symbol`, or `get_repo_map` — never a file read.
28
- 2. **Know the name? Don't search it.** `resolve_symbol("validateToken")` is an
29
- O(1) exact lookup with topology included. Use it whenever you know the
30
- symbol's exact name. Use `search_code` only for concepts and behaviour.
31
- 3. **Never read a whole file to get one function.** Search results give you a
32
- chunk ID → `get_chunk(id)` returns exactly that function's body.
33
- 4. **Never change a signature blind.** Before modifying any exported
34
- function/class, `get_call_graph("name")` lists every caller repo-wide in
35
- one call. Skipping this breaks code you cannot see.
36
- 5. **Escalate token spend stepwise.** signatures → summary → smart → full
37
- body. Start cheap; pay for bodies only when you must (ladder below).
38
-
39
- ---
40
-
41
- ## Tool selection — decision table
42
-
43
- | You need… | Call | ~Tokens |
44
- | :--- | :--- | ---: |
45
- | Orientation in an unfamiliar repo | `get_repo_map()` | 1,500 |
46
- | Orientation in one subsystem | `get_repo_map(path_filter: "auth")` | 300 |
47
- | A symbol you can name exactly | `resolve_symbol("UserService")` | 50 |
48
- | Code for a concept/behaviour | `search_code(query, detail: "signatures")` | 100 |
49
- | The interface of a found chunk | `get_chunk_summary(id)` | 50 |
50
- | Interfaces of everything a chunk calls | `get_chunk_summary(id, expand_calls: true)` | 150 |
51
- | The full implementation | `get_chunk(id)` | 300 |
52
- | All exports of one known file | `get_file_skeleton("src/file.ts")` | 80 |
53
- | Who calls a function (pre-refactor) | `get_call_graph("name")` | 100 |
54
- | What a file imports / who imports it | resource `graph://dependencies/{path}` | 100 |
55
- | Index health / why results look off | `list_index_stats()` | 100 |
56
- | *(comparison)* reading one file directly | — | 5,000+ |
57
-
58
- ### Escalation ladder (token budget)
59
-
60
- ```
61
- 1. search_code(query, detail: "signatures", top_k: 5) ~100 tok — find candidates
62
- 2. get_chunk_summary(id) ~50 tok — confirm the interface
63
- 3. search_code(query, detail: "smart") ~750 tok — see query-relevant lines
64
- 4. get_chunk(id) ~300 tok — full body, last resort
65
- ```
66
-
67
- Stop climbing as soon as you can answer. Most questions die at step 1–2.
68
-
69
- ---
70
-
71
- ## Writing queries that hit
72
-
73
- `search_code` is hybrid (BM25 keywords + semantic vectors) and **detects your
74
- query style automatically**. Both styles are first-class:
75
-
76
- **Keyword style** — when you know domain words or partial names:
77
- - `JWT token validation expiry middleware`
78
- - `route registration path method handler`
79
- - Partial name known? Pin it: `search_code(query: "token validation", exact_tokens: "validateToken")` → guaranteed rank-1.
80
- - Use resolve_symbol("Name") when you ONLY need the definition of that specific symbol. Use search_code(query: "...", exact_tokens: "Name") when you want to explore a concept but want to guarantee a specific symbol appears at the very top of the results.
81
-
82
- **Natural-language style** — when you only know the behaviour:
83
- - `How does the application parse incoming JSON payloads from the client?`
84
- - `the logic that decides whether a requested URL matches a registered route`
85
- - `stopping an HTTP call that takes too long or is no longer needed`
86
-
87
- Rules of thumb:
88
- - Describe **behaviour, not nouns**: `parse and validate request body schema`, not `validation`.
89
- - One-word queries (`auth`, `request`, `handler`) are useless — thousands of matches.
90
- - Include path hints when you have them: `middleware auth route` boosts `auth/middleware.ts`.
91
- - **A keyword miss is not a dead end**: rephrase the same need as a full
92
- behavioural sentence — the semantic channel finds code that shares none of
93
- your words. This rephrase is *mandatory* before any native-tool fallback.
94
- - Slow-but-smarter option: `search_code(..., rerank: true)` has a local LLM
95
- judge reorder the top results (~1–2 s). Use it when a natural-language query
96
- returns plausible-but-not-quite results.
97
-
98
- ### Reading results
99
-
100
- Every result card includes **free topology** — use it instead of new searches:
101
- - `Deps:` what this file imports (with key symbols per import)
102
- - `Used by:` which files import this one
103
- - `Calls:` functions this chunk invokes
104
- - `ID:` pass to `get_chunk` / `get_chunk_summary` — never re-search what you already found.
105
-
106
- A result with node type `re_export` means the symbol is re-exported from a
107
- dependency (e.g. `fastapi` re-exporting Starlette's `BackgroundTasks`): the
108
- implementation lives outside this repo — do not hunt for it in the codebase.
109
-
110
- ---
111
-
112
- ## Standard workflows
113
-
114
- **Unfamiliar codebase** → `get_repo_map()` → pick files → `resolve_symbol` /
115
- `search_code` to drill in.
116
-
117
- **"Where is X implemented?"** → `search_code(behavioural query, detail:
118
- "signatures")` → `get_chunk_summary(best id)` → `get_chunk(id)` only if you
119
- must see the body.
120
-
121
- **Safe refactor** → `get_call_graph("target")` → review callers →
122
- `get_chunk(callerId)` for each call site you need to update → edit with your
123
- normal tools.
124
-
125
- **Understanding one file** → `get_file_skeleton(path)` (all exports + line
126
- numbers, no bodies) → drill into specific symbols.
127
-
128
- **Debugging a behaviour** → natural-language `search_code` describing the
129
- symptom's mechanism → follow `Calls:` topology instead of re-searching.
130
-
131
- ---
132
-
133
- ## Anti-patterns (each of these is a bug in your behaviour)
134
-
135
- - ❌ Reading a file as your *first* move on any question.
136
- - ❌ `search_code("validateToken")` — you know the name; that's `resolve_symbol`.
137
- - ❌ Re-searching a concept you already have a chunk ID for.
138
- - ❌ `detail: "full"` by default — signatures first, always.
139
- - ❌ Editing an exported signature without `get_call_graph` first.
140
- - ❌ Giving up after one keyword query without trying a behavioural rephrase.
141
- - ❌ Ignoring `Used by:` / `Calls:` topology and issuing new searches for it.
142
- - ❌ Re-indexing after edits: Do not tell the user to re-index or attempt to re-index after you modify a file. Graph Indexer has a live daemon (watch-daemon.mjs) that instantly updates the index in the background using SQLite WAL. Your next search will automatically see your changes.
143
-
144
- ---
145
-
146
- ## Fallback to native tools — the ONLY permitted cases
147
-
148
- You may use native file/search tools **only** when one of these is true:
149
-
150
- 1. **The index says no.** You tried BOTH query styles (keyword + behavioural
151
- sentence) and `resolve_symbol`, and the target genuinely isn't returned.
152
- 2. **The index is unhealthy.** `list_index_stats()` shows 0 chunks, a missing
153
- index, or a clearly stale index that the daemon isn't updating — tell the
154
- user to run `npm run mcp:index`, then fall back for now.
155
- 3. **Non-code files.** Configs, lockfiles, markdown, data files, generated
156
- artifacts — the index covers source code chunks; plain files are fair game.
157
- 4. **You need exact current file state for an edit** you are about to make
158
- (e.g. precise surrounding lines to produce a diff) — *after* the index
159
- located the file and line range for you.
160
-
161
- When you do fall back, scope it: read the one file the index pointed you to —
162
- never directory-walk or repo-grep what the index already answered.
163
-
164
- ---
165
-
166
- ## Quick reference
167
-
168
- ```
169
- search_code(query, exact_tokens?, detail?, top_k?, rerank?) hybrid search, topology included
170
- resolve_symbol(symbol) O(1) exact definition lookup
171
- get_chunk(chunk_id, view?) full body of one chunk
172
- get_chunk_summary(chunk_id, expand_calls?) interface only, ~50 tok
173
- get_file_skeleton(file_path) all exports of a file, no bodies
174
- get_call_graph(target_function) every caller repo-wide
175
- get_repo_map(path_filter?, max_files?) PageRank-ordered codebase map
176
- list_index_stats() index health + search mode
177
- graph://dependencies/{file_path} bidirectional import topology
178
- ```
179
-
180
- **Detail levels:** `signatures` ~20 tok/result · `smart` (default) ~150 ·
181
- `full` ~300. **Search modes:** hybrid (semantic + lexical) when Ollama runs;
182
- lexical-only otherwise — in lexical-only mode prefer keyword-style queries.