graph-indexer 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +412 -0
- package/README.md +216 -596
- package/SECURITY.md +68 -0
- package/TRADEMARK.md +31 -0
- package/cli-ui.mjs +78 -0
- package/config.mjs +179 -28
- package/daemon-ctl.mjs +175 -0
- package/daemon-lock.mjs +69 -0
- package/embedders/python/mlx_embed_server.py +61 -0
- package/embedders/python/requirements-mlx.txt +5 -0
- package/embedders/setup-mlx.mjs +110 -0
- package/embeddings.mjs +489 -0
- package/engine/binary.mjs +399 -0
- package/{core-engine.mjs → engine/memory.mjs} +107 -440
- package/{sqlite-store.mjs → engine/sqlite.mjs} +137 -42
- package/enrichment.mjs +111 -32
- package/git-signals.mjs +248 -0
- package/indexer.mjs +151 -42
- package/init.mjs +1603 -171
- package/layout.mjs +123 -0
- package/mcp/format.mjs +61 -0
- package/mcp/tools.mjs +1157 -0
- package/mcp/topology.mjs +551 -0
- package/mcp-server.mjs +55 -24
- package/package.json +44 -13
- package/parse/extractor.mjs +617 -0
- package/parse/imports.mjs +365 -0
- package/parse/languages.mjs +89 -0
- package/parse/metadata.mjs +655 -0
- package/parse/routes.mjs +327 -0
- package/prompts/CORE.md +86 -0
- package/prompts/DOMAIN_TEMPLATE.md +46 -0
- package/prompts/INTEGRATION.md +123 -0
- package/prompts/frameworks/ANDROID.md +17 -0
- package/prompts/frameworks/ASPNET_CORE.md +19 -0
- package/prompts/frameworks/FASTAPI_DJANGO.md +19 -0
- package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +18 -0
- package/prompts/frameworks/RAILS.md +20 -0
- package/prompts/frameworks/REACT.md +20 -0
- package/prompts/frameworks/SPRING_BOOT.md +19 -0
- package/prompts/languages/BASH.md +22 -0
- package/prompts/languages/C.md +21 -0
- package/prompts/languages/CSHARP.md +24 -0
- package/prompts/languages/CSS_SCSS.md +22 -0
- package/prompts/languages/GO.md +23 -0
- package/prompts/languages/JAVA.md +23 -0
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
- package/prompts/languages/KOTLIN.md +24 -0
- package/prompts/languages/PHP.md +23 -0
- package/prompts/languages/PYTHON.md +23 -0
- package/prompts/languages/RUBY.md +23 -0
- package/prompts/languages/RUST.md +21 -0
- package/prompts/languages/SWIFT.md +21 -0
- package/search-core.mjs +254 -43
- package/storage.mjs +28 -3
- package/watch-daemon.mjs +69 -23
- package/PROMPT.md +0 -182
- package/mcp-tools.mjs +0 -545
- package/parser-utils.mjs +0 -1170
package/git-signals.mjs
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file git-signals.mjs
|
|
3
|
+
* @description Lightweight, air-gapped git-history signals for ranking and
|
|
4
|
+
* blast-radius hints. Reads ONLY the local repository's commit log
|
|
5
|
+
* (no network, no remote, no code sent anywhere) and derives three
|
|
6
|
+
* cheap per-file signals competitors mostly ignore:
|
|
7
|
+
*
|
|
8
|
+
* • churn — how often a file changes (commit count);
|
|
9
|
+
* • recency — how recently it last changed (last-commit time);
|
|
10
|
+
* • co-change — which files historically change *together* (the
|
|
11
|
+
* "if you touch A you usually touch B" hint that
|
|
12
|
+
* sharpens the refactoring/blast-radius story).
|
|
13
|
+
*
|
|
14
|
+
* Computed once at index time into a sidecar (code-index.git.json),
|
|
15
|
+
* kept out of the storage contract and the ranking math: the
|
|
16
|
+
* co-change hint is surfaced by the blast-radius tools, and the
|
|
17
|
+
* recency/churn boost is applied opt-in in the tool layer (default
|
|
18
|
+
* off), so the measured retrieval ranking is unchanged by default.
|
|
19
|
+
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
20
|
+
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
21
|
+
* @license MIT
|
|
22
|
+
*/
|
|
23
|
+
import { execFileSync } from 'child_process';
|
|
24
|
+
import fs from 'fs';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
|
|
27
|
+
// Record/field separators chosen to never collide with file paths.
|
|
28
|
+
const REC = '\x01';
|
|
29
|
+
const FS_ = '\x1f';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Collect git signals for a repository from its local commit log.
|
|
33
|
+
*
|
|
34
|
+
* Strictly local: shells out to `git log` on `repoRoot` and parses stdout. No
|
|
35
|
+
* remote is contacted. Returns `null` (a no-op) when the directory is not a git
|
|
36
|
+
* repo, git is unavailable, or there is no history — callers degrade silently.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} repoRoot
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {number} [opts.maxCommits=500] History depth (bounds cost + size).
|
|
41
|
+
* @param {number} [opts.topCoChange=8] Co-changed files kept per file.
|
|
42
|
+
* @param {number} [opts.maxFilesPerCommit=50] Skip sweeping commits (mass renames,
|
|
43
|
+
* formatting) whose co-change is noise.
|
|
44
|
+
* @returns {null | {generated:number, commits:number, maxChurn:number,
|
|
45
|
+
* churn:Object<string,number>, lastCommit:Object<string,number>,
|
|
46
|
+
* coChange:Object<string,Array<[string,number]>>}}
|
|
47
|
+
*/
|
|
48
|
+
export function collectGitSignals(repoRoot, { maxCommits = 500, topCoChange = 8, maxFilesPerCommit = 50 } = {}) {
|
|
49
|
+
// Resolve the actual repo root. If `repoRoot` is a SUBDIRECTORY of a larger
|
|
50
|
+
// repo (e.g. indexing one package, or a fixture nested in this repo), a bare
|
|
51
|
+
// `git -C <dir> log` walks UP and returns the PARENT repo's history with
|
|
52
|
+
// parent-root-relative paths that never match this index's file paths. So we
|
|
53
|
+
// find the toplevel, scope the log to this subtree, and strip the prefix so
|
|
54
|
+
// emitted paths are relative to `repoRoot` (matching chunk.file_path).
|
|
55
|
+
let prefix = '';
|
|
56
|
+
try {
|
|
57
|
+
const toplevel = execFileSync('git', ['-C', repoRoot, 'rev-parse', '--show-toplevel'],
|
|
58
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
59
|
+
if (!toplevel) return null;
|
|
60
|
+
// Compare REAL paths: git returns the physical toplevel, while repoRoot may
|
|
61
|
+
// contain symlinks (e.g. macOS /var → /private/var), which would otherwise
|
|
62
|
+
// produce a bogus prefix.
|
|
63
|
+
const realTop = (() => { try { return fs.realpathSync(toplevel); } catch { return toplevel; } })();
|
|
64
|
+
const realRoot = (() => { try { return fs.realpathSync(path.resolve(repoRoot)); } catch { return path.resolve(repoRoot); } })();
|
|
65
|
+
const rel = path.relative(realTop, realRoot).replace(/\\/g, '/');
|
|
66
|
+
prefix = rel && rel !== '.' ? rel : '';
|
|
67
|
+
} catch {
|
|
68
|
+
return null; // not a git repo / git missing — air-gapped no-op
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
const args = ['-C', repoRoot, 'log', '--no-merges', `-n${maxCommits}`,
|
|
74
|
+
`--pretty=format:${REC}%H${FS_}%ct`, '--name-only'];
|
|
75
|
+
if (prefix) args.push('--', '.'); // limit to this subtree (paths stay toplevel-relative)
|
|
76
|
+
raw = execFileSync('git', args,
|
|
77
|
+
{ encoding: 'utf-8', maxBuffer: 128 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (!raw || !raw.includes(REC)) return null;
|
|
82
|
+
const stripPrefix = prefix ? prefix + '/' : '';
|
|
83
|
+
|
|
84
|
+
const churn = Object.create(null);
|
|
85
|
+
const lastCommit = Object.create(null);
|
|
86
|
+
const pairCounts = new Map(); // file → Map<coFile, count>
|
|
87
|
+
let commits = 0;
|
|
88
|
+
|
|
89
|
+
const bump = (f, ts) => {
|
|
90
|
+
churn[f] = (churn[f] || 0) + 1;
|
|
91
|
+
if (ts > (lastCommit[f] || 0)) lastCommit[f] = ts;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
for (const block of raw.split(REC)) {
|
|
95
|
+
if (!block) continue;
|
|
96
|
+
const nl = block.indexOf('\n');
|
|
97
|
+
const header = nl === -1 ? block : block.slice(0, nl);
|
|
98
|
+
const ts = parseInt(header.split(FS_)[1], 10);
|
|
99
|
+
if (!Number.isFinite(ts)) continue;
|
|
100
|
+
commits++;
|
|
101
|
+
|
|
102
|
+
const files = [];
|
|
103
|
+
if (nl !== -1) {
|
|
104
|
+
for (const line of block.slice(nl + 1).split('\n')) {
|
|
105
|
+
let f = line.trim();
|
|
106
|
+
if (!f) continue;
|
|
107
|
+
// git quotes paths with unusual chars: "src/é.ts" — unwrap.
|
|
108
|
+
if (f.startsWith('"') && f.endsWith('"')) f = f.slice(1, -1);
|
|
109
|
+
if (stripPrefix) {
|
|
110
|
+
if (!f.startsWith(stripPrefix)) continue; // outside the indexed subtree
|
|
111
|
+
f = f.slice(stripPrefix.length);
|
|
112
|
+
}
|
|
113
|
+
files.push(f);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const f of files) bump(f, ts);
|
|
117
|
+
|
|
118
|
+
// Co-change: only for focused commits — a 400-file sweep links nothing useful.
|
|
119
|
+
if (files.length >= 2 && files.length <= maxFilesPerCommit) {
|
|
120
|
+
for (let i = 0; i < files.length; i++) {
|
|
121
|
+
const a = files[i];
|
|
122
|
+
let row = pairCounts.get(a);
|
|
123
|
+
if (!row) { row = new Map(); pairCounts.set(a, row); }
|
|
124
|
+
for (let j = 0; j < files.length; j++) {
|
|
125
|
+
if (i === j) continue;
|
|
126
|
+
const b = files[j];
|
|
127
|
+
row.set(b, (row.get(b) || 0) + 1);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (commits === 0) return null;
|
|
134
|
+
|
|
135
|
+
// Stamp HEAD so clients can report "indexed at commit X" and detect drift
|
|
136
|
+
// (the freshness contract). Best-effort — absence just omits the commit.
|
|
137
|
+
let head = null;
|
|
138
|
+
try {
|
|
139
|
+
head = execFileSync('git', ['-C', repoRoot, 'rev-parse', '--short', 'HEAD'],
|
|
140
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
141
|
+
} catch { /* detached/empty — no stamp */ }
|
|
142
|
+
|
|
143
|
+
const coChange = Object.create(null);
|
|
144
|
+
for (const [file, row] of pairCounts) {
|
|
145
|
+
const top = [...row.entries()]
|
|
146
|
+
.sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))
|
|
147
|
+
.slice(0, topCoChange);
|
|
148
|
+
if (top.length) coChange[file] = top;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let maxChurn = 1;
|
|
152
|
+
for (const k in churn) if (churn[k] > maxChurn) maxChurn = churn[k];
|
|
153
|
+
|
|
154
|
+
return { generated: Math.floor(Date.now() / 1000), head, commits, maxChurn, churn, lastCommit, coChange };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Persist signals next to the index (best-effort). */
|
|
158
|
+
export function writeGitSignals(filePath, signals) {
|
|
159
|
+
try { fs.writeFileSync(filePath, JSON.stringify(signals)); return true; }
|
|
160
|
+
catch { return false; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Load a signals sidecar, or null if absent/corrupt. */
|
|
164
|
+
export function loadGitSignals(filePath) {
|
|
165
|
+
try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); }
|
|
166
|
+
catch { return null; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Files that historically change together with `file`, strongest first. */
|
|
170
|
+
export function coChangesFor(signals, file, limit = 5) {
|
|
171
|
+
if (!signals || !signals.coChange) return [];
|
|
172
|
+
const row = signals.coChange[file];
|
|
173
|
+
if (!row) return [];
|
|
174
|
+
return row.slice(0, limit).map(([f, count]) => ({ file: f, count }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Index freshness (query-time drift detection) ────────────────────────────
|
|
178
|
+
// The blast-radius tools are only trustworthy if the index reflects the working
|
|
179
|
+
// tree. We surface a compact freshness signal on tool responses: index age, the
|
|
180
|
+
// commit it was built at (stamped above), and whether the tree has drifted since.
|
|
181
|
+
// The git read is cheap and cached briefly so a burst of tool calls costs one
|
|
182
|
+
// `git status`; it degrades silently (null) outside a git repo.
|
|
183
|
+
|
|
184
|
+
const _SRC_RE = /\.(js|jsx|mjs|cjs|ts|tsx|py|go|rs|java|kt|cs|rb|php|swift|c|h|cc|cpp|hpp|scss|css|sh|bash)$/i;
|
|
185
|
+
let _gitStateCache = null; // { at, root, head, dirtyCount }
|
|
186
|
+
|
|
187
|
+
/** Reset the cached working-tree state (tests). */
|
|
188
|
+
export function _resetGitStateCache() { _gitStateCache = null; }
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Cheap, cached working-tree state: current short HEAD + count of changed SOURCE
|
|
192
|
+
* files (uncommitted). Cached `ttlMs` per root so a burst of tool calls shells out
|
|
193
|
+
* at most once. `exec`/`nowMs` injectable for tests. Returns {head:null,dirtyCount:null}
|
|
194
|
+
* outside a git repo.
|
|
195
|
+
*/
|
|
196
|
+
export function currentGitState(repoRoot, { ttlMs = 5000, nowMs = Date.now(), exec = execFileSync } = {}) {
|
|
197
|
+
if (_gitStateCache && _gitStateCache.root === repoRoot && (nowMs - _gitStateCache.at) < ttlMs) return _gitStateCache;
|
|
198
|
+
let head = null, dirtyCount = null;
|
|
199
|
+
try {
|
|
200
|
+
head = exec('git', ['-C', repoRoot, 'rev-parse', '--short', 'HEAD'],
|
|
201
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
202
|
+
const status = exec('git', ['-C', repoRoot, 'status', '--porcelain'],
|
|
203
|
+
{ encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
204
|
+
dirtyCount = status.split('\n').filter(l => l.trim() && _SRC_RE.test(l)).length;
|
|
205
|
+
} catch { /* not a git repo / git missing → unknown */ }
|
|
206
|
+
_gitStateCache = { at: nowMs, root: repoRoot, head, dirtyCount };
|
|
207
|
+
return _gitStateCache;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Derive the freshness verdict from index age + the stamped index commit + the
|
|
212
|
+
* current working-tree state + whether the watch daemon is live. Pure (no I/O) so
|
|
213
|
+
* it is trivially testable.
|
|
214
|
+
*
|
|
215
|
+
* @returns {{ageSeconds:number|null, ageLabel:string, indexedCommit:string|null,
|
|
216
|
+
* currentCommit:string|null, pendingChanges:number|null, commitMoved:boolean,
|
|
217
|
+
* daemonRunning:boolean, stale:boolean, syncing:boolean}}
|
|
218
|
+
*/
|
|
219
|
+
export function computeFreshness({ ageSeconds = null, indexedCommit = null, current = null, daemonRunning = false } = {}) {
|
|
220
|
+
const ageLabel = ageSeconds == null ? 'unknown'
|
|
221
|
+
: ageSeconds < 60 ? `${ageSeconds}s` : ageSeconds < 3600 ? `${Math.floor(ageSeconds / 60)}m` : `${Math.floor(ageSeconds / 3600)}h`;
|
|
222
|
+
const currentCommit = current?.head ?? null;
|
|
223
|
+
const pendingChanges = current?.dirtyCount ?? null;
|
|
224
|
+
const commitMoved = Boolean(indexedCommit && currentCommit && indexedCommit !== currentCommit);
|
|
225
|
+
const hasPending = (pendingChanges != null && pendingChanges > 0) || commitMoved;
|
|
226
|
+
// A live daemon syncs changes incrementally, so pending work is "syncing", not
|
|
227
|
+
// "stale". Only when no daemon is keeping the index fresh is drift truly stale.
|
|
228
|
+
return {
|
|
229
|
+
ageSeconds, ageLabel, indexedCommit, currentCommit, pendingChanges, commitMoved,
|
|
230
|
+
daemonRunning, stale: hasPending && !daemonRunning, syncing: hasPending && daemonRunning,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A bounded 0..1 "this file is hot" score combining recency and churn, for the
|
|
236
|
+
* OPT-IN ranking boost only. Returns 0 when there is no signal for the file, so a
|
|
237
|
+
* disabled or signal-less repo never perturbs ranking.
|
|
238
|
+
*/
|
|
239
|
+
export function gitBoostScore(signals, file, nowSec = Date.now() / 1000) {
|
|
240
|
+
if (!signals) return 0;
|
|
241
|
+
const churn = (signals.churn && signals.churn[file]) || 0;
|
|
242
|
+
const last = (signals.lastCommit && signals.lastCommit[file]) || 0;
|
|
243
|
+
if (!churn && !last) return 0;
|
|
244
|
+
const ageDays = last ? Math.max(0, (nowSec - last) / 86400) : 3650;
|
|
245
|
+
const recency = Math.exp(-ageDays / 90); // ~quarter half-life
|
|
246
|
+
const churnScore = Math.log1p(churn) / Math.log1p(signals.maxChurn || 1); // 0..1
|
|
247
|
+
return 0.5 * recency + 0.5 * churnScore;
|
|
248
|
+
}
|
package/indexer.mjs
CHANGED
|
@@ -12,20 +12,29 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import fs from 'fs';
|
|
14
14
|
import path from 'path';
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
} from './
|
|
19
|
-
import { readEmbeddingBinary, writeEmbeddingBinary } from './
|
|
20
|
-
import { embeddingKeyFor, summaryEmbeddingText, SUMMARY_VEC_SUFFIX } from './search-core.mjs';
|
|
21
|
-
import {
|
|
15
|
+
import { MAX_FILE_SIZE_BYTES, buildIgnoreFilter, extractImportsFromAST, extractSemanticChunks } from './parse/extractor.mjs';
|
|
16
|
+
import { EXTENSIONS, getParserForFile } from './parse/languages.mjs';
|
|
17
|
+
import { extractRoutes } from './parse/routes.mjs';
|
|
18
|
+
import { resolveLocalImports, buildEmbeddingPayload, fullBodyForEmbedding } from './parse/imports.mjs';
|
|
19
|
+
import { readEmbeddingBinary, writeEmbeddingBinary } from './engine/binary.mjs';
|
|
20
|
+
import { embeddingKeyFor, summaryEmbeddingText, SUMMARY_VEC_SUFFIX, WINDOW_VEC_SUFFIX, embeddingWindows } from './search-core.mjs';
|
|
21
|
+
import { createEmbedder, describeEmbedder, readEmbedMeta, writeEmbedMeta, _resetSubprocesses } from './embeddings.mjs';
|
|
22
|
+
import { resolveConfig, describeConfig, configNotices } from './config.mjs';
|
|
23
|
+
import { AUTO_SQLITE_CHUNK_THRESHOLD } from './storage.mjs';
|
|
24
|
+
import { ensureDataDir, migrateLegacyLayout } from './layout.mjs';
|
|
22
25
|
import { enrichCoreChunks } from './enrichment.mjs';
|
|
26
|
+
import { collectGitSignals, writeGitSignals } from './git-signals.mjs';
|
|
23
27
|
|
|
24
28
|
const config = resolveConfig();
|
|
25
29
|
const PROJECT_ROOT = config.projectRoot;
|
|
26
30
|
const INDEX_PATH = config.indexPath;
|
|
27
31
|
const EMBEDDINGS_PATH = config.embeddingPath;
|
|
28
32
|
|
|
33
|
+
// Artifacts are written under <root>/.graph-indexer/ — make sure it exists and
|
|
34
|
+
// relocate any artifacts left at the root by a pre-v1.4 install.
|
|
35
|
+
ensureDataDir(PROJECT_ROOT);
|
|
36
|
+
migrateLegacyLayout(PROJECT_ROOT);
|
|
37
|
+
|
|
29
38
|
function walkRepo(dir, root, ig, files = []) {
|
|
30
39
|
for (const entry of fs.readdirSync(dir)) {
|
|
31
40
|
if (entry.startsWith('.')) continue;
|
|
@@ -43,17 +52,45 @@ function walkRepo(dir, root, ig, files = []) {
|
|
|
43
52
|
|
|
44
53
|
async function main() {
|
|
45
54
|
console.log(`\n🚀 Starting Optimized Indexer\n📂 Directory: ${PROJECT_ROOT}`);
|
|
46
|
-
console.log(
|
|
55
|
+
console.log('⚙️ Effective configuration:');
|
|
56
|
+
for (const line of describeConfig(config)) console.log(` ${line}`);
|
|
57
|
+
for (const notice of configNotices(config)) console.log(`⚠️ ${notice}`);
|
|
58
|
+
console.log('');
|
|
47
59
|
|
|
48
60
|
const ig = buildIgnoreFilter(PROJECT_ROOT);
|
|
49
61
|
const files = walkRepo(PROJECT_ROOT, PROJECT_ROOT, ig);
|
|
50
62
|
console.log(`Found ${files.length} files to analyse.\n`);
|
|
51
63
|
|
|
52
|
-
|
|
64
|
+
// Resolve the embedding provider for this run (Ollama → in-process local →
|
|
65
|
+
// lexical, unless forced). Index time and query time must share the model.
|
|
66
|
+
const embedder = await createEmbedder(config);
|
|
67
|
+
console.log(`🔎 Embeddings: ${describeEmbedder(embedder)}`);
|
|
68
|
+
// Make the `auto` fallback ladder observable — never silently lexical-only.
|
|
69
|
+
if (config.embedProvider === 'auto') {
|
|
70
|
+
if (embedder.provider === 'local') {
|
|
71
|
+
console.log(` ↪ Ollama not reachable at ${config.ollamaHost}; using the bundled in-process model (no daemon required).`);
|
|
72
|
+
} else if (embedder.provider === 'off') {
|
|
73
|
+
console.log(` ↪ No Ollama and no in-process model installed (\`npm i @huggingface/transformers\`) — indexing lexical-only.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
console.log('');
|
|
77
|
+
|
|
78
|
+
let existingCache = readEmbeddingBinary(EMBEDDINGS_PATH);
|
|
79
|
+
// A model switch invalidates the cached vectors — vectors of different models
|
|
80
|
+
// (and dims) must never be mixed in one bin. Re-embed from scratch.
|
|
81
|
+
const prevMeta = readEmbedMeta(EMBEDDINGS_PATH);
|
|
82
|
+
if (prevMeta?.model && embedder.model && prevMeta.model !== embedder.model) {
|
|
83
|
+
console.log(`⚠️ Embedding model changed (${prevMeta.model} → ${embedder.model}); re-embedding from scratch.`);
|
|
84
|
+
existingCache = new Map();
|
|
85
|
+
}
|
|
53
86
|
console.log(`📦 Loaded ${existingCache.size} cached embeddings from previous runs.\n`);
|
|
54
87
|
|
|
55
|
-
const indexData = { chunks: [], graph: { dependencies: {}, importedBy: {} }, embeddingCache: {} };
|
|
88
|
+
const indexData = { chunks: [], graph: { dependencies: {}, importedBy: {}, routes: [] }, embeddingCache: {} };
|
|
56
89
|
const pendingChunks = [];
|
|
90
|
+
// Full bodies of oversized chunks (snippet was truncated by the 3000-char cap),
|
|
91
|
+
// captured here while we still hold the source so the dense channel can window the
|
|
92
|
+
// WHOLE definition. Kept out of the chunk/JSON — transient, dropped after embedding.
|
|
93
|
+
const fullBodies = new Map();
|
|
57
94
|
let totalCheckedFiles = 0;
|
|
58
95
|
|
|
59
96
|
for (const absolutePath of files) {
|
|
@@ -79,75 +116,101 @@ async function main() {
|
|
|
79
116
|
const imports = resolveLocalImports(rawImports, relPath, PROJECT_ROOT);
|
|
80
117
|
indexData.graph.dependencies[relPath] = imports;
|
|
81
118
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
119
|
+
const fileChunks = extractSemanticChunks(tree.rootNode, relPath, content, ext);
|
|
120
|
+
for (const chunk of fileChunks) {
|
|
121
|
+
const fullBody = fullBodyForEmbedding(chunk, content);
|
|
122
|
+
if (fullBody) fullBodies.set(chunk.id, fullBody);
|
|
85
123
|
pendingChunks.push(chunk);
|
|
86
124
|
}
|
|
125
|
+
|
|
126
|
+
for (const route of extractRoutes(tree.rootNode, relPath, fileChunks, ext)) {
|
|
127
|
+
indexData.graph.routes.push(route);
|
|
128
|
+
}
|
|
87
129
|
} catch (err) {
|
|
88
130
|
console.error(`\n💥 Error in ${relPath}: ${err.message}`);
|
|
89
131
|
}
|
|
90
132
|
}
|
|
91
133
|
|
|
92
|
-
//
|
|
93
|
-
// Runs before embedding so the hypothetical questions ride the same vector.
|
|
134
|
+
// Enrichment runs before embedding so hypothetical questions share the same vector space.
|
|
94
135
|
if (config.enrichment.enabled) {
|
|
95
136
|
await enrichCoreChunks(pendingChunks, indexData.graph, config);
|
|
96
137
|
}
|
|
97
138
|
|
|
98
139
|
// ── Embedding generation (cache-aware) ────────────────────────────────────────
|
|
99
|
-
// Vectors are keyed by embeddingKeyFor(chunk)
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// this cache on re-runs — previously every enriched chunk was re-embedded on
|
|
103
|
-
// every single index run.
|
|
140
|
+
// Vectors are keyed by embeddingKeyFor(chunk), which includes an enrichment
|
|
141
|
+
// digest so enriched chunks hit the cache on re-runs (previously they were
|
|
142
|
+
// re-embedded on every run).
|
|
104
143
|
const toEmbed = [];
|
|
105
144
|
for (const chunk of pendingChunks) {
|
|
106
145
|
const vecKey = embeddingKeyFor(chunk);
|
|
107
146
|
const sKey = vecKey + SUMMARY_VEC_SUFFIX;
|
|
108
|
-
//
|
|
109
|
-
|
|
147
|
+
// Compute the payload once here and reuse it in the worker to avoid a second build.
|
|
148
|
+
const payload = buildEmbeddingPayload(chunk, indexData.graph.dependencies[chunk.file_path] || [], fullBodies.get(chunk.id) || null);
|
|
149
|
+
const windows = embeddingWindows(payload);
|
|
150
|
+
// Indexes built before dual/window vectors only have the base key, so we must
|
|
151
|
+
// check that summary and window keys are also cached before skipping embedding.
|
|
110
152
|
const summaryMissing = summaryEmbeddingText(chunk) && !existingCache.has(sKey);
|
|
111
|
-
|
|
153
|
+
let windowsMissing = false;
|
|
154
|
+
for (let i = 1; i < windows.length; i++) if (!existingCache.has(vecKey + WINDOW_VEC_SUFFIX + i)) { windowsMissing = true; break; }
|
|
155
|
+
if (existingCache.has(vecKey) && !summaryMissing && !windowsMissing) {
|
|
112
156
|
indexData.embeddingCache[vecKey] = Array.from(existingCache.get(vecKey));
|
|
113
157
|
if (existingCache.has(sKey)) indexData.embeddingCache[sKey] = Array.from(existingCache.get(sKey));
|
|
158
|
+
for (let i = 1; i < windows.length; i++) {
|
|
159
|
+
const wk = vecKey + WINDOW_VEC_SUFFIX + i;
|
|
160
|
+
if (existingCache.has(wk)) indexData.embeddingCache[wk] = Array.from(existingCache.get(wk));
|
|
161
|
+
}
|
|
114
162
|
indexData.chunks.push(chunk);
|
|
115
163
|
} else {
|
|
116
|
-
toEmbed.push(chunk);
|
|
164
|
+
toEmbed.push({ chunk, payload, windows });
|
|
117
165
|
}
|
|
118
166
|
}
|
|
119
167
|
|
|
120
|
-
console.log(`\n\n🧠 Embedding Generation (
|
|
168
|
+
console.log(`\n\n🧠 Embedding Generation — ${describeEmbedder(embedder)}`);
|
|
121
169
|
console.log(`Chunks reused from cache: ${indexData.chunks.length}`);
|
|
122
170
|
console.log(`New chunks to process: ${toEmbed.length}`);
|
|
123
171
|
|
|
124
172
|
if (toEmbed.length > 0) {
|
|
125
173
|
const BATCH_SIZE = 64;
|
|
126
|
-
|
|
174
|
+
// Embedding concurrency. A single local Ollama model serves requests
|
|
175
|
+
// serially, so a high fan-out just queues batches and risks per-request
|
|
176
|
+
// timeouts (a large model like qwen3-embedding:4b made every queued batch
|
|
177
|
+
// breach the old cap). Default 4 (fast on small models / when
|
|
178
|
+
// OLLAMA_NUM_PARALLEL is set); lower it (e.g. 1) for big models on modest
|
|
179
|
+
// hardware via INDEXER_EMBED_CONCURRENCY.
|
|
180
|
+
const CONCURRENCY = Number(process.env.INDEXER_EMBED_CONCURRENCY) > 0
|
|
181
|
+
? Number(process.env.INDEXER_EMBED_CONCURRENCY) : 4;
|
|
127
182
|
const batches = [];
|
|
128
183
|
for (let i = 0; i < toEmbed.length; i += BATCH_SIZE) batches.push(toEmbed.slice(i, i + BATCH_SIZE));
|
|
129
184
|
|
|
130
|
-
let completed = 0;
|
|
185
|
+
let completed = 0, vectorBatches = 0, failedBatches = 0;
|
|
131
186
|
console.time('Embedding Generation Duration');
|
|
132
187
|
const worker = async (batch) => {
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
188
|
+
// Oversized chunks need window vectors so semantic search can reach
|
|
189
|
+
// their tail (the embedder truncates the base text to window 0 only).
|
|
190
|
+
// Enriched chunks also get a compact summary vector whose vocabulary
|
|
191
|
+
// matches natural-language queries.
|
|
136
192
|
const entries = [];
|
|
137
|
-
for (const c of batch) {
|
|
138
|
-
|
|
193
|
+
for (const { chunk: c, payload, windows } of batch) {
|
|
194
|
+
const baseKey = embeddingKeyFor(c);
|
|
195
|
+
entries.push({ key: baseKey, text: payload });
|
|
196
|
+
for (let i = 1; i < windows.length; i++) entries.push({ key: baseKey + WINDOW_VEC_SUFFIX + i, text: windows[i] });
|
|
139
197
|
const sText = summaryEmbeddingText(c);
|
|
140
|
-
if (sText) entries.push({ key:
|
|
198
|
+
if (sText) entries.push({ key: baseKey + SUMMARY_VEC_SUFFIX, text: sText });
|
|
141
199
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
200
|
+
// Per-batch graceful degradation: a slow/failed embedding batch (e.g. a
|
|
201
|
+
// timeout on a big model) must NOT abort the whole index. Those chunks
|
|
202
|
+
// are still indexed lexically — they just miss their vector until the
|
|
203
|
+
// next run re-tries them (the cache only stores what succeeded).
|
|
204
|
+
let matrix = null;
|
|
205
|
+
try { matrix = await embedder.embedDocuments(entries.map(e => e.text)); }
|
|
206
|
+
catch (e) { failedBatches++; process.stderr.write(`\n⚠️ embedding batch failed (${e.message}); indexing ${batch.length} chunks lexical-only this run.\n`); }
|
|
145
207
|
if (matrix && matrix.length === entries.length) {
|
|
146
208
|
for (let j = 0; j < entries.length; j++) {
|
|
147
209
|
indexData.embeddingCache[entries[j].key] = matrix[j];
|
|
148
210
|
}
|
|
211
|
+
vectorBatches++;
|
|
149
212
|
}
|
|
150
|
-
for (const chunk of batch) indexData.chunks.push(chunk);
|
|
213
|
+
for (const { chunk } of batch) indexData.chunks.push(chunk);
|
|
151
214
|
completed += batch.length;
|
|
152
215
|
process.stdout.write(`\r🤖 Embedding Progress: [${completed}/${toEmbed.length}] Chunks processed...`);
|
|
153
216
|
};
|
|
@@ -155,9 +218,12 @@ async function main() {
|
|
|
155
218
|
await Promise.all(batches.slice(i, i + CONCURRENCY).map(worker));
|
|
156
219
|
}
|
|
157
220
|
console.timeEnd('Embedding Generation Duration');
|
|
221
|
+
if (failedBatches > 0) {
|
|
222
|
+
console.log(`⚠️ ${failedBatches}/${batches.length} embedding batches failed (lexical-only for those chunks); re-run to fill them in. `
|
|
223
|
+
+ `Tip: a large embed model on modest hardware wants INDEXER_EMBED_CONCURRENCY=1 and/or a higher INDEXER_EMBED_TIMEOUT_MS.`);
|
|
224
|
+
}
|
|
158
225
|
}
|
|
159
226
|
|
|
160
|
-
// ── Reverse topology edges ────────────────────────────────────────────────────
|
|
161
227
|
for (const [filePath, imports] of Object.entries(indexData.graph.dependencies)) {
|
|
162
228
|
for (const dep of imports) {
|
|
163
229
|
if (!indexData.graph.importedBy[dep]) indexData.graph.importedBy[dep] = [];
|
|
@@ -165,13 +231,23 @@ async function main() {
|
|
|
165
231
|
}
|
|
166
232
|
}
|
|
167
233
|
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
234
|
+
// Resolve 'auto' now that the true chunk count is known — the threshold is only
|
|
235
|
+
// meaningful after all chunks have been extracted.
|
|
236
|
+
const backend = config.storage === 'auto'
|
|
237
|
+
? (indexData.chunks.length >= AUTO_SQLITE_CHUNK_THRESHOLD ? 'sqlite' : 'memory')
|
|
238
|
+
: config.storage;
|
|
239
|
+
if (config.storage === 'auto') {
|
|
240
|
+
console.log(`🗄 Storage: auto → ${backend} (${indexData.chunks.length} chunks, threshold ${AUTO_SQLITE_CHUNK_THRESHOLD}).`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (backend === 'sqlite') {
|
|
244
|
+
const { SqliteGraphStore } = await import('./engine/sqlite.mjs');
|
|
171
245
|
const store = new SqliteGraphStore(config.sqlitePath, { embeddingPath: EMBEDDINGS_PATH });
|
|
172
246
|
const res = store.buildFrom({
|
|
173
247
|
chunks: indexData.chunks, graph: indexData.graph, embeddingCache: indexData.embeddingCache,
|
|
174
248
|
});
|
|
249
|
+
store.close?.();
|
|
250
|
+
for (const p of [INDEX_PATH, `${INDEX_PATH}.tmp`]) { try { fs.unlinkSync(p); } catch { /* none */ } }
|
|
175
251
|
console.log(`\n🎉 SQLite index built: ${res.chunks} chunks · ${res.terms} terms · dim ${res.dim}`);
|
|
176
252
|
console.log(` → ${config.sqlitePath}\n`);
|
|
177
253
|
} else {
|
|
@@ -185,8 +261,41 @@ async function main() {
|
|
|
185
261
|
fs.promises.rename(tmpPath, INDEX_PATH),
|
|
186
262
|
fs.promises.rename(tmpBinPath, EMBEDDINGS_PATH),
|
|
187
263
|
]);
|
|
188
|
-
|
|
264
|
+
for (const p of [config.sqlitePath, `${config.sqlitePath}-wal`, `${config.sqlitePath}-shm`]) {
|
|
265
|
+
try { fs.unlinkSync(p); } catch { /* none */ }
|
|
266
|
+
}
|
|
267
|
+
if (indexData.chunks.length === 0) {
|
|
268
|
+
console.log(`\n⚠️ Indexing finished but produced 0 chunks — no indexable definitions found under ${PROJECT_ROOT}.`);
|
|
269
|
+
console.log(` Check the --repo path is correct and contains supported languages (see README), and note that trivial one-line definitions are intentionally skipped.\n`);
|
|
270
|
+
} else {
|
|
271
|
+
console.log(`\n🎉 Indexing complete. Total fragments: ${indexData.chunks.length}\n`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Stamp which model produced the shared embeddings bin, so the server queries
|
|
276
|
+
// with the same provider and the next index run detects a model switch.
|
|
277
|
+
writeEmbedMeta(EMBEDDINGS_PATH, {
|
|
278
|
+
provider: embedder.provider,
|
|
279
|
+
model: embedder.model,
|
|
280
|
+
dim: embedder.dim ?? prevMeta?.dim ?? null,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Git signals are a sidecar kept out of the main index so they don't affect
|
|
284
|
+
// measured retrieval ranking — only opt-in tools consume them.
|
|
285
|
+
if (config.gitSignals) {
|
|
286
|
+
const signals = collectGitSignals(PROJECT_ROOT);
|
|
287
|
+
if (signals) {
|
|
288
|
+
writeGitSignals(config.gitSignalsPath, signals);
|
|
289
|
+
console.log(`🔄 Git signals: ${signals.commits} commits · ${Object.keys(signals.churn).length} files (churn/recency/co-change).\n`);
|
|
290
|
+
} else {
|
|
291
|
+
// Drop any stale sidecar so signals from a previous run don't linger.
|
|
292
|
+
try { fs.unlinkSync(config.gitSignalsPath); } catch { /* none to remove */ }
|
|
293
|
+
}
|
|
189
294
|
}
|
|
295
|
+
|
|
296
|
+
// Kill any subprocess-based embedder (MLX) so the event loop can drain.
|
|
297
|
+
// Without this the readline interface on proc.stdout keeps Node alive indefinitely.
|
|
298
|
+
_resetSubprocesses();
|
|
190
299
|
}
|
|
191
300
|
|
|
192
301
|
main().catch(console.error);
|