graph-indexer 2.1.1 → 3.0.1

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 (131) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +236 -312
  3. package/SECURITY.md +53 -58
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +9 -0
  6. package/integrations/claude-code/.mcp.json +8 -0
  7. package/integrations/claude-code/agents/code-structure.md +22 -0
  8. package/integrations/claude-code/hooks/gi-hook.mjs +103 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +129 -0
  11. package/package.json +32 -109
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +366 -0
  14. package/src/cli/init.mjs +272 -0
  15. package/src/cli/resident.mjs +185 -0
  16. package/src/index/discover.mjs +128 -0
  17. package/src/index/fingerprint.mjs +30 -0
  18. package/src/index/graph.mjs +61 -0
  19. package/src/index/indexer.mjs +476 -0
  20. package/src/index/modules.mjs +332 -0
  21. package/src/index/resolver.mjs +961 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +974 -0
  25. package/src/parse/arity.mjs +153 -0
  26. package/src/parse/extract.mjs +1038 -0
  27. package/src/parse/lang/csharp.mjs +106 -0
  28. package/src/parse/lang/ecma.mjs +438 -0
  29. package/src/parse/lang/go.mjs +194 -0
  30. package/src/parse/lang/jvm.mjs +118 -0
  31. package/src/parse/lang/kotlin.mjs +97 -0
  32. package/src/parse/lang/misc.mjs +115 -0
  33. package/src/parse/lang/native.mjs +126 -0
  34. package/src/parse/lang/python.mjs +153 -0
  35. package/src/parse/lang/rust.mjs +157 -0
  36. package/src/parse/lang/scripting.mjs +219 -0
  37. package/src/parse/languages.mjs +48 -0
  38. package/src/parse/runtime.mjs +56 -0
  39. package/src/query/check.mjs +345 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +683 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +130 -0
  44. package/src/query/textsearch.mjs +178 -0
  45. package/src/search/search.mjs +210 -0
  46. package/src/search/tokenize.mjs +188 -0
  47. package/src/store/db.mjs +169 -0
  48. package/src/util/git.mjs +30 -0
  49. package/src/util/paths.mjs +63 -0
  50. package/src/util/regex.mjs +2 -0
  51. package/vendor/grammars/bash.wasm.gz +0 -0
  52. package/vendor/grammars/c.wasm.gz +0 -0
  53. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  54. package/vendor/grammars/cpp.wasm.gz +0 -0
  55. package/vendor/grammars/css.wasm.gz +0 -0
  56. package/vendor/grammars/go.wasm.gz +0 -0
  57. package/vendor/grammars/java.wasm.gz +0 -0
  58. package/vendor/grammars/javascript.wasm.gz +0 -0
  59. package/vendor/grammars/kotlin.wasm.gz +0 -0
  60. package/vendor/grammars/manifest.json +148 -0
  61. package/vendor/grammars/php.wasm.gz +0 -0
  62. package/vendor/grammars/python.wasm.gz +0 -0
  63. package/vendor/grammars/ruby.wasm.gz +0 -0
  64. package/vendor/grammars/rust.wasm.gz +0 -0
  65. package/vendor/grammars/scala.wasm.gz +0 -0
  66. package/vendor/grammars/tsx.wasm.gz +0 -0
  67. package/vendor/grammars/typescript.wasm.gz +0 -0
  68. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  69. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  70. package/cli-ui.mjs +0 -78
  71. package/colbert.mjs +0 -168
  72. package/config.mjs +0 -494
  73. package/daemon-ctl.mjs +0 -175
  74. package/daemon-lock.mjs +0 -69
  75. package/embedders/python/mlx_embed_server.py +0 -61
  76. package/embedders/python/requirements-mlx.txt +0 -5
  77. package/embedders/setup-mlx.mjs +0 -110
  78. package/embeddings.mjs +0 -571
  79. package/engine/binary.mjs +0 -437
  80. package/engine/memory.mjs +0 -1003
  81. package/engine/sqlite.mjs +0 -1136
  82. package/enrichment.mjs +0 -521
  83. package/git-signals.mjs +0 -248
  84. package/indexer.mjs +0 -546
  85. package/init.mjs +0 -1809
  86. package/layout.mjs +0 -123
  87. package/mcp/centrality.mjs +0 -117
  88. package/mcp/format.mjs +0 -61
  89. package/mcp/resolver.mjs +0 -116
  90. package/mcp/symbolgraph.mjs +0 -137
  91. package/mcp/taint.mjs +0 -225
  92. package/mcp/tools.mjs +0 -1582
  93. package/mcp/topology.mjs +0 -676
  94. package/mcp-server.mjs +0 -197
  95. package/parse/extractor.mjs +0 -628
  96. package/parse/imports.mjs +0 -365
  97. package/parse/interprocedural.mjs +0 -134
  98. package/parse/languages.mjs +0 -253
  99. package/parse/metadata.mjs +0 -701
  100. package/parse/routes.mjs +0 -327
  101. package/parse/scip.mjs +0 -391
  102. package/parse/taint-patterns.mjs +0 -119
  103. package/prompts/CORE.md +0 -91
  104. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  105. package/prompts/INTEGRATION.md +0 -123
  106. package/prompts/frameworks/ANDROID.md +0 -17
  107. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  108. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  109. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  110. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  111. package/prompts/frameworks/RAILS.md +0 -20
  112. package/prompts/frameworks/REACT.md +0 -20
  113. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  114. package/prompts/languages/BASH.md +0 -22
  115. package/prompts/languages/C.md +0 -21
  116. package/prompts/languages/CSHARP.md +0 -24
  117. package/prompts/languages/CSS_SCSS.md +0 -22
  118. package/prompts/languages/GO.md +0 -24
  119. package/prompts/languages/JAVA.md +0 -23
  120. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  121. package/prompts/languages/KOTLIN.md +0 -24
  122. package/prompts/languages/PHP.md +0 -23
  123. package/prompts/languages/PYTHON.md +0 -23
  124. package/prompts/languages/RUBY.md +0 -23
  125. package/prompts/languages/RUST.md +0 -21
  126. package/prompts/languages/SWIFT.md +0 -21
  127. package/seal.mjs +0 -400
  128. package/search-core.mjs +0 -777
  129. package/search-sparse.mjs +0 -183
  130. package/storage.mjs +0 -73
  131. package/watch-daemon.mjs +0 -289
package/search-sparse.mjs DELETED
@@ -1,183 +0,0 @@
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 DELETED
@@ -1,73 +0,0 @@
1
- /**
2
- * @file storage.mjs
3
- * @description Storage backend factory. Selects the in-memory engine (default,
4
- * zero-dependency) or the disk-backed SQLite store based on config,
5
- * and documents the single read contract that the MCP tools depend
6
- * on so they remain backend-agnostic.
7
- * @author MaquinaTech <https://github.com/MaquinaTech>
8
- * @copyright (c) 2026 MaquinaTech. All rights reserved.
9
- * @license MIT
10
- *
11
- * ── Store contract (implemented by both MemoryGraphIndex and SqliteGraphStore) ──
12
- * load() Prepare for queries (open db / parse json).
13
- * get backend() 'memory' | 'sqlite'.
14
- * get graph() { dependencies, importedBy } (file-level).
15
- * searchHybrid(q, vec, topK, minScore, exactBoost) → [{ score, chunk }]
16
- * getChunk(id) → chunk | null
17
- * getChunksByFile(path) → chunk[]
18
- * resolveSymbol(name) → chunk[] (exact, case-insensitive)
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)
27
- * iterateChunks() → Iterable<chunk> (cursor on SQLite)
28
- * getDependencies(path) / getImportedBy(path) → string[]
29
- * chunkCount() / fileCount() / symbolCount() / vectorCount() → number
30
- * stats() → engine health facts
31
- * close() Release fds / db handles.
32
- */
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
- }
57
-
58
- /**
59
- * Construct (but do not yet load) the configured store.
60
- *
61
- * @param {object} config Resolved config from config.mjs.
62
- * @param {object} [opts]
63
- * @param {boolean} [opts.cacheEmbeddings] Eager vector cache (in-memory backend only).
64
- * @returns {Promise<object>} a store implementing the contract above.
65
- */
66
- export async function createStore(config, { cacheEmbeddings = false } = {}) {
67
- if (resolveBackend(config) === 'sqlite') {
68
- // Imported lazily so the default path never loads node:sqlite.
69
- const { SqliteGraphStore } = await import('./engine/sqlite.mjs');
70
- return new SqliteGraphStore(config.sqlitePath, { embeddingPath: config.embeddingPath });
71
- }
72
- return new MemoryGraphIndex(config.indexPath, { cacheEmbeddings });
73
- }
package/watch-daemon.mjs DELETED
@@ -1,289 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * @file watch-daemon.mjs
4
- * @description Native FileSystem Watcher Daemon that keeps the configured index
5
- * backend fresh incrementally. Backend-agnostic: file changes are
6
- * parsed once (Tree-sitter), optionally enriched (LLM cache-first)
7
- * and embedded (Ollama), then handed to the store's applyFileUpdate
8
- * — the in-memory engine persists a debounced JSON snapshot, the
9
- * SQLite store commits a per-file WAL transaction that running MCP
10
- * servers pick up live via PRAGMA data_version. No more "SQLite
11
- * requires a full re-index on every change".
12
- * @author MaquinaTech <https://github.com/MaquinaTech>
13
- * @copyright (c) 2026 MaquinaTech. All rights reserved.
14
- * @license MIT
15
- * Copyright (c) 2026 MaquinaTech. All rights reserved.
16
- * Permission is hereby granted, free of charge, to any person obtaining a copy
17
- * of this software and associated documentation files (the "Software"), to deal
18
- * in the Software without restriction, including without limitation the rights
19
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
20
- * copies of the Software, and to permit persons to whom the Software is
21
- * furnished to do so, subject to the following conditions: The above copyright
22
- * notice and this permission notice shall be included in all copies or
23
- * substantial portions of the Software.
24
- */
25
-
26
- import fs from 'fs';
27
- import path from 'path';
28
- import chokidar from 'chokidar';
29
- import { resolveConfig } from './config.mjs';
30
- import { ensureDataDir, migrateLegacyLayout } from './layout.mjs';
31
- import { acquireLock, releaseLock, readPid } from './daemon-lock.mjs';
32
- import { createStore, resolveBackend } from './storage.mjs';
33
- import {
34
- embeddingKeyFor, computePageRank, TEST_FILE_RE, EXAMPLE_DIR_RE,
35
- summaryEmbeddingText, SUMMARY_VEC_SUFFIX, WINDOW_VEC_SUFFIX, embeddingWindows,
36
- } from './search-core.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';
41
- import {
42
- loadEnrichmentCache, saveEnrichmentCache, attachEnrichment,
43
- ollamaGenerate, parseEnrichResponse, buildEnrichPrompt, resolveEnrichModel,
44
- } from './enrichment.mjs';
45
-
46
- const config = resolveConfig();
47
- const PROJECT_ROOT = config.projectRoot;
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
-
77
- const ignoreFilter = buildIgnoreFilter(PROJECT_ROOT);
78
- const db = await createStore(config, { cacheEmbeddings: true });
79
- db.load();
80
-
81
- // ─── Enrichment (cache-first, live for core files) ──────────────────────────────
82
- // Cached entries re-attach for free on every change. When enrichment is enabled
83
- // and the changed file sits in the PageRank core, new/changed chunks are enriched
84
- // live (best-effort, low concurrency) so edits don't silently lose their semantic
85
- // metadata until the next full index run.
86
- const enrichCache = config.enrichment.enabled
87
- ? loadEnrichmentCache(config.enrichmentCachePath)
88
- : null;
89
- let enrichCacheDirty = false;
90
- let saveCacheTimer = null;
91
- function scheduleEnrichCacheSave() {
92
- if (!enrichCacheDirty) return;
93
- if (saveCacheTimer) clearTimeout(saveCacheTimer);
94
- saveCacheTimer = setTimeout(() => {
95
- saveCacheTimer = null;
96
- try { saveEnrichmentCache(config.enrichmentCachePath, enrichCache); enrichCacheDirty = false; }
97
- catch (err) { process.stderr.write(`[daemon] ⚠️ enrichment cache save failed: ${err.message}\n`); }
98
- }, 3000);
99
- }
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
-
109
- let _coreFiles = null;
110
- function isEnrichableFile(filename) {
111
- if (TEST_FILE_RE.test(filename) || EXAMPLE_DIR_RE.test(filename)) return false;
112
- if (config.enrichment.coreRatio >= 1) return true; // all production files
113
- if (!_coreFiles) {
114
- const pr = computePageRank(db.graph);
115
- const files = Array.from(pr.keys()).sort((a, b) => (pr.get(b) ?? 0) - (pr.get(a) ?? 0));
116
- const coreCount = Math.max(1, Math.ceil(files.length * config.enrichment.coreRatio));
117
- _coreFiles = new Set(files.slice(0, coreCount));
118
- }
119
- return _coreFiles.has(filename);
120
- }
121
-
122
- async function enrichChunks(filename, chunks) {
123
- if (!enrichCache) return;
124
- const pending = [];
125
- for (const chunk of chunks) {
126
- if (!chunk.content_hash) continue;
127
- if (attachEnrichment(chunk, enrichCache.get(chunk.content_hash))) continue;
128
- pending.push(chunk);
129
- }
130
- if (pending.length === 0 || !isEnrichableFile(filename)) return;
131
- // Live enrichment of changed core chunks: sequential and best-effort — a file
132
- // save touches a handful of chunks, so latency stays low and failures only
133
- // mean "no enrichment until the next full index run".
134
- const model = await getEnrichModel();
135
- for (const chunk of pending) {
136
- if ((chunk.end_line - chunk.start_line) < 4) continue; // trivial stubs
137
- const raw = await ollamaGenerate(buildEnrichPrompt(chunk), {
138
- model, ollamaHost: config.ollamaHost, timeoutMs: 20000,
139
- });
140
- const parsed = parseEnrichResponse(raw);
141
- if (!parsed) break; // model unreachable — stop trying for this batch
142
- chunk.summary = parsed.summary;
143
- chunk.concepts = parsed.concepts;
144
- chunk.hyde = parsed.hyde;
145
- enrichCache.set(chunk.content_hash, {
146
- summary: parsed.summary, concepts: parsed.concepts, model,
147
- });
148
- enrichCacheDirty = true;
149
- }
150
- scheduleEnrichCacheSave();
151
- }
152
-
153
- // ─── Incremental file sync ──────────────────────────────────────────────────────
154
-
155
- // Catch-up scan: chokidar fires 'add' for every existing file at startup. Files
156
- // not modified since the index artifact was last written are already indexed —
157
- // skipping them turns daemon start from O(repo) parse work into O(changed files),
158
- // while still picking up edits made while the daemon was down.
159
- const BACKEND = resolveBackend(config); // 'auto' → concrete backend by artifact presence.
160
- const ARTIFACT_PATH = BACKEND === 'sqlite' ? config.sqlitePath : config.indexPath;
161
- let indexBuiltAt = 0;
162
- try { indexBuiltAt = fs.statSync(ARTIFACT_PATH).mtimeMs; } catch { /* no index yet → full scan */ }
163
- let initialScan = true;
164
-
165
- async function processFileChange(absolutePath) {
166
- const filename = path.relative(PROJECT_ROOT, absolutePath).replace(/\\/g, '/');
167
- if (ignoreFilter.ignores(filename)) return;
168
- if (filename.includes('.bundle.') || filename.includes('.min.')) return;
169
- if (filename.startsWith('code-index.')) return; // never index our own artifacts
170
-
171
- try {
172
- if (initialScan && indexBuiltAt > 0 && fs.existsSync(absolutePath)
173
- && fs.statSync(absolutePath).mtimeMs <= indexBuiltAt) {
174
- return; // unchanged since the last index write
175
- }
176
-
177
- if (!fs.existsSync(absolutePath)) {
178
- if (typeof db.removeFile === 'function') db.removeFile(filename);
179
- else db.applyFileUpdate(filename, { chunks: [], imports: [] });
180
- _coreFiles = null;
181
- process.stderr.write(`[daemon] 🗑️ Purged: ${filename}\n`);
182
- return;
183
- }
184
-
185
- const stats = fs.statSync(absolutePath);
186
- if (stats.size > MAX_FILE_SIZE_BYTES) return;
187
-
188
- const content = fs.readFileSync(absolutePath, 'utf-8');
189
- if (!content.trim()) return;
190
-
191
- const ext = path.extname(absolutePath);
192
- const parser = getParserForFile(ext);
193
- if (!parser) return;
194
-
195
- const tree = parser.parse((offset) => offset < content.length ? content.slice(offset, offset + 4096) : null);
196
- const imports = resolveLocalImports(extractImportsFromAST(tree.rootNode, ext), filename, PROJECT_ROOT);
197
- const newChunks = extractSemanticChunks(tree.rootNode, filename, content, ext);
198
- _coreFiles = null; // dependency graph may change → PageRank core set is stale
199
-
200
- // Enrichment BEFORE embedding: summary rides the vector payload and
201
- // embeddingKeyFor accounts for it, so cache lookups stay correct.
202
- await enrichChunks(filename, newChunks);
203
-
204
- // Identical embedding payloads to indexer.mjs (shared helpers) so incremental
205
- // updates match the bootstrap index, including summary-only vectors for enriched chunks.
206
- const chunksToEmbed = newChunks.filter(c => !db.hasEmbedding(embeddingKeyFor(c)));
207
- const embeddings = new Map();
208
- if (chunksToEmbed.length > 0) {
209
- const entries = [];
210
- for (const c of chunksToEmbed) {
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] });
218
- const sText = summaryEmbeddingText(c);
219
- if (sText) entries.push({ key: baseKey + SUMMARY_VEC_SUFFIX, text: sText });
220
- }
221
- const embedder = await getEmbedder();
222
- const embeddingsMatrix = await embedder.embedDocuments(entries.map(e => e.text));
223
- if (embeddingsMatrix && embeddingsMatrix.length === entries.length) {
224
- entries.forEach((entry, j) => {
225
- if (embeddingsMatrix[j]) embeddings.set(entry.key, new Float32Array(embeddingsMatrix[j]));
226
- });
227
- }
228
- }
229
-
230
- db.applyFileUpdate(filename, { chunks: newChunks, imports, embeddings });
231
- process.stderr.write(
232
- `[daemon] 🔄 Synced: ${filename} (${newChunks.length} chunks, `
233
- + `${newChunks.length - chunksToEmbed.length} cached vectors, ${BACKEND})\n`
234
- );
235
- } catch (err) {
236
- process.stderr.write(`[daemon] ❌ Error in ${filename}: ${err.message}\n`);
237
- }
238
- }
239
-
240
- // Serialize updates: chokidar can fire bursts (git checkout, format-on-save across
241
- // files); per-file work must not interleave inside the store.
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') });
248
- function enqueue(absolutePath) {
249
- queue = queue.then(() => processFileChange(absolutePath)).catch(() => {});
250
- }
251
-
252
- process.stderr.write(`🚀 Watcher Daemon started in: ${PROJECT_ROOT} (backend: ${BACKEND})\n`);
253
-
254
- // Only watch source files we can actually index. Critically, this prevents
255
- // chokidar from descending into node_modules / dist / .git / .gitignored dirs —
256
- // watching those would exhaust the OS file-watcher limit (ENOSPC on Linux) and
257
- // waste CPU/memory on large projects. The ignore decision happens at the watcher
258
- // level, not just in processFileChange, so ignored trees are never traversed.
259
- function shouldIgnore(absPath) {
260
- const rel = path.relative(PROJECT_ROOT, absPath).replace(/\\/g, '/');
261
- if (!rel) return false; // the project root itself
262
- if (path.basename(absPath).startsWith('.')) return true; // dotfiles / dot-dirs
263
- if (rel.startsWith('code-index.')) return true; // our own artifacts (.db/-wal/.bin/.json)
264
- try { return ignoreFilter.ignores(rel); } // node_modules, dist, .gitignore, …
265
- catch { return false; }
266
- }
267
-
268
- const watcher = chokidar.watch(PROJECT_ROOT, {
269
- ignored: shouldIgnore,
270
- persistent: true,
271
- awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }
272
- });
273
- watcher.on('add', enqueue).on('change', enqueue).on('unlink', enqueue);
274
- watcher.on('ready', () => { queue = queue.then(() => { initialScan = false; }); });
275
- watcher.on('error', (err) => process.stderr.write(`[daemon] 💥 OS Watcher panic: ${err.message}\n`));
276
-
277
- function shutdown() {
278
- watcher.close().catch(() => {});
279
- if (enrichCache && enrichCacheDirty) {
280
- try { saveEnrichmentCache(config.enrichmentCachePath, enrichCache); } catch { /* best effort */ }
281
- }
282
- if (typeof db.close === 'function') db.close();
283
- process.exit(0);
284
- }
285
- process.on('SIGTERM', shutdown);
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));