@wrongstack/tools 0.155.0 → 0.250.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 (71) hide show
  1. package/dist/audit.js +22 -1
  2. package/dist/audit.js.map +1 -1
  3. package/dist/background-indexer-DwJsyAB0.d.ts +373 -0
  4. package/dist/bash.js +121 -24
  5. package/dist/bash.js.map +1 -1
  6. package/dist/builtin.js +1553 -544
  7. package/dist/builtin.js.map +1 -1
  8. package/dist/circuit-breaker.d.ts +9 -2
  9. package/dist/circuit-breaker.js +11 -2
  10. package/dist/circuit-breaker.js.map +1 -1
  11. package/dist/codebase-index/index.d.ts +53 -2
  12. package/dist/codebase-index/index.js +866 -367
  13. package/dist/codebase-index/index.js.map +1 -1
  14. package/dist/codebase-index/worker.d.ts +2 -0
  15. package/dist/codebase-index/worker.js +2321 -0
  16. package/dist/codebase-index/worker.js.map +1 -0
  17. package/dist/diff.js +3 -2
  18. package/dist/diff.js.map +1 -1
  19. package/dist/document.js +1 -1
  20. package/dist/document.js.map +1 -1
  21. package/dist/edit.js +1 -1
  22. package/dist/edit.js.map +1 -1
  23. package/dist/exec.js +61 -11
  24. package/dist/exec.js.map +1 -1
  25. package/dist/fetch.js.map +1 -1
  26. package/dist/format.js +22 -1
  27. package/dist/format.js.map +1 -1
  28. package/dist/git.js +2 -1
  29. package/dist/git.js.map +1 -1
  30. package/dist/glob.js +1 -1
  31. package/dist/glob.js.map +1 -1
  32. package/dist/grep.js +3 -3
  33. package/dist/grep.js.map +1 -1
  34. package/dist/index.d.ts +5 -4
  35. package/dist/index.js +1593 -622
  36. package/dist/index.js.map +1 -1
  37. package/dist/install.js +66 -14
  38. package/dist/install.js.map +1 -1
  39. package/dist/lint.js +22 -1
  40. package/dist/lint.js.map +1 -1
  41. package/dist/logs.js +2 -2
  42. package/dist/logs.js.map +1 -1
  43. package/dist/outdated.js +2 -2
  44. package/dist/outdated.js.map +1 -1
  45. package/dist/pack.js +1553 -544
  46. package/dist/pack.js.map +1 -1
  47. package/dist/patch.js +2 -2
  48. package/dist/patch.js.map +1 -1
  49. package/dist/process-registry.d.ts +21 -16
  50. package/dist/process-registry.js +48 -10
  51. package/dist/process-registry.js.map +1 -1
  52. package/dist/read.js +1 -1
  53. package/dist/read.js.map +1 -1
  54. package/dist/replace.js +4 -3
  55. package/dist/replace.js.map +1 -1
  56. package/dist/scaffold.js +1 -1
  57. package/dist/scaffold.js.map +1 -1
  58. package/dist/search.js +19 -16
  59. package/dist/search.js.map +1 -1
  60. package/dist/test.js +22 -1
  61. package/dist/test.js.map +1 -1
  62. package/dist/todo.js +44 -0
  63. package/dist/todo.js.map +1 -1
  64. package/dist/tree.js +1 -1
  65. package/dist/tree.js.map +1 -1
  66. package/dist/typecheck.js +22 -1
  67. package/dist/typecheck.js.map +1 -1
  68. package/dist/write.js +1 -1
  69. package/dist/write.js.map +1 -1
  70. package/package.json +5 -5
  71. package/dist/background-indexer-CtbgPExj.d.ts +0 -228
@@ -1,17 +1,102 @@
1
- import { resolveWstackPaths, expectDefined, compileGlob } from '@wrongstack/core';
2
- import * as fs3 from 'node:fs/promises';
3
- import * as path4 from 'node:path';
1
+ import { resolveWstackPaths, expectDefined, compileGlob, truncate } from '@wrongstack/core';
4
2
  import { createRequire } from 'node:module';
5
3
  import * as fs from 'node:fs';
6
4
  import { writeFileSync, mkdirSync } from 'node:fs';
5
+ import * as path4 from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { Worker } from 'node:worker_threads';
8
+ import * as fs3 from 'node:fs/promises';
7
9
  import * as ts from 'typescript';
8
10
  import { execFileSync, spawnSync } from 'node:child_process';
9
11
  import * as os from 'node:os';
10
12
 
11
- // src/codebase-index/indexer.ts
13
+ // src/codebase-index/writer.ts
14
+
15
+ // src/codebase-index/circuit-breaker.ts
16
+ var CircuitOpenError = class extends Error {
17
+ name = "CircuitOpenError";
18
+ };
19
+ var IndexTimeoutError = class extends Error {
20
+ name = "IndexTimeoutError";
21
+ };
22
+ var LockError = class extends Error {
23
+ name = "LockError";
24
+ };
25
+ var IndexCircuitBreaker = class {
26
+ failureThreshold;
27
+ cooldownMs;
28
+ now;
29
+ state = "closed";
30
+ consecutiveFailures = 0;
31
+ openedAt = 0;
32
+ lastFailure = null;
33
+ probeInFlight = false;
34
+ constructor(opts = {}) {
35
+ this.failureThreshold = opts.failureThreshold ?? 3;
36
+ this.cooldownMs = opts.cooldownMs ?? 6e4;
37
+ this.now = opts.now ?? Date.now;
38
+ }
39
+ /**
40
+ * True when a run may proceed. An open circuit transitions to half-open once
41
+ * the cooldown has elapsed, admitting exactly one probe; further requests
42
+ * are rejected until that probe settles via recordSuccess/recordFailure.
43
+ */
44
+ allowRequest() {
45
+ if (this.state === "closed") return true;
46
+ if (this.state === "open") {
47
+ if (this.now() - this.openedAt < this.cooldownMs) return false;
48
+ this.state = "half-open";
49
+ this.probeInFlight = true;
50
+ return true;
51
+ }
52
+ if (this.probeInFlight) return false;
53
+ this.probeInFlight = true;
54
+ return true;
55
+ }
56
+ recordSuccess() {
57
+ this.state = "closed";
58
+ this.consecutiveFailures = 0;
59
+ this.lastFailure = null;
60
+ this.probeInFlight = false;
61
+ }
62
+ recordFailure(err) {
63
+ if (err instanceof LockError) {
64
+ this.lastFailure = `[transient/lock] ${err.message}`;
65
+ this.probeInFlight = false;
66
+ return;
67
+ }
68
+ this.lastFailure = err instanceof Error ? err.message : String(err);
69
+ this.probeInFlight = false;
70
+ this.consecutiveFailures++;
71
+ if (this.state === "half-open" || this.consecutiveFailures >= this.failureThreshold) {
72
+ this.state = "open";
73
+ this.openedAt = this.now();
74
+ }
75
+ }
76
+ /** Force-close the circuit (manual recovery: `/codebase-reindex`). */
77
+ reset() {
78
+ this.state = "closed";
79
+ this.consecutiveFailures = 0;
80
+ this.lastFailure = null;
81
+ this.probeInFlight = false;
82
+ this.openedAt = 0;
83
+ }
84
+ snapshot() {
85
+ return {
86
+ state: this.state,
87
+ consecutiveFailures: this.consecutiveFailures,
88
+ lastFailure: this.lastFailure,
89
+ cooldownRemainingMs: this.state === "open" ? Math.max(0, this.cooldownMs - (this.now() - this.openedAt)) : 0
90
+ };
91
+ }
92
+ };
93
+ var indexCircuitBreaker = new IndexCircuitBreaker();
94
+ function resetIndexCircuitBreaker() {
95
+ indexCircuitBreaker.reset();
96
+ }
12
97
 
13
98
  // src/codebase-index/schema.ts
14
- var SCHEMA_VERSION = 1;
99
+ var SCHEMA_VERSION = 2;
15
100
 
16
101
  // src/codebase-index/lsp-kind.ts
17
102
  function lspKindToInternalKind(k) {
@@ -75,6 +160,94 @@ function internalKindToLspKind(k) {
75
160
  }
76
161
  }
77
162
 
163
+ // src/codebase-index/bm25.ts
164
+ var K1 = 1.5;
165
+ var B = 0.75;
166
+ function tokenise(text) {
167
+ const sanitised = text.replace(/[^\p{L}\p{N}$'_]/gu, " ").replace(/_/g, " ");
168
+ return sanitised.toLowerCase().split(" ").filter(Boolean);
169
+ }
170
+ function splitName(name) {
171
+ return name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
172
+ }
173
+ function buildIndexableText(name, signature, docComment) {
174
+ return [splitName(name), name, signature, docComment].filter(Boolean).join(" ");
175
+ }
176
+ function buildBm25Index(docs) {
177
+ const documents = docs.map((d) => {
178
+ const tokens = tokenise(d.text);
179
+ return { id: d.id, tokens, raw: d.text, len: tokens.length };
180
+ });
181
+ const df = {};
182
+ for (const doc of documents) {
183
+ const seen = /* @__PURE__ */ new Set();
184
+ for (const t of doc.tokens) {
185
+ if (!seen.has(t)) {
186
+ df[t] = (df[t] ?? 0) + 1;
187
+ seen.add(t);
188
+ }
189
+ }
190
+ }
191
+ const N = documents.length;
192
+ const totalLen = documents.reduce((sum, d) => sum + d.len, 0);
193
+ const avgLen = N === 0 ? 0 : totalLen / N;
194
+ return new Bm25Index(documents, df, N, avgLen);
195
+ }
196
+ var Bm25Index = class {
197
+ constructor(documents, df, N, avgLen) {
198
+ this.documents = documents;
199
+ this.df = df;
200
+ this.N = N;
201
+ this.safeAvgLen = avgLen === 0 ? 1 : avgLen;
202
+ }
203
+ documents;
204
+ df;
205
+ N;
206
+ safeAvgLen;
207
+ score(query, filter) {
208
+ const qTokens = tokenise(query);
209
+ if (qTokens.length === 0) return [];
210
+ const results = [];
211
+ for (const doc of this.documents) {
212
+ if (filter && !filter(doc.id)) continue;
213
+ let docScore = 0;
214
+ for (const qTerm of qTokens) {
215
+ let tf = 0;
216
+ for (const t of doc.tokens) {
217
+ if (t === qTerm) tf++;
218
+ }
219
+ if (tf === 0) continue;
220
+ const dfVal = this.df[qTerm] ?? 0;
221
+ if (dfVal === 0) continue;
222
+ const idf = Math.log((this.N - dfVal + 0.5) / (dfVal + 0.5) + 1);
223
+ const lenRatio = B * (doc.len / this.safeAvgLen);
224
+ const tfComponent = tf * (K1 + 1) / (tf + K1 * (1 - B + lenRatio));
225
+ docScore += idf * tfComponent;
226
+ }
227
+ if (docScore > 0) results.push({ id: doc.id, score: docScore });
228
+ }
229
+ return results;
230
+ }
231
+ getDoc(id) {
232
+ return this.documents.find((d) => d.id === id);
233
+ }
234
+ extractSnippet(docId, queryTokens, radius = 40) {
235
+ const doc = this.getDoc(docId);
236
+ if (!doc) return "";
237
+ for (const tok of queryTokens) {
238
+ const idx = doc.raw.toLowerCase().indexOf(tok);
239
+ if (idx !== -1) {
240
+ const start = Math.max(0, idx - radius);
241
+ const end = Math.min(doc.raw.length, idx + tok.length + radius);
242
+ const excerpt = doc.raw.slice(start, end);
243
+ const ellipsis = "\u2026";
244
+ return (start > 0 ? ellipsis : "") + excerpt + (end < doc.raw.length ? ellipsis : "");
245
+ }
246
+ }
247
+ return doc.raw.slice(0, radius * 2) + (doc.raw.length > radius * 2 ? "\u2026" : "");
248
+ }
249
+ };
250
+
78
251
  // src/codebase-index/writer.ts
79
252
  var DB_FILE = "index.db";
80
253
  function resolveIndexDir(projectRoot, override) {
@@ -110,15 +283,79 @@ function loadDatabaseSync() {
110
283
  }
111
284
  return DatabaseSyncCtor;
112
285
  }
286
+ var MAX_LOCK_RETRIES = 3;
287
+ var LOCK_RETRY_BASE_DELAY_MS = 50;
288
+ var LOCK_RETRY_MAX_DELAY_MS = 500;
289
+ function isLockError(err) {
290
+ if (!(err instanceof Error)) return false;
291
+ const e = err;
292
+ const code = e.code ?? e.sqliteCode;
293
+ if (typeof code === "string" && /SQLITE_(BUSY|LOCKED)/.test(code)) return true;
294
+ if (typeof code === "number" && (code === 5 || code === 6)) return true;
295
+ if (/SQLITE_(BUSY|LOCKED)/.test(err.message)) return true;
296
+ return false;
297
+ }
298
+ function sleepSync(ms) {
299
+ try {
300
+ const sab = new SharedArrayBuffer(4);
301
+ const view = new Int32Array(sab);
302
+ Atomics.wait(view, 0, 0, ms);
303
+ } catch {
304
+ }
305
+ }
113
306
  var IndexStore = class {
114
307
  db;
115
308
  /** Absolute path to this project's index directory. */
116
309
  indexDir;
310
+ /**
311
+ * True when the SQLite build provides FTS5 (Node's bundled SQLite does).
312
+ * When false, ranked search falls back to the LIKE + in-process BM25 path.
313
+ */
314
+ ftsAvailable = false;
315
+ /**
316
+ * Execute a SQLite write operation with automatic retry on lock conflicts.
317
+ *
318
+ * When another wstack process is holding the write lock the statement first
319
+ * waits up to `busy_timeout` ms, then throws SQLITE_BUSY. This wrapper catches
320
+ * that error and retries (up to MAX_LOCK_RETRIES) with exponential backoff,
321
+ * giving the competing writer time to finish and release the lock.
322
+ *
323
+ * @param fn The write operation to execute. Can return a value which is
324
+ * returned to the caller on success.
325
+ * @throws {@link LockError} when all retries are exhausted on a lock conflict
326
+ * (non-lock errors always propagate on the first attempt).
327
+ */
328
+ runWithRetry(fn) {
329
+ let lastError;
330
+ for (let attempt = 0; attempt <= MAX_LOCK_RETRIES; attempt++) {
331
+ try {
332
+ return fn();
333
+ } catch (err) {
334
+ lastError = err;
335
+ if (!isLockError(err)) throw err;
336
+ if (attempt === MAX_LOCK_RETRIES) {
337
+ const msg = lastError instanceof Error ? lastError.message : String(lastError);
338
+ throw new LockError(`SQLite lock conflict after ${MAX_LOCK_RETRIES} retries: ${msg}`);
339
+ }
340
+ const delay = Math.min(
341
+ LOCK_RETRY_BASE_DELAY_MS * Math.pow(2, attempt),
342
+ LOCK_RETRY_MAX_DELAY_MS
343
+ );
344
+ sleepSync(delay);
345
+ }
346
+ }
347
+ throw lastError;
348
+ }
117
349
  constructor(projectRoot, opts = {}) {
118
350
  this.indexDir = resolveIndexDir(projectRoot, opts.indexDir);
119
351
  fs.mkdirSync(this.indexDir, { recursive: true });
120
352
  const Database = loadDatabaseSync();
121
353
  this.db = new Database(path4.join(this.indexDir, DB_FILE));
354
+ try {
355
+ this.db.exec("PRAGMA journal_mode = WAL");
356
+ this.db.exec("PRAGMA busy_timeout = 5000");
357
+ } catch {
358
+ }
122
359
  this.initSchema();
123
360
  }
124
361
  initSchema() {
@@ -127,6 +364,21 @@ var IndexStore = class {
127
364
  key TEXT PRIMARY KEY,
128
365
  value TEXT NOT NULL
129
366
  );
367
+ `);
368
+ const storedRows = this.db.prepare("SELECT value FROM metadata WHERE key = ?").all("version");
369
+ const storedVersion = storedRows.length ? Number(storedRows[0]?.value) : null;
370
+ if (storedVersion !== null && storedVersion !== SCHEMA_VERSION) {
371
+ this.db.exec(`
372
+ DROP TABLE IF EXISTS symbols;
373
+ DROP TABLE IF EXISTS files;
374
+ DROP TABLE IF EXISTS refs;
375
+ `);
376
+ this.db.exec("DROP TABLE IF EXISTS symbols_fts");
377
+ this.db.prepare("UPDATE metadata SET value = ? WHERE key = ?").run(String(SCHEMA_VERSION), "version");
378
+ } else if (storedVersion === null) {
379
+ this.db.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)").run("version", String(SCHEMA_VERSION));
380
+ }
381
+ this.db.exec(`
130
382
  CREATE TABLE IF NOT EXISTS files (
131
383
  file TEXT PRIMARY KEY,
132
384
  lang TEXT NOT NULL,
@@ -167,53 +419,76 @@ var IndexStore = class {
167
419
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_to_id ON refs(to_id)");
168
420
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_to_name ON refs(to_name)");
169
421
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_call_type ON refs(call_type)");
170
- const versionRows = this.db.prepare("SELECT value FROM metadata WHERE key = ?").all("version");
171
- if (!versionRows.length) {
172
- this.db.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)").run("version", String(SCHEMA_VERSION));
422
+ try {
423
+ this.db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(text, tokenize = 'unicode61')");
424
+ this.ftsAvailable = true;
425
+ } catch {
426
+ this.ftsAvailable = false;
173
427
  }
174
428
  }
175
429
  // ─── Symbol CRUD ─────────────────────────────────────────────────────────────
176
430
  insertSymbols(symbols, nextId) {
177
- const stmt = this.db.prepare(
178
- `INSERT INTO symbols(id, lang, kind, name, file, line, col, signature, doc_comment, scope, text, file_fk)
179
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
180
- );
181
- let id = nextId;
182
- for (const s of symbols) {
183
- stmt.run(
184
- id++,
185
- s.lang,
186
- s.kind,
187
- s.name,
188
- s.file,
189
- s.line,
190
- s.col,
191
- s.signature,
192
- s.docComment,
193
- s.scope,
194
- s.text,
195
- s.file
431
+ return this.runWithRetry(() => {
432
+ const stmt = this.db.prepare(
433
+ `INSERT INTO symbols(id, lang, kind, name, file, line, col, signature, doc_comment, scope, text, file_fk)
434
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
196
435
  );
197
- }
198
- return id;
436
+ const ftsStmt = this.ftsAvailable ? this.db.prepare("INSERT INTO symbols_fts(rowid, text) VALUES (?, ?)") : null;
437
+ let id = nextId;
438
+ for (const s of symbols) {
439
+ stmt.run(
440
+ id,
441
+ s.lang,
442
+ s.kind,
443
+ s.name,
444
+ s.file,
445
+ s.line,
446
+ s.col,
447
+ s.signature,
448
+ s.docComment,
449
+ s.scope,
450
+ s.text,
451
+ s.file
452
+ );
453
+ ftsStmt?.run(id, buildIndexableText(s.name, s.signature, s.docComment));
454
+ id++;
455
+ }
456
+ return id;
457
+ });
199
458
  }
200
459
  deleteSymbolsForFile(file) {
201
- this.db.prepare("DELETE FROM symbols WHERE file_fk = ?").run(file);
460
+ this.runWithRetry(() => {
461
+ if (this.ftsAvailable) {
462
+ this.db.prepare("DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file_fk = ?)").run(file);
463
+ }
464
+ this.db.prepare("DELETE FROM symbols WHERE file_fk = ?").run(file);
465
+ });
202
466
  }
467
+ /**
468
+ * Remove every trace of a file (refs, symbols, FTS rows, file meta). Used
469
+ * when a source file disappears between index runs — previously this only
470
+ * dropped the `files` row, leaving its symbols orphaned but still searchable.
471
+ */
203
472
  deleteFile(file) {
204
- this.db.prepare("DELETE FROM files WHERE file = ?").run(file);
473
+ this.runWithRetry(() => {
474
+ this.deleteRefsForFile(file);
475
+ this.deleteSymbolsForFile(file);
476
+ this.db.prepare("DELETE FROM files WHERE file = ?").run(file);
477
+ });
205
478
  }
206
479
  // ─── File metadata ──────────────────────────────────────────────────────────
207
480
  upsertFile(meta) {
208
- this.db.prepare(
209
- `INSERT INTO files(file, lang, mtime_ms, symbol_count, last_indexed)
210
- VALUES (?, ?, ?, ?, ?)
211
- ON CONFLICT(file) DO UPDATE SET
212
- lang = excluded.lang,
213
- mtime_ms = excluded.mtime_ms,
214
- symbol_count = excluded.symbol_count,
215
- last_indexed = excluded.last_indexed`
216
- ).run(meta.file, meta.lang, meta.mtimeMs, meta.symbolCount, meta.lastIndexed);
481
+ this.runWithRetry(() => {
482
+ this.db.prepare(
483
+ `INSERT INTO files(file, lang, mtime_ms, symbol_count, last_indexed)
484
+ VALUES (?, ?, ?, ?, ?)
485
+ ON CONFLICT(file) DO UPDATE SET
486
+ lang = excluded.lang,
487
+ mtime_ms = excluded.mtime_ms,
488
+ symbol_count = excluded.symbol_count,
489
+ last_indexed = excluded.last_indexed`
490
+ ).run(meta.file, meta.lang, meta.mtimeMs, meta.symbolCount, meta.lastIndexed);
491
+ });
217
492
  }
218
493
  getFileMeta(file) {
219
494
  const rows = this.db.prepare(
@@ -280,6 +555,94 @@ var IndexStore = class {
280
555
  lspKind: filter?.lspKind
281
556
  }));
282
557
  }
558
+ /**
559
+ * Ranked search — the one-stop query the codebase-search tool and plug-lsp
560
+ * use. With FTS5 this is a single indexed `MATCH` ranked by SQLite's native
561
+ * `bm25()` with a built-in `snippet()`; without FTS5 it falls back to the
562
+ * legacy LIKE scan + in-process BM25 (identical semantics, slower).
563
+ *
564
+ * Tokens are matched as prefixes (`"tok"*`), mirroring the old
565
+ * `LIKE '%tok%'` recall for the common symbol-search shapes ("user" finds
566
+ * "users", camelCase-split text makes "complex" find "complexOperation").
567
+ */
568
+ searchRanked(query, filter, limit) {
569
+ const tokens = tokenise(query);
570
+ if (tokens.length === 0 || !this.ftsAvailable) {
571
+ return this.searchRankedFallback(query, filter, limit);
572
+ }
573
+ let effectiveKind = filter?.kind;
574
+ if (filter?.lspKind !== void 0) {
575
+ const mapped = lspKindToInternalKind(filter.lspKind);
576
+ if (mapped === null) return { results: [], total: 0 };
577
+ effectiveKind = mapped;
578
+ }
579
+ const match = tokens.map((t) => `"${t.replaceAll('"', "")}"*`).join(" OR ");
580
+ const conditions = ["symbols_fts MATCH ?"];
581
+ const values = [match];
582
+ if (effectiveKind) {
583
+ conditions.push("s.kind = ?");
584
+ values.push(effectiveKind);
585
+ }
586
+ if (filter?.lang) {
587
+ conditions.push("s.lang = ?");
588
+ values.push(filter.lang);
589
+ }
590
+ if (filter?.file) {
591
+ conditions.push("s.file LIKE ?");
592
+ values.push(`%${filter.file}%`);
593
+ }
594
+ const where = conditions.join(" AND ");
595
+ const countRows = this.db.prepare(`SELECT COUNT(*) AS n FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid WHERE ${where}`).all(...values);
596
+ const total = countRows[0] ? Number(countRows[0].n) : 0;
597
+ if (total === 0) return { results: [], total: 0 };
598
+ const rows = this.db.prepare(
599
+ `SELECT s.id, s.lang, s.kind, s.name, s.file, s.line, s.col, s.signature, s.doc_comment,
600
+ -bm25(symbols_fts) AS score,
601
+ snippet(symbols_fts, 0, '', '', '\u2026', 12) AS snippet
602
+ FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid
603
+ WHERE ${where}
604
+ ORDER BY bm25(symbols_fts)
605
+ LIMIT ?`
606
+ ).all(...values, limit);
607
+ return {
608
+ results: rows.map((r) => ({
609
+ id: r.id,
610
+ lang: r.lang,
611
+ kind: r.kind,
612
+ name: r.name,
613
+ file: r.file,
614
+ line: r.line,
615
+ col: r.col,
616
+ signature: r.signature,
617
+ docComment: r.doc_comment,
618
+ // bm25() is negative-is-better; negate so callers keep "higher is
619
+ // better" and clamp so a match never reports a zero score.
620
+ score: Math.max(1e-4, r.score),
621
+ snippet: r.snippet,
622
+ lspKind: filter?.lspKind
623
+ })),
624
+ total
625
+ };
626
+ }
627
+ /** Legacy ranked path: LIKE candidates + in-process BM25 + JS snippets. */
628
+ searchRankedFallback(query, filter, limit) {
629
+ const candidates = this.search(query, filter);
630
+ if (candidates.length === 0) return { results: [], total: 0 };
631
+ if (!query.trim()) {
632
+ return { results: candidates.slice(0, limit), total: candidates.length };
633
+ }
634
+ const bm25 = buildBm25Index(
635
+ candidates.map((c) => ({ id: c.id, text: buildIndexableText(c.name, c.signature, c.docComment) }))
636
+ );
637
+ const scored = bm25.score(query, (id) => candidates.some((c) => c.id === id));
638
+ scored.sort((a, b) => b.score - a.score);
639
+ const qTokens = tokenise(query);
640
+ const results = scored.slice(0, limit).map(({ id, score }) => {
641
+ const c = expectDefined(candidates.find((cand) => cand.id === id));
642
+ return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
643
+ });
644
+ return { results, total: candidates.length };
645
+ }
283
646
  getAllIndexable() {
284
647
  return this.db.prepare("SELECT id, text FROM symbols").all().map(
285
648
  ({ id, text }) => ({ id, text })
@@ -329,14 +692,19 @@ var IndexStore = class {
329
692
  };
330
693
  }
331
694
  setLastIndexed(ts2) {
332
- this.db.prepare(
333
- "INSERT OR REPLACE INTO metadata(key, value) VALUES('last_indexed', ?)"
334
- ).run(String(ts2));
695
+ this.runWithRetry(() => {
696
+ this.db.prepare(
697
+ "INSERT OR REPLACE INTO metadata(key, value) VALUES('last_indexed', ?)"
698
+ ).run(String(ts2));
699
+ });
335
700
  }
336
701
  clearAll() {
337
- this.db.exec("DELETE FROM symbols");
338
- this.db.exec("DELETE FROM files");
339
- this.db.exec("DELETE FROM refs");
702
+ this.runWithRetry(() => {
703
+ this.db.exec("DELETE FROM symbols");
704
+ this.db.exec("DELETE FROM files");
705
+ this.db.exec("DELETE FROM refs");
706
+ if (this.ftsAvailable) this.db.exec("DELETE FROM symbols_fts");
707
+ });
340
708
  }
341
709
  // ─── Ref CRUD ────────────────────────────────────────────────────────────────
342
710
  /**
@@ -344,46 +712,52 @@ var IndexStore = class {
344
712
  * Replaces any existing refs from the same source (idempotent on re-index).
345
713
  */
346
714
  insertRefs(fromId, refs) {
347
- this.db.prepare("DELETE FROM refs WHERE from_id = ?").run(fromId);
348
- if (refs.length === 0) return;
349
- const stmt = this.db.prepare(
350
- `INSERT INTO refs(from_id, to_name, to_id, call_type, line)
351
- VALUES (?, ?, ?, ?, ?)`
352
- );
353
- for (const ref of refs) {
354
- stmt.run(fromId, ref.toName, ref.toId ?? null, ref.callType, ref.line);
355
- }
715
+ this.runWithRetry(() => {
716
+ this.db.prepare("DELETE FROM refs WHERE from_id = ?").run(fromId);
717
+ if (refs.length === 0) return;
718
+ const stmt = this.db.prepare(
719
+ `INSERT INTO refs(from_id, to_name, to_id, call_type, line)
720
+ VALUES (?, ?, ?, ?, ?)`
721
+ );
722
+ for (const ref of refs) {
723
+ stmt.run(fromId, ref.toName, ref.toId ?? null, ref.callType, ref.line);
724
+ }
725
+ });
356
726
  }
357
727
  /**
358
728
  * Delete all refs whose source symbols are in a given file.
359
729
  * Used when re-indexing a file to clear stale refs.
360
730
  */
361
731
  deleteRefsForFile(file) {
362
- const ids = this.db.prepare(
363
- "SELECT id FROM symbols WHERE file = ?"
364
- ).all(file);
365
- if (!ids.length) return;
366
- const placeholders = ids.map(() => "?").join(",");
367
- this.db.prepare(`DELETE FROM refs WHERE from_id IN (${placeholders})`).run(...ids.map((r) => r.id));
732
+ this.runWithRetry(() => {
733
+ const ids = this.db.prepare(
734
+ "SELECT id FROM symbols WHERE file = ?"
735
+ ).all(file);
736
+ if (!ids.length) return;
737
+ const placeholders = ids.map(() => "?").join(",");
738
+ this.db.prepare(`DELETE FROM refs WHERE from_id IN (${placeholders})`).run(...ids.map((r) => r.id));
739
+ });
368
740
  }
369
741
  /**
370
742
  * Resolve `to_name` → `to_id` for all refs that have a name but no id.
371
743
  * Call this after all symbols have been inserted to fill in cross-references.
372
744
  */
373
745
  resolveRefs() {
374
- const unresolved = this.db.prepare(
375
- "SELECT id, to_name FROM refs WHERE to_id IS NULL AND to_name IS NOT NULL"
376
- ).all();
377
- let resolved = 0;
378
- for (const row of unresolved) {
379
- const target = this.db.prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1").all(row.to_name);
380
- const first = target[0];
381
- if (first) {
382
- this.db.prepare("UPDATE refs SET to_id = ? WHERE id = ?").run(first.id, row.id);
383
- resolved++;
746
+ return this.runWithRetry(() => {
747
+ const unresolved = this.db.prepare(
748
+ "SELECT id, to_name FROM refs WHERE to_id IS NULL AND to_name IS NOT NULL"
749
+ ).all();
750
+ let resolved = 0;
751
+ for (const row of unresolved) {
752
+ const target = this.db.prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1").all(row.to_name);
753
+ const first = target[0];
754
+ if (first) {
755
+ this.db.prepare("UPDATE refs SET to_id = ? WHERE id = ?").run(first.id, row.id);
756
+ resolved++;
757
+ }
384
758
  }
385
- }
386
- return resolved;
759
+ return resolved;
760
+ });
387
761
  }
388
762
  /**
389
763
  * Find all references TO a given symbol (who calls / uses this symbol?).
@@ -1144,7 +1518,7 @@ function parseSymbols4(opts) {
1144
1518
  }
1145
1519
  function checkNativeParser() {
1146
1520
  try {
1147
- execFileSync("rustc", ["--version"], { stdio: "pipe" });
1521
+ execFileSync("rustc", ["--version"], { stdio: "pipe", windowsHide: true });
1148
1522
  const toolsDir = path4.join(process.cwd(), "tools");
1149
1523
  try {
1150
1524
  execFileSync(
@@ -1157,7 +1531,7 @@ function checkNativeParser() {
1157
1531
  "--manifest-path",
1158
1532
  path4.join(toolsDir, "Cargo.toml")
1159
1533
  ],
1160
- { stdio: "pipe" }
1534
+ { stdio: "pipe", windowsHide: true }
1161
1535
  );
1162
1536
  return true;
1163
1537
  } catch {
@@ -1180,7 +1554,8 @@ function tryNativeParse(file, content) {
1180
1554
  cwd: process.cwd(),
1181
1555
  encoding: "utf8",
1182
1556
  timeout: 15e3,
1183
- stdio: ["pipe", "pipe", "pipe"]
1557
+ stdio: ["pipe", "pipe", "pipe"],
1558
+ windowsHide: true
1184
1559
  }
1185
1560
  );
1186
1561
  if (result.status === 0 && result.stdout) {
@@ -1594,10 +1969,6 @@ function isScalar(value) {
1594
1969
  if (/^'[^']*'$/.test(value) || /^"[^"]*"$/.test(value)) return true;
1595
1970
  return false;
1596
1971
  }
1597
- function truncate(s, max) {
1598
- if (s.length <= max) return s;
1599
- return s.slice(0, max) + "...";
1600
- }
1601
1972
  function makeSymbol2(opts) {
1602
1973
  return {
1603
1974
  id: 0,
@@ -1664,136 +2035,16 @@ async function loadGitignoreMatcher(projectRoot) {
1664
2035
  return compileGitignore(lines);
1665
2036
  }
1666
2037
 
1667
- // src/codebase-index/background-indexer.ts
1668
- var _ready = false;
1669
- var _indexing = false;
1670
- var _currentFile = 0;
1671
- var _totalFiles = 0;
1672
- var _lastError = null;
1673
- function isIndexReady() {
1674
- return _ready;
1675
- }
1676
- function setIndexReady() {
1677
- _ready = true;
1678
- }
1679
- function isIndexing() {
1680
- return _indexing;
1681
- }
1682
- function getIndexState() {
1683
- return {
1684
- ready: _ready,
1685
- indexing: _indexing,
1686
- currentFile: _currentFile,
1687
- totalFiles: _totalFiles,
1688
- lastError: _lastError
1689
- };
1690
- }
1691
- var _listeners = [];
1692
- function onIndexStateChange(listener) {
1693
- _listeners.push(listener);
1694
- return () => {
1695
- _listeners = _listeners.filter((l) => l !== listener);
1696
- };
1697
- }
1698
- function emitState() {
1699
- const state = getIndexState();
1700
- for (const l of _listeners) l(state);
1701
- }
1702
- function _setIndexProgress(current, total) {
1703
- _currentFile = current;
1704
- _totalFiles = total;
1705
- emitState();
1706
- }
1707
- function stubCtx(projectRoot) {
1708
- return {
1709
- projectRoot,
1710
- cwd: projectRoot,
1711
- messages: [],
1712
- todos: [],
1713
- readFiles: /* @__PURE__ */ new Set(),
1714
- fileMtimes: /* @__PURE__ */ new Map()
1715
- };
2038
+ // src/codebase-index/indexer.ts
2039
+ var YIELD_EVERY_N = 50;
2040
+ function yieldEventLoop() {
2041
+ return new Promise((resolve2) => setImmediate(resolve2));
1716
2042
  }
1717
- var chain = Promise.resolve();
1718
- function withMutex(job) {
1719
- const run = chain.then(job, job);
1720
- chain = run.then(
1721
- () => void 0,
1722
- () => void 0
1723
- );
1724
- return run;
1725
- }
1726
- var DEFAULT_DEBOUNCE_MS = 400;
1727
- var debounceTimers = /* @__PURE__ */ new Map();
1728
- function debounceKey(indexDir, file) {
1729
- return `${indexDir ?? ""}|${file}`;
1730
- }
1731
- function isIndexableFile(filePath) {
1732
- return detectLang(filePath) !== null;
1733
- }
1734
- async function runStartupIndex(opts) {
1735
- _indexing = true;
1736
- _currentFile = 0;
1737
- _totalFiles = 0;
1738
- _lastError = null;
1739
- emitState();
1740
- try {
1741
- const result = await withMutex(
1742
- () => runIndexer(stubCtx(opts.projectRoot), {
1743
- projectRoot: opts.projectRoot,
1744
- indexDir: opts.indexDir,
1745
- force: opts.force,
1746
- signal: opts.signal
1747
- })
1748
- );
1749
- _ready = true;
1750
- return result;
1751
- } catch (err) {
1752
- _lastError = err instanceof Error ? err.message : String(err);
1753
- _ready = true;
1754
- throw err;
1755
- } finally {
1756
- _indexing = false;
1757
- emitState();
1758
- }
1759
- }
1760
- function enqueueReindex(opts) {
1761
- const files = opts.files.filter(isIndexableFile);
1762
- if (files.length === 0) return;
1763
- const ms = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
1764
- for (const file of files) {
1765
- const key = debounceKey(opts.indexDir, file);
1766
- const existing = debounceTimers.get(key);
1767
- if (existing) clearTimeout(existing);
1768
- const timer = setTimeout(() => {
1769
- debounceTimers.delete(key);
1770
- void withMutex(
1771
- () => runIndexer(stubCtx(opts.projectRoot), {
1772
- projectRoot: opts.projectRoot,
1773
- files: [file],
1774
- indexDir: opts.indexDir
1775
- })
1776
- ).catch((err) => opts.onError?.(err));
1777
- }, ms);
1778
- timer.unref?.();
1779
- debounceTimers.set(key, timer);
1780
- }
1781
- }
1782
- function cancelPendingReindexes() {
1783
- for (const t of debounceTimers.values()) clearTimeout(t);
1784
- debounceTimers.clear();
1785
- }
1786
-
1787
- // src/codebase-index/indexer.ts
1788
- var YIELD_EVERY_N = 50;
1789
- function yieldEventLoop() {
1790
- return new Promise((resolve2) => setImmediate(resolve2));
1791
- }
1792
- function throwIfAborted(signal) {
1793
- if (!signal?.aborted) return;
1794
- if (signal.reason instanceof Error) throw signal.reason;
1795
- throw new Error(
1796
- typeof signal.reason === "string" ? signal.reason : "Indexing cancelled"
2043
+ function throwIfAborted(signal) {
2044
+ if (!signal?.aborted) return;
2045
+ if (signal.reason instanceof Error) throw signal.reason;
2046
+ throw new Error(
2047
+ typeof signal.reason === "string" ? signal.reason : "Indexing cancelled"
1797
2048
  );
1798
2049
  }
1799
2050
  function isAbortError(err) {
@@ -1883,8 +2134,18 @@ async function parseFile(file, content, lang) {
1883
2134
  }
1884
2135
  }
1885
2136
  async function runIndexer(_ctx, opts) {
1886
- const { projectRoot, force = false, langs, ignore = [], indexDir, signal } = opts;
1887
- const store = new IndexStore(projectRoot, { indexDir });
2137
+ const store = new IndexStore(opts.projectRoot, { indexDir: opts.indexDir });
2138
+ try {
2139
+ return await runIndexerWithStore(store, opts);
2140
+ } finally {
2141
+ try {
2142
+ store.close();
2143
+ } catch {
2144
+ }
2145
+ }
2146
+ }
2147
+ async function runIndexerWithStore(store, opts) {
2148
+ const { projectRoot, force = false, langs, ignore = [], signal } = opts;
1888
2149
  const startMs = Date.now();
1889
2150
  const errors = [];
1890
2151
  const langStats = {};
@@ -1911,7 +2172,7 @@ async function runIndexer(_ctx, opts) {
1911
2172
  }
1912
2173
  for (let fi = 0; fi < files.length; fi++) {
1913
2174
  const file = expectDefined(files[fi]);
1914
- _setIndexProgress(fi + 1, files.length);
2175
+ opts.onProgress?.(fi + 1, files.length);
1915
2176
  if (fi > 0 && fi % YIELD_EVERY_N === 0) {
1916
2177
  await yieldEventLoop();
1917
2178
  throwIfAborted(signal);
@@ -1997,7 +2258,6 @@ async function runIndexer(_ctx, opts) {
1997
2258
  }
1998
2259
  const durationMs = Date.now() - startMs;
1999
2260
  store.setLastIndexed(Date.now());
2000
- store.close();
2001
2261
  return {
2002
2262
  filesIndexed,
2003
2263
  symbolsIndexed,
@@ -2007,6 +2267,349 @@ async function runIndexer(_ctx, opts) {
2007
2267
  };
2008
2268
  }
2009
2269
 
2270
+ // src/codebase-index/index-service.ts
2271
+ function stubCtx(projectRoot) {
2272
+ return {
2273
+ projectRoot,
2274
+ cwd: projectRoot,
2275
+ messages: [],
2276
+ todos: [],
2277
+ readFiles: /* @__PURE__ */ new Set(),
2278
+ fileMtimes: /* @__PURE__ */ new Map()
2279
+ };
2280
+ }
2281
+ async function indexService(args, hooks = {}) {
2282
+ return runIndexer(stubCtx(args.projectRoot), {
2283
+ projectRoot: args.projectRoot,
2284
+ indexDir: args.indexDir,
2285
+ files: args.files,
2286
+ force: args.force,
2287
+ langs: args.langs,
2288
+ ignore: args.ignore,
2289
+ signal: hooks.signal,
2290
+ onProgress: hooks.onProgress
2291
+ });
2292
+ }
2293
+ function searchService(args) {
2294
+ const store = new IndexStore(args.projectRoot, { indexDir: args.indexDir });
2295
+ try {
2296
+ return store.searchRanked(
2297
+ args.query,
2298
+ {
2299
+ kind: args.kind,
2300
+ lang: args.lang,
2301
+ file: args.file,
2302
+ lspKind: args.lspKind
2303
+ },
2304
+ args.limit
2305
+ );
2306
+ } finally {
2307
+ store.close();
2308
+ }
2309
+ }
2310
+ function statsService(args) {
2311
+ const store = new IndexStore(args.projectRoot, { indexDir: args.indexDir });
2312
+ try {
2313
+ return store.getStats();
2314
+ } finally {
2315
+ store.close();
2316
+ }
2317
+ }
2318
+
2319
+ // src/codebase-index/background-indexer.ts
2320
+ var DEFAULT_FULL_INDEX_TIMEOUT_MS = 12e4;
2321
+ var DEFAULT_INCREMENTAL_TIMEOUT_MS = 3e4;
2322
+ var DEFAULT_QUERY_TIMEOUT_MS = 8e3;
2323
+ var _ready = false;
2324
+ var _indexing = false;
2325
+ var _currentFile = 0;
2326
+ var _totalFiles = 0;
2327
+ var _lastError = null;
2328
+ function isIndexReady() {
2329
+ return _ready;
2330
+ }
2331
+ function isIndexing() {
2332
+ return _indexing;
2333
+ }
2334
+ function getIndexState() {
2335
+ return {
2336
+ ready: _ready,
2337
+ indexing: _indexing,
2338
+ currentFile: _currentFile,
2339
+ totalFiles: _totalFiles,
2340
+ lastError: _lastError,
2341
+ circuit: indexCircuitBreaker.snapshot()
2342
+ };
2343
+ }
2344
+ var _listeners = [];
2345
+ function onIndexStateChange(listener) {
2346
+ _listeners.push(listener);
2347
+ return () => {
2348
+ _listeners = _listeners.filter((l) => l !== listener);
2349
+ };
2350
+ }
2351
+ function emitState() {
2352
+ const state = getIndexState();
2353
+ for (const l of _listeners) l(state);
2354
+ }
2355
+ function setIndexProgress(current, total) {
2356
+ _currentFile = current;
2357
+ _totalFiles = total;
2358
+ emitState();
2359
+ }
2360
+ var worker = null;
2361
+ var workerUnavailable = false;
2362
+ var nextRpcId = 1;
2363
+ var pending = /* @__PURE__ */ new Map();
2364
+ function resolveWorkerUrl() {
2365
+ if (process.env["WRONGSTACK_INDEX_INLINE"]) return null;
2366
+ for (const rel of ["./worker.js", "./codebase-index/worker.js"]) {
2367
+ try {
2368
+ const url = new URL(rel, import.meta.url);
2369
+ if (url.protocol === "file:" && fs.existsSync(fileURLToPath(url))) return url;
2370
+ } catch {
2371
+ }
2372
+ }
2373
+ return null;
2374
+ }
2375
+ function failAllPending(err) {
2376
+ const entries = [...pending.values()];
2377
+ pending.clear();
2378
+ for (const p of entries) p.reject(err);
2379
+ }
2380
+ function ensureWorker() {
2381
+ if (worker) return worker;
2382
+ if (workerUnavailable) return null;
2383
+ const url = resolveWorkerUrl();
2384
+ if (!url) {
2385
+ workerUnavailable = true;
2386
+ return null;
2387
+ }
2388
+ try {
2389
+ const w = new Worker(url, { name: "wstack-codebase-index" });
2390
+ w.unref();
2391
+ w.on("message", (msg) => {
2392
+ if (msg.type === "progress") {
2393
+ pending.get(msg.id)?.onProgress?.(msg.current, msg.total);
2394
+ return;
2395
+ }
2396
+ const entry = pending.get(msg.id);
2397
+ if (!entry) return;
2398
+ pending.delete(msg.id);
2399
+ if (msg.ok) entry.resolve(msg.result);
2400
+ else entry.reject(new Error(msg.error));
2401
+ });
2402
+ w.on("error", (err) => {
2403
+ worker = null;
2404
+ failAllPending(err);
2405
+ });
2406
+ w.on("exit", () => {
2407
+ if (worker === w) worker = null;
2408
+ failAllPending(new Error("codebase-index worker exited"));
2409
+ });
2410
+ worker = w;
2411
+ return w;
2412
+ } catch {
2413
+ workerUnavailable = true;
2414
+ return null;
2415
+ }
2416
+ }
2417
+ function terminateWorker(reason) {
2418
+ const w = worker;
2419
+ worker = null;
2420
+ failAllPending(reason);
2421
+ if (w) void w.terminate().catch(() => {
2422
+ });
2423
+ }
2424
+ function shutdownCodebaseIndexHost() {
2425
+ cancelPendingReindexes();
2426
+ terminateWorker(new Error("codebase-index host shut down"));
2427
+ workerUnavailable = false;
2428
+ }
2429
+ function callIndexOp(op, args, opts) {
2430
+ const w = ensureWorker();
2431
+ if (!w) return callInline(op, args, opts);
2432
+ return new Promise((resolve2, reject) => {
2433
+ const id = nextRpcId++;
2434
+ const timer = setTimeout(() => {
2435
+ pending.delete(id);
2436
+ const err = new IndexTimeoutError(
2437
+ `Index ${op} exceeded its ${opts.timeoutMs}ms watchdog timeout`
2438
+ );
2439
+ terminateWorker(err);
2440
+ reject(err);
2441
+ }, opts.timeoutMs);
2442
+ timer.unref?.();
2443
+ const onAbort = () => {
2444
+ w.postMessage({ type: "cancel", id });
2445
+ };
2446
+ if (opts.signal?.aborted) onAbort();
2447
+ else opts.signal?.addEventListener("abort", onAbort, { once: true });
2448
+ const cleanup = () => {
2449
+ clearTimeout(timer);
2450
+ opts.signal?.removeEventListener("abort", onAbort);
2451
+ };
2452
+ pending.set(id, {
2453
+ resolve: (v) => {
2454
+ cleanup();
2455
+ resolve2(v);
2456
+ },
2457
+ reject: (e) => {
2458
+ cleanup();
2459
+ reject(e);
2460
+ },
2461
+ onProgress: opts.onProgress
2462
+ });
2463
+ w.postMessage({ type: "request", id, op, args });
2464
+ });
2465
+ }
2466
+ async function callInline(op, args, opts) {
2467
+ const ac = new AbortController();
2468
+ const onOuterAbort = () => ac.abort(opts.signal?.reason ?? new Error("Indexing cancelled"));
2469
+ if (opts.signal?.aborted) onOuterAbort();
2470
+ else opts.signal?.addEventListener("abort", onOuterAbort, { once: true });
2471
+ let timer;
2472
+ const watchdog = new Promise((_, reject) => {
2473
+ timer = setTimeout(() => {
2474
+ const err = new IndexTimeoutError(
2475
+ `Index ${op} exceeded its ${opts.timeoutMs}ms watchdog timeout`
2476
+ );
2477
+ ac.abort(err);
2478
+ reject(err);
2479
+ }, opts.timeoutMs);
2480
+ timer.unref?.();
2481
+ });
2482
+ const job = async () => {
2483
+ switch (op) {
2484
+ case "index":
2485
+ return await indexService(args, {
2486
+ signal: ac.signal,
2487
+ onProgress: opts.onProgress
2488
+ });
2489
+ case "search":
2490
+ return searchService(args);
2491
+ case "stats":
2492
+ return statsService(args);
2493
+ default:
2494
+ throw new Error(`unknown index op: ${String(op)}`);
2495
+ }
2496
+ };
2497
+ try {
2498
+ return await Promise.race([job(), watchdog]);
2499
+ } finally {
2500
+ if (timer) clearTimeout(timer);
2501
+ opts.signal?.removeEventListener("abort", onOuterAbort);
2502
+ }
2503
+ }
2504
+ var chain = Promise.resolve();
2505
+ function withMutex(job) {
2506
+ const run = chain.then(job, job);
2507
+ chain = run.then(
2508
+ () => void 0,
2509
+ () => void 0
2510
+ );
2511
+ return run;
2512
+ }
2513
+ function circuitOpenError() {
2514
+ const c = indexCircuitBreaker.snapshot();
2515
+ return new CircuitOpenError(
2516
+ "Codebase indexing is temporarily paused after repeated failures" + (c.lastFailure ? ` (last: ${c.lastFailure})` : "") + (c.cooldownRemainingMs > 0 ? `; auto-retry in ${Math.ceil(c.cooldownRemainingMs / 1e3)}s` : "") + ". Use /codebase-reindex to retry now."
2517
+ );
2518
+ }
2519
+ var DEFAULT_DEBOUNCE_MS = 400;
2520
+ var debounceTimers = /* @__PURE__ */ new Map();
2521
+ function debounceKey(indexDir, file) {
2522
+ return `${indexDir ?? ""}|${file}`;
2523
+ }
2524
+ function isIndexableFile(filePath) {
2525
+ return detectLang(filePath) !== null;
2526
+ }
2527
+ async function runStartupIndex(opts) {
2528
+ if (!indexCircuitBreaker.allowRequest()) throw circuitOpenError();
2529
+ _indexing = true;
2530
+ emitState();
2531
+ try {
2532
+ const result = await withMutex(() => {
2533
+ _currentFile = 0;
2534
+ _totalFiles = 0;
2535
+ _lastError = null;
2536
+ return callIndexOp(
2537
+ "index",
2538
+ {
2539
+ projectRoot: opts.projectRoot,
2540
+ indexDir: opts.indexDir,
2541
+ force: opts.force,
2542
+ langs: opts.langs
2543
+ },
2544
+ {
2545
+ timeoutMs: opts.timeoutMs ?? DEFAULT_FULL_INDEX_TIMEOUT_MS,
2546
+ signal: opts.signal,
2547
+ onProgress: setIndexProgress
2548
+ }
2549
+ );
2550
+ });
2551
+ _ready = true;
2552
+ indexCircuitBreaker.recordSuccess();
2553
+ return result;
2554
+ } catch (err) {
2555
+ _lastError = err instanceof Error ? err.message : String(err);
2556
+ _ready = true;
2557
+ if (!opts.signal?.aborted) indexCircuitBreaker.recordFailure(err);
2558
+ throw err;
2559
+ } finally {
2560
+ _indexing = false;
2561
+ emitState();
2562
+ }
2563
+ }
2564
+ function enqueueReindex(opts) {
2565
+ const files = opts.files.filter(isIndexableFile);
2566
+ if (files.length === 0) return;
2567
+ const ms = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
2568
+ for (const file of files) {
2569
+ const key = debounceKey(opts.indexDir, file);
2570
+ const existing = debounceTimers.get(key);
2571
+ if (existing) clearTimeout(existing);
2572
+ const timer = setTimeout(() => {
2573
+ debounceTimers.delete(key);
2574
+ if (!indexCircuitBreaker.allowRequest()) {
2575
+ opts.onError?.(circuitOpenError());
2576
+ return;
2577
+ }
2578
+ void withMutex(
2579
+ () => callIndexOp(
2580
+ "index",
2581
+ { projectRoot: opts.projectRoot, files: [file], indexDir: opts.indexDir },
2582
+ { timeoutMs: opts.timeoutMs ?? DEFAULT_INCREMENTAL_TIMEOUT_MS }
2583
+ )
2584
+ ).then(
2585
+ () => indexCircuitBreaker.recordSuccess(),
2586
+ (err) => {
2587
+ indexCircuitBreaker.recordFailure(err);
2588
+ opts.onError?.(err);
2589
+ }
2590
+ );
2591
+ }, ms);
2592
+ timer.unref?.();
2593
+ debounceTimers.set(key, timer);
2594
+ }
2595
+ }
2596
+ function cancelPendingReindexes() {
2597
+ for (const t of debounceTimers.values()) clearTimeout(t);
2598
+ debounceTimers.clear();
2599
+ }
2600
+ async function searchCodebaseIndex(args, opts = {}) {
2601
+ return callIndexOp("search", args, {
2602
+ timeoutMs: opts.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
2603
+ signal: opts.signal
2604
+ });
2605
+ }
2606
+ async function codebaseIndexStats(args, opts = {}) {
2607
+ return callIndexOp("stats", args, {
2608
+ timeoutMs: opts.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
2609
+ signal: opts.signal
2610
+ });
2611
+ }
2612
+
2010
2613
  // src/codebase-index/codebase-index-tool.ts
2011
2614
  var codebaseIndexTool = {
2012
2615
  name: "codebase-index",
@@ -2042,103 +2645,24 @@ var codebaseIndexTool = {
2042
2645
  note: "A full index is already in progress. Retry codebase-index after it completes (check codebase-stats)."
2043
2646
  };
2044
2647
  }
2045
- const result = await runIndexer(ctx, {
2648
+ const circuit = indexCircuitBreaker.snapshot();
2649
+ if (circuit.state === "open" && circuit.cooldownRemainingMs > 0) {
2650
+ return {
2651
+ filesIndexed: 0,
2652
+ symbolsIndexed: 0,
2653
+ langStats: {},
2654
+ durationMs: 0,
2655
+ errors: [],
2656
+ note: `Codebase indexing is paused after repeated failures (last: ${circuit.lastFailure ?? "unknown"}). Auto-retry possible in ${Math.ceil(circuit.cooldownRemainingMs / 1e3)}s; the user can run /codebase-reindex to retry immediately.`
2657
+ };
2658
+ }
2659
+ return await runStartupIndex({
2046
2660
  projectRoot: ctx.projectRoot,
2047
2661
  force: input.force ?? false,
2048
2662
  langs: input.langs,
2049
2663
  indexDir: codebaseIndexDirOverride(ctx),
2050
2664
  signal: execOpts?.signal
2051
2665
  });
2052
- setIndexReady();
2053
- return result;
2054
- }
2055
- };
2056
-
2057
- // src/codebase-index/bm25.ts
2058
- var K1 = 1.5;
2059
- var B = 0.75;
2060
- function tokenise(text) {
2061
- const sanitised = text.replace(/[^\p{L}\p{N}$'_]/gu, " ").replace(/_/g, " ");
2062
- return sanitised.toLowerCase().split(" ").filter(Boolean);
2063
- }
2064
- function splitName(name) {
2065
- return name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
2066
- }
2067
- function buildIndexableText(name, signature, docComment) {
2068
- return [splitName(name), name, signature, docComment].filter(Boolean).join(" ");
2069
- }
2070
- function buildBm25Index(docs) {
2071
- const documents = docs.map((d) => {
2072
- const tokens = tokenise(d.text);
2073
- return { id: d.id, tokens, raw: d.text, len: tokens.length };
2074
- });
2075
- const df = {};
2076
- for (const doc of documents) {
2077
- const seen = /* @__PURE__ */ new Set();
2078
- for (const t of doc.tokens) {
2079
- if (!seen.has(t)) {
2080
- df[t] = (df[t] ?? 0) + 1;
2081
- seen.add(t);
2082
- }
2083
- }
2084
- }
2085
- const N = documents.length;
2086
- const totalLen = documents.reduce((sum, d) => sum + d.len, 0);
2087
- const avgLen = N === 0 ? 0 : totalLen / N;
2088
- return new Bm25Index(documents, df, N, avgLen);
2089
- }
2090
- var Bm25Index = class {
2091
- constructor(documents, df, N, avgLen) {
2092
- this.documents = documents;
2093
- this.df = df;
2094
- this.N = N;
2095
- this.safeAvgLen = avgLen === 0 ? 1 : avgLen;
2096
- }
2097
- documents;
2098
- df;
2099
- N;
2100
- safeAvgLen;
2101
- score(query, filter) {
2102
- const qTokens = tokenise(query);
2103
- if (qTokens.length === 0) return [];
2104
- const results = [];
2105
- for (const doc of this.documents) {
2106
- if (filter && !filter(doc.id)) continue;
2107
- let docScore = 0;
2108
- for (const qTerm of qTokens) {
2109
- let tf = 0;
2110
- for (const t of doc.tokens) {
2111
- if (t === qTerm) tf++;
2112
- }
2113
- if (tf === 0) continue;
2114
- const dfVal = this.df[qTerm] ?? 0;
2115
- if (dfVal === 0) continue;
2116
- const idf = Math.log((this.N - dfVal + 0.5) / (dfVal + 0.5) + 1);
2117
- const lenRatio = B * (doc.len / this.safeAvgLen);
2118
- const tfComponent = tf * (K1 + 1) / (tf + K1 * (1 - B + lenRatio));
2119
- docScore += idf * tfComponent;
2120
- }
2121
- if (docScore > 0) results.push({ id: doc.id, score: docScore });
2122
- }
2123
- return results;
2124
- }
2125
- getDoc(id) {
2126
- return this.documents.find((d) => d.id === id);
2127
- }
2128
- extractSnippet(docId, queryTokens, radius = 40) {
2129
- const doc = this.getDoc(docId);
2130
- if (!doc) return "";
2131
- for (const tok of queryTokens) {
2132
- const idx = doc.raw.toLowerCase().indexOf(tok);
2133
- if (idx !== -1) {
2134
- const start = Math.max(0, idx - radius);
2135
- const end = Math.min(doc.raw.length, idx + tok.length + radius);
2136
- const excerpt = doc.raw.slice(start, end);
2137
- const ellipsis = "\u2026";
2138
- return (start > 0 ? ellipsis : "") + excerpt + (end < doc.raw.length ? ellipsis : "");
2139
- }
2140
- }
2141
- return doc.raw.slice(0, radius * 2) + (doc.raw.length > radius * 2 ? "\u2026" : "");
2142
2666
  }
2143
2667
  };
2144
2668
 
@@ -2184,7 +2708,7 @@ var codebaseSearchTool = {
2184
2708
  },
2185
2709
  required: ["query"]
2186
2710
  },
2187
- async execute(input, ctx) {
2711
+ async execute(input, ctx, execOpts) {
2188
2712
  const state = getIndexState();
2189
2713
  if (!state.ready) {
2190
2714
  return {
@@ -2203,51 +2727,30 @@ var codebaseSearchTool = {
2203
2727
  };
2204
2728
  }
2205
2729
  if (state.lastError) {
2730
+ const circuit = state.circuit;
2731
+ const retryHint = circuit.state === "open" ? `Indexing is paused (circuit open, retry in ${Math.ceil(circuit.cooldownRemainingMs / 1e3)}s); the user can run /codebase-reindex to retry now.` : "Try /codebase-reindex.";
2206
2732
  return {
2207
2733
  results: [],
2208
2734
  total: 0,
2209
2735
  query: input.query,
2210
- indexStatus: `Index build failed: ${state.lastError}. Try /codebase-reindex.`
2736
+ indexStatus: `Index build failed: ${state.lastError}. ${retryHint}`
2211
2737
  };
2212
2738
  }
2213
- const store = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
2214
- try {
2215
- const limit = Math.min(input.limit ?? 20, 100);
2216
- const candidates = store.search(input.query, {
2739
+ const limit = Math.min(input.limit ?? 20, 100);
2740
+ const { results, total } = await searchCodebaseIndex(
2741
+ {
2742
+ projectRoot: ctx.projectRoot,
2743
+ indexDir: codebaseIndexDirOverride(ctx),
2744
+ query: input.query,
2217
2745
  kind: input.kind,
2218
2746
  lang: input.lang,
2219
2747
  file: input.file,
2220
- lspKind: input.lspKind
2221
- });
2222
- if (candidates.length === 0) {
2223
- return { results: [], total: 0, query: input.query };
2224
- }
2225
- const indexable = candidates.map((c) => ({
2226
- id: c.id,
2227
- text: buildIndexableText(c.name, c.signature, c.docComment)
2228
- }));
2229
- const bm25 = buildBm25Index(indexable);
2230
- const scored = bm25.score(input.query, (id) => candidates.some((c) => c.id === id));
2231
- scored.sort((a, b) => b.score - a.score);
2232
- const top = scored.slice(0, limit);
2233
- const qTokens = tokenise(input.query);
2234
- const results = top.map(({ id, score }) => {
2235
- const c = expectDefined(candidates.find((c2) => c2.id === id));
2236
- const snippet = bm25.extractSnippet(id, qTokens);
2237
- return {
2238
- ...c,
2239
- score,
2240
- snippet
2241
- };
2242
- });
2243
- return {
2244
- results,
2245
- total: candidates.length,
2246
- query: input.query
2247
- };
2248
- } finally {
2249
- store.close();
2250
- }
2748
+ lspKind: input.lspKind,
2749
+ limit
2750
+ },
2751
+ { signal: execOpts?.signal }
2752
+ );
2753
+ return { results, total, query: input.query };
2251
2754
  }
2252
2755
  };
2253
2756
 
@@ -2266,7 +2769,7 @@ var codebaseStatsTool = {
2266
2769
  properties: {},
2267
2770
  additionalProperties: false
2268
2771
  },
2269
- async execute(_input, ctx) {
2772
+ async execute(_input, ctx, execOpts) {
2270
2773
  const idxState = getIndexState();
2271
2774
  if (!idxState.ready) {
2272
2775
  return {
@@ -2281,37 +2784,33 @@ var codebaseStatsTool = {
2281
2784
  indexStatus: idxState.indexing ? `Indexing in progress (${idxState.currentFile}/${idxState.totalFiles} files).` : "Index not yet built."
2282
2785
  };
2283
2786
  }
2787
+ const stats = await codebaseIndexStats(
2788
+ { projectRoot: ctx.projectRoot, indexDir: codebaseIndexDirOverride(ctx) },
2789
+ { signal: execOpts?.signal }
2790
+ );
2284
2791
  if (idxState.indexing) {
2285
- const store2 = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
2286
- try {
2287
- const stats = store2.getStats();
2288
- return {
2289
- ...stats,
2290
- indexStatus: `Index refresh in progress (${idxState.currentFile}/${idxState.totalFiles} files). Stats may be incomplete.`
2291
- };
2292
- } finally {
2293
- store2.close();
2294
- }
2295
- }
2296
- const store = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
2297
- try {
2298
- const stats = store.getStats();
2299
2792
  return {
2300
- totalSymbols: stats.totalSymbols,
2301
- totalFiles: stats.totalFiles,
2302
- byLang: stats.byLang,
2303
- byKind: stats.byKind,
2304
- lastIndexed: stats.lastIndexed,
2305
- sizeBytes: stats.sizeBytes,
2306
- indexPath: stats.indexPath,
2307
- version: stats.version
2793
+ ...stats,
2794
+ indexStatus: `Index refresh in progress (${idxState.currentFile}/${idxState.totalFiles} files). Stats may be incomplete.`
2308
2795
  };
2309
- } finally {
2310
- store.close();
2311
2796
  }
2797
+ const circuit = idxState.circuit;
2798
+ return {
2799
+ totalSymbols: stats.totalSymbols,
2800
+ totalFiles: stats.totalFiles,
2801
+ byLang: stats.byLang,
2802
+ byKind: stats.byKind,
2803
+ lastIndexed: stats.lastIndexed,
2804
+ sizeBytes: stats.sizeBytes,
2805
+ indexPath: stats.indexPath,
2806
+ version: stats.version,
2807
+ ...circuit.state === "open" ? {
2808
+ indexStatus: `Indexing is paused after repeated failures (last: ${circuit.lastFailure ?? "unknown"}); auto-retry in ${Math.ceil(circuit.cooldownRemainingMs / 1e3)}s, or run /codebase-reindex. Stats reflect the last successful build.`
2809
+ } : {}
2810
+ };
2312
2811
  }
2313
2812
  };
2314
2813
 
2315
- export { IndexStore, SCHEMA_VERSION, buildBm25Index, buildIndexableText, cancelPendingReindexes, codebaseIndexDirOverride, codebaseIndexTool, codebaseSearchTool, codebaseStatsTool, enqueueReindex, getIndexState, internalKindToLspKind, isIndexReady, isIndexableFile, isIndexing, lspKindToInternalKind, onIndexStateChange, resolveIndexDir, runIndexer, runStartupIndex, tokenise };
2814
+ export { CircuitOpenError, IndexCircuitBreaker, IndexStore, IndexTimeoutError, SCHEMA_VERSION, buildBm25Index, buildIndexableText, cancelPendingReindexes, codebaseIndexDirOverride, codebaseIndexStats, codebaseIndexTool, codebaseSearchTool, codebaseStatsTool, enqueueReindex, getIndexState, indexCircuitBreaker, internalKindToLspKind, isIndexReady, isIndexableFile, isIndexing, lspKindToInternalKind, onIndexStateChange, resetIndexCircuitBreaker, resolveIndexDir, runIndexer, runStartupIndex, searchCodebaseIndex, shutdownCodebaseIndexHost, tokenise };
2316
2815
  //# sourceMappingURL=index.js.map
2317
2816
  //# sourceMappingURL=index.js.map