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.
- package/CHANGELOG.md +854 -0
- package/README.md +258 -595
- package/SECURITY.md +85 -0
- package/TRADEMARK.md +31 -0
- package/cli-ui.mjs +78 -0
- package/colbert.mjs +168 -0
- package/config.mjs +408 -32
- package/daemon-ctl.mjs +175 -0
- package/daemon-lock.mjs +69 -0
- package/embedders/python/mlx_embed_server.py +61 -0
- package/embedders/python/requirements-mlx.txt +5 -0
- package/embedders/setup-mlx.mjs +110 -0
- package/embeddings.mjs +571 -0
- package/engine/binary.mjs +437 -0
- package/{core-engine.mjs → engine/memory.mjs} +321 -441
- package/{sqlite-store.mjs → engine/sqlite.mjs} +413 -45
- package/enrichment.mjs +206 -32
- package/git-signals.mjs +248 -0
- package/indexer.mjs +399 -45
- package/init.mjs +1626 -171
- package/layout.mjs +123 -0
- package/mcp/centrality.mjs +117 -0
- package/mcp/format.mjs +61 -0
- package/mcp/resolver.mjs +116 -0
- package/mcp/symbolgraph.mjs +137 -0
- package/mcp/taint.mjs +225 -0
- package/mcp/tools.mjs +1582 -0
- package/mcp/topology.mjs +676 -0
- package/mcp-server.mjs +80 -25
- package/package.json +66 -27
- package/parse/extractor.mjs +628 -0
- package/parse/imports.mjs +365 -0
- package/parse/interprocedural.mjs +134 -0
- package/parse/languages.mjs +253 -0
- package/parse/metadata.mjs +701 -0
- package/parse/routes.mjs +327 -0
- package/parse/scip.mjs +391 -0
- package/parse/taint-patterns.mjs +119 -0
- package/prompts/CORE.md +91 -0
- package/prompts/DOMAIN_TEMPLATE.md +46 -0
- package/prompts/INTEGRATION.md +123 -0
- package/prompts/frameworks/ANDROID.md +17 -0
- package/prompts/frameworks/ASPNET_CORE.md +19 -0
- package/prompts/frameworks/FASTAPI_DJANGO.md +20 -0
- package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +19 -0
- package/prompts/frameworks/RAILS.md +20 -0
- package/prompts/frameworks/REACT.md +20 -0
- package/prompts/frameworks/SPRING_BOOT.md +20 -0
- package/prompts/languages/BASH.md +22 -0
- package/prompts/languages/C.md +21 -0
- package/prompts/languages/CSHARP.md +24 -0
- package/prompts/languages/CSS_SCSS.md +22 -0
- package/prompts/languages/GO.md +24 -0
- package/prompts/languages/JAVA.md +23 -0
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
- package/prompts/languages/KOTLIN.md +24 -0
- package/prompts/languages/PHP.md +23 -0
- package/prompts/languages/PYTHON.md +23 -0
- package/prompts/languages/RUBY.md +23 -0
- package/prompts/languages/RUST.md +21 -0
- package/prompts/languages/SWIFT.md +21 -0
- package/seal.mjs +400 -0
- package/search-core.mjs +405 -44
- package/search-sparse.mjs +183 -0
- package/storage.mjs +33 -3
- package/watch-daemon.mjs +75 -24
- package/PROMPT.md +0 -182
- package/mcp-tools.mjs +0 -545
- 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
|
|
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
|
|
67
|
+
if (resolveBackend(config) === 'sqlite') {
|
|
38
68
|
// Imported lazily so the default path never loads node:sqlite.
|
|
39
|
-
const { SqliteGraphStore } = await import('./sqlite
|
|
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 {
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
162
|
-
//
|
|
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
|
-
//
|
|
166
|
-
//
|
|
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
|
-
|
|
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:
|
|
219
|
+
if (sText) entries.push({ key: baseKey + SUMMARY_VEC_SUFFIX, text: sText });
|
|
176
220
|
}
|
|
177
|
-
const
|
|
178
|
-
|
|
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, ${
|
|
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
|
-
|
|
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: ${
|
|
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.
|