ziprin-context-optimizer 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +48 -0
  2. package/bin/ziprin-context-mcp.js +34 -0
  3. package/bin/ziprin-context-setup.js +10 -0
  4. package/install-source.json +5 -0
  5. package/media/context-activity.svg +7 -0
  6. package/package.json +154 -0
  7. package/releases/ziprin-context-optimizer-8.0.0.vsix +0 -0
  8. package/scripts/install.mjs +204 -0
  9. package/scripts/package-vsix.mjs +38 -0
  10. package/scripts/paths.mjs +40 -0
  11. package/scripts/publish.mjs +37 -0
  12. package/scripts/update.mjs +101 -0
  13. package/src/adaptive-budget.js +46 -0
  14. package/src/analyzer.js +456 -0
  15. package/src/audit-history.js +137 -0
  16. package/src/bm25.js +132 -0
  17. package/src/collector.js +343 -0
  18. package/src/compression.js +67 -0
  19. package/src/config.js +28 -0
  20. package/src/context-memory.js +151 -0
  21. package/src/context-profiles.js +124 -0
  22. package/src/dependency-graph.js +90 -0
  23. package/src/estimate.js +109 -0
  24. package/src/eval-harness.js +76 -0
  25. package/src/extension.js +322 -0
  26. package/src/firewall.js +35 -0
  27. package/src/fts-index.js +319 -0
  28. package/src/gateway-api.js +404 -0
  29. package/src/git-recency.js +43 -0
  30. package/src/glob.js +42 -0
  31. package/src/identifier.js +37 -0
  32. package/src/inspector-panel.js +470 -0
  33. package/src/language.js +157 -0
  34. package/src/mcp-event-stream.js +179 -0
  35. package/src/mcp-health-cli.js +34 -0
  36. package/src/mcp-lifecycle-manager.js +427 -0
  37. package/src/mcp-server.js +372 -0
  38. package/src/mmr.js +57 -0
  39. package/src/pagerank.js +176 -0
  40. package/src/profiles.js +74 -0
  41. package/src/pruner.js +130 -0
  42. package/src/quality-guard.js +122 -0
  43. package/src/query-rewrite.js +32 -0
  44. package/src/relevance-scorer.js +197 -0
  45. package/src/repo-map.js +134 -0
  46. package/src/retrieve.js +350 -0
  47. package/src/serena.js +126 -0
  48. package/src/session-ledger.js +83 -0
  49. package/src/session-store.js +78 -0
  50. package/src/skeleton.js +129 -0
  51. package/src/slice-pack.js +70 -0
  52. package/src/supervisor.js +113 -0
  53. package/src/task-analyzer.js +189 -0
  54. package/src/tool-router.js +92 -0
  55. package/src/version.js +5 -0
  56. package/templates/ziprin-context-mcp.mjs +68 -0
@@ -0,0 +1,322 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Ziprin Context Inspector — observer/dashboard only.
5
+ * Does not start MCP, run optimize, or own lifecycle. Native Cursor Chat is the UI.
6
+ */
7
+
8
+ const path = require('path');
9
+ const { listContextProfiles } = require('./context-profiles');
10
+ const { getConfig } = require('./config');
11
+ const { installTokenBudget } = require('./supervisor');
12
+ const {
13
+ clearSessions,
14
+ listSessions,
15
+ getSession,
16
+ historySummary,
17
+ SESSIONS_REL,
18
+ } = require('./session-store');
19
+ const {
20
+ EVENTS_REL,
21
+ readEvents,
22
+ servicesFromEvents,
23
+ } = require('./mcp-event-stream');
24
+ const {
25
+ runHealthCheck,
26
+ loadHealth,
27
+ HEALTH_REL,
28
+ } = require('./mcp-lifecycle-manager');
29
+
30
+ let vscode;
31
+ try {
32
+ vscode = require('vscode');
33
+ } catch {
34
+ vscode = null;
35
+ }
36
+
37
+ const { InspectorViewProvider } = require('./inspector-panel');
38
+
39
+ let provider;
40
+ let refreshTimer = null;
41
+ let sessionPollTimer = null;
42
+ let lastHistoryFingerprint = '';
43
+ let lastEventsFingerprint = '';
44
+ let lastMcpServices = null;
45
+
46
+ function workspaceRoot() {
47
+ if (!vscode || !vscode.workspace.workspaceFolders?.length) return null;
48
+ return vscode.workspace.workspaceFolders[0].uri.fsPath;
49
+ }
50
+
51
+ function cfg() {
52
+ return getConfig(vscode);
53
+ }
54
+
55
+ function enrichHistoryEntry(session) {
56
+ const h = historySummary(session);
57
+ const before = Number(h.tokensBeforePrune) || 0;
58
+ const after = Number(h.tokensAfterPrune) || 0;
59
+ const reductionPct =
60
+ session.tokenAnalysis?.reductionPct != null
61
+ ? session.tokenAnalysis.reductionPct
62
+ : before > 0
63
+ ? Math.round((1 - after / before) * 100)
64
+ : null;
65
+ const status =
66
+ session.qualityReport?.ok === false || /weak context/i.test(String(session.result || ''))
67
+ ? 'weak'
68
+ : 'ok';
69
+ const decisionSummary =
70
+ session.decisionReport?.topSelected?.[0]?.path ||
71
+ session.intent?.action ||
72
+ String(session.task || '').slice(0, 48);
73
+ return { ...h, reductionPct, status, decisionSummary, intent: session.intent };
74
+ }
75
+
76
+ function connectionState(root, history) {
77
+ if (!root) return { state: 'no-workspace' };
78
+ return {
79
+ state: 'observing',
80
+ lastSessionAt: history[0]?.timestamp || null,
81
+ sessionCount: history.length,
82
+ };
83
+ }
84
+
85
+ /** Derive MCP Services from event stream + optional cached health (observe only). */
86
+ function refreshMcpServicesFromDisk() {
87
+ const root = workspaceRoot();
88
+ if (!root) {
89
+ lastMcpServices = servicesFromEvents([], null);
90
+ return lastMcpServices;
91
+ }
92
+ const events = readEvents(root, 120);
93
+ const health = loadHealth(root);
94
+ lastMcpServices = servicesFromEvents(events, health);
95
+ lastEventsFingerprint = eventsFingerprint(events);
96
+ return lastMcpServices;
97
+ }
98
+
99
+ function eventsFingerprint(events) {
100
+ const last = events[events.length - 1];
101
+ return `${events.length}:${last?.timestamp || ''}:${last?.type || ''}`;
102
+ }
103
+
104
+ function pushModel() {
105
+ if (!provider) return;
106
+ const root = workspaceRoot();
107
+ const sessions = root ? listSessions(root, 40) : [];
108
+ const history = sessions.map(enrichHistoryEntry);
109
+ const latest = sessions[0] ? getSession(root, sessions[0].id) : null;
110
+ lastHistoryFingerprint = history.map((h) => h.id).join(',') + ':' + (latest?.timestamp || '');
111
+ if (!lastMcpServices) refreshMcpServicesFromDisk();
112
+ provider.setModel({
113
+ type: 'model',
114
+ profiles: listContextProfiles(),
115
+ history,
116
+ profileId: cfg().profile,
117
+ latestSession: latest,
118
+ connection: connectionState(root, history),
119
+ mcpServices: lastMcpServices,
120
+ });
121
+ }
122
+
123
+ function refreshHistoryFromDisk() {
124
+ pushModel();
125
+ const root = workspaceRoot();
126
+ if (!root || !provider) return;
127
+ const listed = listSessions(root, 1);
128
+ if (listed[0]) {
129
+ const session = getSession(root, listed[0].id);
130
+ if (session) provider.showSession(session);
131
+ }
132
+ }
133
+
134
+ function pollLiveArtifacts() {
135
+ const root = workspaceRoot();
136
+ if (!root) return;
137
+ const sessions = listSessions(root, 40);
138
+ const fp = sessions.map((s) => s.id).join(',') + ':' + (sessions[0]?.timestamp || '');
139
+ if (fp !== lastHistoryFingerprint) {
140
+ refreshHistoryFromDisk();
141
+ }
142
+ const events = readEvents(root, 120);
143
+ const efp = eventsFingerprint(events);
144
+ if (efp !== lastEventsFingerprint) {
145
+ lastMcpServices = servicesFromEvents(events, loadHealth(root));
146
+ lastEventsFingerprint = efp;
147
+ pushModel();
148
+ }
149
+ }
150
+
151
+ function scheduleLive() {
152
+ if (!vscode || !cfg().liveRefresh) return;
153
+ if (refreshTimer) clearTimeout(refreshTimer);
154
+ refreshTimer = setTimeout(() => {
155
+ refreshTimer = null;
156
+ refreshMcpServicesFromDisk();
157
+ pushModel();
158
+ }, Math.max(120, Number(cfg().refreshDebounceMs) || 500));
159
+ }
160
+
161
+ function activate(context) {
162
+ if (!vscode) return;
163
+
164
+ provider = new InspectorViewProvider({
165
+ setProfile: async (id) => {
166
+ await vscode.workspace
167
+ .getConfiguration('ziprinContext')
168
+ .update('profile', id, vscode.ConfigurationTarget.Workspace);
169
+ scheduleLive();
170
+ },
171
+ openSession: (id) => {
172
+ const root = workspaceRoot();
173
+ if (!root) return;
174
+ const session = getSession(root, id);
175
+ if (session) provider.showSession(session);
176
+ },
177
+ clear: async () => {
178
+ const root = workspaceRoot();
179
+ if (!root) return;
180
+ const ok = await vscode.window.showWarningMessage(
181
+ 'Clear all Context Inspector sessions?',
182
+ 'Clear',
183
+ 'Cancel'
184
+ );
185
+ if (ok !== 'Clear') return;
186
+ clearSessions(root);
187
+ pushModel();
188
+ },
189
+ });
190
+
191
+ context.subscriptions.push(
192
+ vscode.window.registerWebviewViewProvider('ziprinContext.inspector', provider, {
193
+ webviewOptions: { retainContextWhenHidden: true },
194
+ })
195
+ );
196
+
197
+ context.subscriptions.push(
198
+ vscode.commands.registerCommand('ziprinContext.openInspector', () =>
199
+ vscode.commands.executeCommand('workbench.view.extension.ziprinContext')
200
+ )
201
+ );
202
+
203
+ context.subscriptions.push(
204
+ vscode.commands.registerCommand('ziprinContext.refreshDashboard', () => {
205
+ refreshMcpServicesFromDisk();
206
+ refreshHistoryFromDisk();
207
+ })
208
+ );
209
+
210
+ // One-shot diagnostic probe — does not start MCP or replace Cursor lifecycle.
211
+ context.subscriptions.push(
212
+ vscode.commands.registerCommand('ziprinContext.diagnoseMcp', () => {
213
+ const root = workspaceRoot();
214
+ if (!root) {
215
+ vscode.window.showWarningMessage('Open a workspace to diagnose MCP.');
216
+ return;
217
+ }
218
+ try {
219
+ runHealthCheck(root, { persist: true });
220
+ } catch (err) {
221
+ vscode.window.showErrorMessage(`MCP diagnose failed: ${err.message}`);
222
+ }
223
+ const services = refreshMcpServicesFromDisk();
224
+ pushModel();
225
+ vscode.window.showInformationMessage(
226
+ `Ziprin MCP health: ${services?.overall || 'unknown'}. See Context Inspector → MCP Services.`
227
+ );
228
+ })
229
+ );
230
+
231
+ context.subscriptions.push(
232
+ vscode.commands.registerCommand('ziprinContext.installTokenBudget', async () => {
233
+ const root = workspaceRoot();
234
+ if (!root) return;
235
+ const choice = await vscode.window.showWarningMessage(
236
+ 'Install token budget: write supervisor stub and strip extra .mdc files.',
237
+ 'ادامه',
238
+ 'انصراف'
239
+ );
240
+ if (choice !== 'ادامه') return;
241
+ const r = installTokenBudget(root, { write: true, stripOtherMdc: cfg().stripOtherMdc });
242
+ vscode.window.showInformationMessage(
243
+ `Context Inspector: stub written, stripped ${r.stripped.length}, flipped ${r.flipped.length}.`
244
+ );
245
+ scheduleLive();
246
+ })
247
+ );
248
+
249
+ context.subscriptions.push(
250
+ vscode.workspace.onDidChangeConfiguration((e) => {
251
+ if (e.affectsConfiguration('ziprinContext')) scheduleLive();
252
+ })
253
+ );
254
+ context.subscriptions.push(
255
+ vscode.workspace.onDidChangeWorkspaceFolders(() => {
256
+ refreshMcpServicesFromDisk();
257
+ pushModel();
258
+ })
259
+ );
260
+
261
+ const root = workspaceRoot();
262
+ if (root) {
263
+ const sessionWatch = vscode.workspace.createFileSystemWatcher(
264
+ new vscode.RelativePattern(root, SESSIONS_REL)
265
+ );
266
+ sessionWatch.onDidChange(refreshHistoryFromDisk);
267
+ sessionWatch.onDidCreate(refreshHistoryFromDisk);
268
+ sessionWatch.onDidDelete(pushModel);
269
+ context.subscriptions.push(sessionWatch);
270
+
271
+ const eventsWatch = vscode.workspace.createFileSystemWatcher(
272
+ new vscode.RelativePattern(root, EVENTS_REL)
273
+ );
274
+ const onEvents = () => {
275
+ refreshMcpServicesFromDisk();
276
+ pushModel();
277
+ };
278
+ eventsWatch.onDidChange(onEvents);
279
+ eventsWatch.onDidCreate(onEvents);
280
+ eventsWatch.onDidDelete(onEvents);
281
+ context.subscriptions.push(eventsWatch);
282
+
283
+ const healthWatch = vscode.workspace.createFileSystemWatcher(
284
+ new vscode.RelativePattern(root, HEALTH_REL)
285
+ );
286
+ healthWatch.onDidChange(() => {
287
+ refreshMcpServicesFromDisk();
288
+ pushModel();
289
+ });
290
+ context.subscriptions.push(healthWatch);
291
+
292
+ context.subscriptions.push(
293
+ vscode.workspace.onDidSaveTextDocument((doc) => {
294
+ try {
295
+ const { patchFile } = require('./fts-index');
296
+ const rel = path.relative(root, doc.fileName).replace(/\\/g, '/');
297
+ if (rel && !rel.startsWith('..')) patchFile(root, rel);
298
+ } catch {
299
+ /* observer-side index patch; ignore */
300
+ }
301
+ })
302
+ );
303
+ }
304
+
305
+ // Backup poll when FS watchers miss appends (JSONL).
306
+ sessionPollTimer = setInterval(pollLiveArtifacts, 2000);
307
+ context.subscriptions.push({ dispose: () => clearInterval(sessionPollTimer) });
308
+
309
+ refreshMcpServicesFromDisk();
310
+ pushModel();
311
+ refreshHistoryFromDisk();
312
+ if (cfg().autoRevealPanel) {
313
+ vscode.commands.executeCommand('workbench.view.extension.ziprinContext');
314
+ }
315
+ }
316
+
317
+ function deactivate() {
318
+ if (sessionPollTimer) clearInterval(sessionPollTimer);
319
+ if (refreshTimer) clearTimeout(refreshTimer);
320
+ }
321
+
322
+ module.exports = { activate, deactivate };
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Context Firewall — block / summarize / allow classification.
5
+ */
6
+
7
+ const BLOCK = [
8
+ /node_modules\//,
9
+ /\/dist\//,
10
+ /\/build\//,
11
+ /\.next\//,
12
+ /\/reports\//,
13
+ /\.cursor\/(agents|skills|_tmp)/,
14
+ /project-index\.json$/,
15
+ /project-must-follow-catalog\.json$/,
16
+ /\.vsix$/,
17
+ ];
18
+
19
+ function firewallDecision(rel, bytes = 0) {
20
+ const r = String(rel || '').replace(/\\/g, '/');
21
+ for (const re of BLOCK) {
22
+ if (re.test(r)) {
23
+ return { action: 'block', reason: 'blocked by context firewall', path: r };
24
+ }
25
+ }
26
+ if (bytes > 40_000 || (/service|documentation|\.md$/i.test(r) && bytes > 12_000)) {
27
+ return { action: 'summarize', reason: 'large file — compress instead of full dump', path: r };
28
+ }
29
+ if (/\/src\/|packages\/(ui|shared)\/|AGENTS\.md$|\.policy\.json$|\.mdc$/.test(r)) {
30
+ return { action: 'allow', reason: 'task-relevant / architecture source', path: r };
31
+ }
32
+ return { action: 'allow', reason: 'candidate', path: r };
33
+ }
34
+
35
+ module.exports = { firewallDecision, BLOCK };
@@ -0,0 +1,319 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Persistent FTS5 unicode61 index at .ziprin-context/index.db
5
+ * Incremental by mtime. Column weights: path×8, body×1, symbols×12.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { tokenizePath, splitIdentifier } = require('./identifier');
11
+ const { tokenizeBody, scoreDocsBm25F } = require('./bm25');
12
+ const { isTokenBomb } = require('./pruner');
13
+
14
+ const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.md', '.mdc']);
15
+ const SKIP_DIR = new Set([
16
+ 'node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.turbo',
17
+ '.ziprin-context', 'reports', '__mocks__', 'generated',
18
+ ]);
19
+ const EXPORT_RE = /^export\s+(?:async\s+)?(?:function|class|const|type|interface|enum)\s+([A-Za-z0-9_]+)/gm;
20
+
21
+ const openDbs = new Map();
22
+ const memFallback = new Map();
23
+
24
+ function indexDir(root) {
25
+ return path.join(root, '.ziprin-context');
26
+ }
27
+
28
+ function dbPath(root) {
29
+ return path.join(indexDir(root), 'index.db');
30
+ }
31
+
32
+ function extractSymbols(text) {
33
+ const out = [];
34
+ EXPORT_RE.lastIndex = 0;
35
+ let m;
36
+ while ((m = EXPORT_RE.exec(text))) out.push(m[1]);
37
+ return out;
38
+ }
39
+
40
+ function walkSource(root, acc = [], max = 4000) {
41
+ function walk(abs) {
42
+ if (acc.length >= max) return;
43
+ let entries;
44
+ try {
45
+ entries = fs.readdirSync(abs, { withFileTypes: true });
46
+ } catch {
47
+ return;
48
+ }
49
+ for (const ent of entries) {
50
+ if (acc.length >= max) return;
51
+ const full = path.join(abs, ent.name);
52
+ if (ent.isDirectory()) {
53
+ if (SKIP_DIR.has(ent.name)) continue;
54
+ walk(full);
55
+ } else {
56
+ const rel = path.relative(root, full).replace(/\\/g, '/');
57
+ if (!SOURCE_EXT.has(path.extname(rel))) continue;
58
+ if (isTokenBomb(rel)) continue;
59
+ acc.push(rel);
60
+ }
61
+ }
62
+ }
63
+ walk(root);
64
+ return acc;
65
+ }
66
+
67
+ function openDb(root) {
68
+ if (openDbs.has(root)) return openDbs.get(root);
69
+ let DatabaseSync;
70
+ try {
71
+ ({ DatabaseSync } = require('node:sqlite'));
72
+ } catch {
73
+ return null;
74
+ }
75
+ try {
76
+ fs.mkdirSync(indexDir(root), { recursive: true });
77
+ const db = new DatabaseSync(dbPath(root));
78
+ db.exec('PRAGMA busy_timeout=5000');
79
+ db.exec('PRAGMA journal_mode=WAL');
80
+ db.exec(`
81
+ CREATE TABLE IF NOT EXISTS files (
82
+ path TEXT PRIMARY KEY,
83
+ mtime INTEGER NOT NULL,
84
+ size INTEGER NOT NULL
85
+ );
86
+ CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(
87
+ path,
88
+ body,
89
+ symbols,
90
+ tokenize = 'unicode61'
91
+ );
92
+ CREATE TABLE IF NOT EXISTS meta (
93
+ k TEXT PRIMARY KEY,
94
+ v TEXT
95
+ );
96
+ `);
97
+ openDbs.set(root, db);
98
+ return db;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function ftsQuery(tokens) {
105
+ const cleaned = [...new Set((tokens || [])
106
+ .map((t) => String(t).replace(/["']/g, '').trim())
107
+ .filter((t) => t.length > 1 && !/^[-+*(){}]+$/.test(t)))]
108
+ .slice(0, 20);
109
+ if (!cleaned.length) return '';
110
+ return cleaned.map((t) => `"${t}"`).join(' OR ');
111
+ }
112
+
113
+ function upsertMem(root, rel, rec) {
114
+ if (!memFallback.has(root)) memFallback.set(root, new Map());
115
+ memFallback.get(root).set(rel, rec);
116
+ }
117
+
118
+ function indexFileRecord(root, rel) {
119
+ const abs = path.join(root, rel);
120
+ let st;
121
+ try {
122
+ st = fs.statSync(abs);
123
+ } catch {
124
+ return null;
125
+ }
126
+ let text = '';
127
+ try {
128
+ text = fs.readFileSync(abs, 'utf8').slice(0, 24000);
129
+ } catch {
130
+ return null;
131
+ }
132
+ const symbols = extractSymbols(text);
133
+ return {
134
+ path: rel,
135
+ mtime: Math.round(st.mtimeMs),
136
+ size: st.size,
137
+ body: text,
138
+ symbols: symbols.join(' '),
139
+ pathTerms: tokenizePath(rel),
140
+ bodyTerms: tokenizeBody(text),
141
+ symbolTerms: symbols.flatMap((s) => [s.toLowerCase(), ...splitIdentifier(s)]),
142
+ };
143
+ }
144
+
145
+ function ensureIndex(root, opts = {}) {
146
+ const files = walkSource(root, [], opts.maxFiles || 4000);
147
+ const db = openDb(root);
148
+ let updated = 0;
149
+ let fromCache = 0;
150
+
151
+ if (db) {
152
+ const getMeta = db.prepare('SELECT mtime, size FROM files WHERE path = ?');
153
+ const insFile = db.prepare('INSERT OR REPLACE INTO files(path, mtime, size) VALUES (?, ?, ?)');
154
+ const delDocs = db.prepare('DELETE FROM docs WHERE path = ?');
155
+ const insDocs = db.prepare('INSERT INTO docs(path, body, symbols) VALUES (?, ?, ?)');
156
+ db.exec('BEGIN');
157
+ try {
158
+ const seen = new Set();
159
+ for (const rel of files) {
160
+ seen.add(rel);
161
+ const abs = path.join(root, rel);
162
+ let st;
163
+ try {
164
+ st = fs.statSync(abs);
165
+ } catch {
166
+ continue;
167
+ }
168
+ const mtime = Math.round(st.mtimeMs);
169
+ const row = getMeta.get(rel);
170
+ if (row && Number(row.mtime) === mtime && Number(row.size) === st.size) {
171
+ fromCache++;
172
+ continue;
173
+ }
174
+ const rec = indexFileRecord(root, rel);
175
+ if (!rec) continue;
176
+ delDocs.run(rel);
177
+ insDocs.run(rec.path, rec.body, rec.symbols);
178
+ insFile.run(rel, rec.mtime, rec.size);
179
+ upsertMem(root, rel, rec);
180
+ updated++;
181
+ }
182
+ const stale = db.prepare('SELECT path FROM files').all();
183
+ for (const row of stale) {
184
+ if (!seen.has(row.path)) {
185
+ db.prepare('DELETE FROM files WHERE path = ?').run(row.path);
186
+ delDocs.run(row.path);
187
+ memFallback.get(root)?.delete(row.path);
188
+ }
189
+ }
190
+ db.prepare("INSERT OR REPLACE INTO meta(k, v) VALUES ('builtAt', ?)").run(new Date().toISOString());
191
+ db.exec('COMMIT');
192
+ } catch (err) {
193
+ try { db.exec('ROLLBACK'); } catch { /* ignore */ }
194
+ throw err;
195
+ }
196
+ } else {
197
+ for (const rel of files) {
198
+ const rec = indexFileRecord(root, rel);
199
+ if (rec) {
200
+ upsertMem(root, rel, rec);
201
+ updated++;
202
+ }
203
+ }
204
+ }
205
+
206
+ return { files: files.length, updated, fromCache, backend: db ? 'fts5' : 'memory' };
207
+ }
208
+
209
+ function searchFts(root, tokens, opts = {}) {
210
+ const limit = opts.limit || 40;
211
+ const q = ftsQuery(tokens);
212
+ const db = openDb(root);
213
+ if (db && q) {
214
+ try {
215
+ const stmt = db.prepare(`
216
+ SELECT path, bm25(docs, 8.0, 1.0, 12.0) AS rank
217
+ FROM docs
218
+ WHERE docs MATCH ?
219
+ ORDER BY rank
220
+ LIMIT ?
221
+ `);
222
+ const rows = stmt.all(q, limit);
223
+ return rows.map((r, i) => ({
224
+ path: r.path,
225
+ score: -Number(r.rank) || (limit - i),
226
+ source: 'fts5',
227
+ }));
228
+ } catch {
229
+ /* fall through to memory */
230
+ }
231
+ }
232
+ const bag = memFallback.get(root);
233
+ if (!bag || !bag.size) {
234
+ ensureIndex(root);
235
+ }
236
+ const docs = [...(memFallback.get(root)?.values() || [])];
237
+ return scoreDocsBm25F(docs, tokens).filter((x) => x.score > 0).slice(0, limit)
238
+ .map((x) => ({ ...x, source: 'bm25f' }));
239
+ }
240
+
241
+ function patchFile(root, rel) {
242
+ if (!rel || isTokenBomb(rel)) return false;
243
+ if (!SOURCE_EXT.has(path.extname(rel))) return false;
244
+ const rec = indexFileRecord(root, rel);
245
+ if (!rec) {
246
+ const db = openDb(root);
247
+ if (db) {
248
+ try {
249
+ db.prepare('DELETE FROM files WHERE path = ?').run(rel);
250
+ db.prepare('DELETE FROM docs WHERE path = ?').run(rel);
251
+ } catch { /* ignore */ }
252
+ }
253
+ memFallback.get(root)?.delete(rel);
254
+ return false;
255
+ }
256
+ const db = openDb(root);
257
+ if (db) {
258
+ try {
259
+ db.exec('BEGIN');
260
+ db.prepare('DELETE FROM docs WHERE path = ?').run(rel);
261
+ db.prepare('INSERT INTO docs(path, body, symbols) VALUES (?, ?, ?)').run(rec.path, rec.body, rec.symbols);
262
+ db.prepare('INSERT OR REPLACE INTO files(path, mtime, size) VALUES (?, ?, ?)').run(rel, rec.mtime, rec.size);
263
+ db.exec('COMMIT');
264
+ } catch {
265
+ try { db.exec('ROLLBACK'); } catch { /* ignore */ }
266
+ }
267
+ }
268
+ upsertMem(root, rel, rec);
269
+ return true;
270
+ }
271
+
272
+ function indexStats(root) {
273
+ const db = openDb(root);
274
+ let fileCount = 0;
275
+ let builtAt = null;
276
+ let bytes = 0;
277
+ if (db) {
278
+ try {
279
+ fileCount = Number(db.prepare('SELECT COUNT(*) AS n FROM files').get()?.n || 0);
280
+ builtAt = db.prepare("SELECT v FROM meta WHERE k = 'builtAt'").get()?.v || null;
281
+ } catch { /* ignore */ }
282
+ try {
283
+ bytes = fs.statSync(dbPath(root)).size;
284
+ } catch { /* ignore */ }
285
+ } else {
286
+ fileCount = memFallback.get(root)?.size || 0;
287
+ }
288
+ return {
289
+ fileCount,
290
+ builtAt,
291
+ bytes,
292
+ backend: db ? 'fts5' : 'memory',
293
+ dbPath: db ? dbPath(root) : null,
294
+ };
295
+ }
296
+
297
+ function closeIndex(root) {
298
+ const db = openDbs.get(root);
299
+ if (db) {
300
+ try {
301
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
302
+ db.close();
303
+ } catch { /* ignore */ }
304
+ openDbs.delete(root);
305
+ }
306
+ memFallback.delete(root);
307
+ }
308
+
309
+ module.exports = {
310
+ ensureIndex,
311
+ searchFts,
312
+ patchFile,
313
+ indexStats,
314
+ closeIndex,
315
+ walkSource,
316
+ ftsQuery,
317
+ extractSymbols,
318
+ dbPath,
319
+ };