opencode-rag-plugin 1.19.3 → 1.19.5

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 (82) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/api.js +2 -0
  3. package/dist/chunker/base.js +19 -5
  4. package/dist/chunker/factory.js +27 -9
  5. package/dist/chunker/grammar.d.ts +18 -1
  6. package/dist/chunker/grammar.js +48 -10
  7. package/dist/chunker/pdf.js +30 -14
  8. package/dist/cli/commands/init-helpers.js +15 -2
  9. package/dist/cli/commands/init.js +31 -21
  10. package/dist/cli/commands/query.js +2 -0
  11. package/dist/cli/commands/quirk.js +9 -3
  12. package/dist/cli/commands/setup.js +5 -2
  13. package/dist/cli/commands/status.js +14 -5
  14. package/dist/cli/commands/ui.js +23 -9
  15. package/dist/cli/commands/update.js +4 -5
  16. package/dist/cli/format.d.ts +5 -2
  17. package/dist/cli/format.js +14 -5
  18. package/dist/content/image.js +33 -11
  19. package/dist/content/reader.js +79 -17
  20. package/dist/core/bootstrap.js +10 -3
  21. package/dist/core/config.js +31 -0
  22. package/dist/core/desc-cache.d.ts +8 -2
  23. package/dist/core/desc-cache.js +10 -3
  24. package/dist/core/doc-progress.js +5 -2
  25. package/dist/core/interfaces.d.ts +8 -0
  26. package/dist/core/interfaces.js +8 -1
  27. package/dist/core/provider-defaults.d.ts +2 -0
  28. package/dist/core/provider-defaults.js +19 -4
  29. package/dist/core/runtime-overrides.d.ts +0 -6
  30. package/dist/core/version-check.d.ts +5 -0
  31. package/dist/core/version-check.js +8 -2
  32. package/dist/describer/anthropic.d.ts +2 -2
  33. package/dist/describer/anthropic.js +19 -5
  34. package/dist/describer/describer.js +15 -2
  35. package/dist/describer/gemini.js +25 -10
  36. package/dist/embedder/factory.d.ts +5 -3
  37. package/dist/embedder/factory.js +41 -8
  38. package/dist/embedder/health.js +19 -19
  39. package/dist/embedder/http.d.ts +14 -1
  40. package/dist/embedder/http.js +60 -6
  41. package/dist/eval/session-logger.js +7 -0
  42. package/dist/eval/storage.js +8 -0
  43. package/dist/indexer/git-diff.d.ts +1 -1
  44. package/dist/indexer/git-diff.js +5 -1
  45. package/dist/indexer/pipeline.js +421 -344
  46. package/dist/indexer/stats.d.ts +2 -0
  47. package/dist/indexer/stats.js +1 -0
  48. package/dist/indexer/watch.js +8 -1
  49. package/dist/indexer/worker.js +21 -0
  50. package/dist/mcp/cli.js +4 -0
  51. package/dist/mcp/handlers.d.ts +1 -1
  52. package/dist/mcp/handlers.js +23 -6
  53. package/dist/mcp/server.js +3 -0
  54. package/dist/opencode/create-read-tool.d.ts +1 -1
  55. package/dist/opencode/create-read-tool.js +17 -5
  56. package/dist/opencode/tool-args.js +23 -1
  57. package/dist/opencode/tools.d.ts +1 -1
  58. package/dist/opencode/tools.js +3 -1
  59. package/dist/plugin.d.ts +1 -1
  60. package/dist/plugin.js +69 -152
  61. package/dist/quirks/auto-capture.js +5 -0
  62. package/dist/quirks/quirk-store.d.ts +1 -1
  63. package/dist/quirks/quirk-store.js +56 -17
  64. package/dist/retriever/context-optimizer.js +18 -4
  65. package/dist/retriever/keyword-index.d.ts +2 -0
  66. package/dist/retriever/keyword-index.js +38 -4
  67. package/dist/retriever/retriever.js +6 -1
  68. package/dist/tui.js +41 -4
  69. package/dist/vectorstore/lancedb.d.ts +25 -1
  70. package/dist/vectorstore/lancedb.js +157 -11
  71. package/dist/vectorstore/memory.js +5 -1
  72. package/dist/watcher.js +30 -4
  73. package/dist/web/api.d.ts +6 -2
  74. package/dist/web/api.js +198 -70
  75. package/dist/web/server.d.ts +2 -0
  76. package/dist/web/server.js +66 -28
  77. package/dist/web/static.d.ts +5 -2
  78. package/dist/web/static.js +9 -5
  79. package/dist/web/ui/assets/index-BDPYdtA1.js +3 -0
  80. package/dist/web/ui/index.html +1 -1
  81. package/package.json +1 -1
  82. package/dist/web/ui/assets/index-CJBvt6e0.js +0 -3
@@ -1,3 +1,4 @@
1
+ import { normalizeFilePath } from "../core/manifest.js";
1
2
  const INDEX_VERSION = 2;
2
3
  /** Suffix-stripping stemmer. Only stems words >= 6 characters to reduce false positives. */
3
4
  function stem(word) {
@@ -71,6 +72,8 @@ export class KeywordIndex {
71
72
  invertedIndex = new Map();
72
73
  chunkMap = new Map();
73
74
  storePath;
75
+ /** filePath (normalized) → chunk IDs, for O(chunks-in-file) removal. */
76
+ fileToIds = new Map();
74
77
  constructor(storePath) {
75
78
  this.storePath = storePath;
76
79
  }
@@ -78,6 +81,13 @@ export class KeywordIndex {
78
81
  for (const chunk of chunks) {
79
82
  const id = chunk.id;
80
83
  this.chunkMap.set(id, chunk);
84
+ const fileKey = normalizeFilePath(chunk.metadata.filePath);
85
+ let ids = this.fileToIds.get(fileKey);
86
+ if (!ids) {
87
+ ids = new Set();
88
+ this.fileToIds.set(fileKey, ids);
89
+ }
90
+ ids.add(id);
81
91
  const tokens = tokenize(chunk.content);
82
92
  for (const token of tokens) {
83
93
  let docs = this.invertedIndex.get(token);
@@ -101,10 +111,21 @@ export class KeywordIndex {
101
111
  return matched;
102
112
  }
103
113
  removeByFilePath(filePath) {
104
- const idsToRemove = [];
105
- for (const [id, chunk] of this.chunkMap) {
106
- if (chunk.metadata.filePath === filePath) {
107
- idsToRemove.push(id);
114
+ const fileKey = normalizeFilePath(filePath);
115
+ // Fast path: per-file id index. Falls back to a full chunkMap scan when
116
+ // the path is not tracked (entries loaded from an older persisted index).
117
+ let idsToRemove;
118
+ const tracked = this.fileToIds.get(fileKey);
119
+ if (tracked) {
120
+ idsToRemove = [...tracked];
121
+ this.fileToIds.delete(fileKey);
122
+ }
123
+ else {
124
+ idsToRemove = [];
125
+ for (const [id, chunk] of this.chunkMap) {
126
+ if (normalizeFilePath(chunk.metadata.filePath) === fileKey) {
127
+ idsToRemove.push(id);
128
+ }
108
129
  }
109
130
  }
110
131
  for (const id of idsToRemove) {
@@ -155,10 +176,12 @@ export class KeywordIndex {
155
176
  close() {
156
177
  this.invertedIndex.clear();
157
178
  this.chunkMap.clear();
179
+ this.fileToIds.clear();
158
180
  }
159
181
  clear() {
160
182
  this.invertedIndex.clear();
161
183
  this.chunkMap.clear();
184
+ this.fileToIds.clear();
162
185
  }
163
186
  count() {
164
187
  return this.chunkMap.size;
@@ -241,6 +264,17 @@ export class KeywordIndex {
241
264
  },
242
265
  });
243
266
  }
267
+ // Rebuild the per-file id index from the loaded chunk map so removals
268
+ // work even for entries persisted by older versions.
269
+ for (const [id, chunk] of index.chunkMap) {
270
+ const fileKey = normalizeFilePath(chunk.metadata.filePath);
271
+ let ids = index.fileToIds.get(fileKey);
272
+ if (!ids) {
273
+ ids = new Set();
274
+ index.fileToIds.set(fileKey, ids);
275
+ }
276
+ ids.add(id);
277
+ }
244
278
  return index;
245
279
  }
246
280
  static async clearFile(storePath) {
@@ -96,7 +96,12 @@ export async function retrieve(query, embedder, store, options = {}) {
96
96
  .slice(0, topK);
97
97
  return combinedResults;
98
98
  }
99
- catch {
99
+ catch (err) {
100
+ // Never silently mask retrieval failures as "no results" — an embedder
101
+ // outage or store bug must be visible, not indistinguishable from an
102
+ // empty index.
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ console.warn(`[retriever] retrieve() failed (returning []): ${message}`);
100
105
  return [];
101
106
  }
102
107
  }
package/dist/tui.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * settings dialog for editing config values, and model selection picker.
4
4
  */
5
5
  import { createElement, insert, setProp } from "@opentui/solid";
6
- import { readFileSync, existsSync, writeFileSync } from "node:fs";
6
+ import { readFileSync, existsSync, writeFileSync, unlinkSync } from "node:fs";
7
7
  import { dirname, join, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { loadRuntimeOverrides, saveRuntimeOverride } from "./core/runtime-overrides.js";
@@ -277,7 +277,10 @@ function resolveProviderBaseUrl(provider) {
277
277
  */
278
278
  function saveConfigValue(configPath, path, value) {
279
279
  try {
280
- const data = JSON.parse(readFileSync(configPath, "utf-8"));
280
+ let raw = readFileSync(configPath, "utf-8");
281
+ if (raw.charCodeAt(0) === 0xfeff)
282
+ raw = raw.slice(1);
283
+ const data = JSON.parse(raw);
281
284
  let target = data;
282
285
  for (let i = 0; i < path.length - 1; i++) {
283
286
  const key = path[i];
@@ -292,6 +295,26 @@ function saveConfigValue(configPath, path, value) {
292
295
  catch {
293
296
  }
294
297
  }
298
+ /**
299
+ * Mirror the watcher status file that the server plugin maintains, so the
300
+ * sidebar reflects a watcher toggle from the settings dialog immediately:
301
+ * - enabled → write the same initial status the background indexer writes on startup
302
+ * - disabled → remove the file (same cleanup the plugin does when auto-index is off)
303
+ */
304
+ function syncWatcherStatusFile(storePath, enabled) {
305
+ const statusPath = join(storePath, "watcher-status.json");
306
+ try {
307
+ if (enabled) {
308
+ writeFileSync(statusPath, JSON.stringify({ running: false }, null, 2), "utf-8");
309
+ }
310
+ else if (existsSync(statusPath)) {
311
+ unlinkSync(statusPath);
312
+ }
313
+ }
314
+ catch {
315
+ // best-effort mirror; the server plugin reconciles on its next load
316
+ }
317
+ }
295
318
  /**
296
319
  * Save a model selection to both runtime overrides and the config file.
297
320
  * Resolves the RAG provider name and API base URL automatically.
@@ -320,8 +343,9 @@ function saveModelSelection(storePath, configPath, selectionValue, path, provide
320
343
  }
321
344
  const apiKey = provider?.options?.apiKey ?? "";
322
345
  if (apiKey) {
346
+ // Persist the key only in the store-dir overrides, never in the workspace
347
+ // config file — `opencode-rag.json` is routinely committed to git.
323
348
  saveRuntimeOverride(storePath, [section, "apiKey"], apiKey);
324
- saveConfigValue(configPath, [section, "apiKey"], apiKey);
325
349
  }
326
350
  return selectionValue;
327
351
  }
@@ -689,7 +713,12 @@ async function openSettingsDialog(api) {
689
713
  if (entry.path[0] === "indexing" || entry.path[0] === "chunking") {
690
714
  api.ui.toast({ variant: "warning", title: "Settings", message: "Chunking changed. Re-index required." });
691
715
  }
716
+ if (entry.path.join(".") === "openCode.autoIndex.enabled") {
717
+ syncWatcherStatusFile(storePath, newVal);
718
+ api.ui.toast({ variant: "warning", title: "Settings", message: "Watcher changes take effect after an OpenCode restart" });
719
+ }
692
720
  entry.currentValue = newVal;
721
+ api.onSettingsChanged?.();
693
722
  showSettingMenu(cat);
694
723
  }
695
724
  else if (entry.type === "number") {
@@ -711,6 +740,7 @@ async function openSettingsDialog(api) {
711
740
  api.ui.toast({ variant: "warning", title: "Settings", message: "Chunking changed. Re-index required." });
712
741
  }
713
742
  entry.currentValue = num;
743
+ api.onSettingsChanged?.();
714
744
  }
715
745
  showSettingMenu(cat);
716
746
  },
@@ -743,6 +773,7 @@ async function openSettingsDialog(api) {
743
773
  api.ui.toast({ variant: "warning", title: "Settings", message: "Chunking changed. Re-index required." });
744
774
  }
745
775
  entry.currentValue = parsed;
776
+ api.onSettingsChanged?.();
746
777
  }
747
778
  catch {
748
779
  api.ui.toast({ variant: "error", title: "Settings", message: "Invalid JSON" });
@@ -768,6 +799,7 @@ async function openSettingsDialog(api) {
768
799
  message: `${entry.label}: ${input}`,
769
800
  });
770
801
  entry.currentValue = input;
802
+ api.onSettingsChanged?.();
771
803
  showSettingMenu(cat);
772
804
  },
773
805
  onCancel: () => {
@@ -799,6 +831,7 @@ async function openSettingsDialog(api) {
799
831
  saveConfigValue(configPath, entry.path, input);
800
832
  entry.currentValue = input;
801
833
  }
834
+ api.onSettingsChanged?.();
802
835
  showSettingMenu(cat);
803
836
  },
804
837
  onCancel: () => showSettingMenu(cat),
@@ -821,6 +854,7 @@ async function openSettingsDialog(api) {
821
854
  message: "Embedding changed. Re-index may be required. Restart OpenCode for changes.",
822
855
  });
823
856
  }
857
+ api.onSettingsChanged?.();
824
858
  }
825
859
  showSettingMenu(cat);
826
860
  },
@@ -840,7 +874,7 @@ const plugin = {
840
874
  const version = meta.version ?? getVersion();
841
875
  let cachedStatus = DEFAULT_STATUS;
842
876
  let lastRefresh = 0;
843
- const REFRESH_INTERVAL_MS = Number(process.env.OPENCODE_RAG_TUI_REFRESH_MS) || 3600000;
877
+ const REFRESH_INTERVAL_MS = Number(process.env.OPENCODE_RAG_TUI_REFRESH_MS) || 30000;
844
878
  // Load tui config for keybinding display
845
879
  let tuiConfig;
846
880
  const worktree = api.state.path.worktree;
@@ -948,6 +982,9 @@ const plugin = {
948
982
  openSettingsDialog({
949
983
  ui: api.ui,
950
984
  state: api.state,
985
+ onSettingsChanged: () => {
986
+ refreshStatus();
987
+ },
951
988
  });
952
989
  return undefined;
953
990
  },
@@ -120,6 +120,24 @@ export declare class LanceDbStore implements VectorStore {
120
120
  * @returns An array of chunk summaries.
121
121
  */
122
122
  getChunks(offset: number, limit: number): Promise<ChunkSummary[]>;
123
+ /**
124
+ * Retrieve a paginated list of chunks without embeddings, with optional
125
+ * language and file-path filters pushed into the SQL WHERE clause.
126
+ *
127
+ * @param offset - Number of rows to skip (for pagination).
128
+ * @param limit - Maximum number of rows to return.
129
+ * @param lang - Optional exact language filter.
130
+ * @param filePrefix - Optional filePath prefix filter.
131
+ * @returns The page of chunks plus the total count of matching rows.
132
+ */
133
+ getChunksFiltered(offset: number, limit: number, lang?: string, filePrefix?: string): Promise<{
134
+ chunks: ChunkSummary[];
135
+ total: number;
136
+ }>;
137
+ /** Look up a single chunk by its ID. Returns undefined when not found. */
138
+ getChunkById(id: string): Promise<ChunkSummary | undefined>;
139
+ /** Fetch chunks by their IDs (for the compare view). Bounded to 100 ids. */
140
+ getChunksByIds(ids: string[]): Promise<ChunkSummary[]>;
123
141
  /**
124
142
  * Fetch chunks with their embedding vectors included (for embedding projection).
125
143
  * @param limit - Maximum number of rows to return.
@@ -169,7 +187,13 @@ export declare class LanceDbStore implements VectorStore {
169
187
  * @param newPath - Optional new filesystem path for the LanceDB database.
170
188
  */
171
189
  reopen(newPath?: string): Promise<void>;
172
- /** Close the database connection and release resources. */
190
+ /**
191
+ * Close the database connection and release resources.
192
+ *
193
+ * The native LanceDB `close()` can hang indefinitely on Windows, so the
194
+ * whole close is raced against a timeout — callers (CLI commands, plugin
195
+ * reload, web server) must never block forever on shutdown.
196
+ */
173
197
  close(): Promise<void>;
174
198
  /**
175
199
  * Back up the chunks.lance directory before a destructive operation.
@@ -396,6 +396,12 @@ export class LanceDbStore {
396
396
  }
397
397
  async searchWithFilter(embedding, topK, filter) {
398
398
  try {
399
+ // Guard against dimension mismatch BEFORE the native call — LanceDB
400
+ // throws a cryptic error that used to be swallowed into "no results".
401
+ if (embedding.length !== this.vectorDimension) {
402
+ console.warn(`[lancedb] searchWithFilter: query embedding dimension ${embedding.length} != store dimension ${this.vectorDimension} — returning empty`);
403
+ return [];
404
+ }
399
405
  return await this.searchInternal(embedding, topK, filter);
400
406
  }
401
407
  catch (err) {
@@ -404,7 +410,12 @@ export class LanceDbStore {
404
410
  if (repaired) {
405
411
  return this.searchInternal(embedding, topK, filter);
406
412
  }
413
+ console.warn(`[lancedb] searchWithFilter: repair failed: ${err.message}`);
414
+ return [];
407
415
  }
416
+ // Never silently mask non-corruption failures as "no results" —
417
+ // that hides provider outages and store bugs from every caller.
418
+ console.warn(`[lancedb] searchWithFilter failed (returning []): ${err.message}`);
408
419
  return [];
409
420
  }
410
421
  }
@@ -534,6 +545,104 @@ export class LanceDbStore {
534
545
  }));
535
546
  });
536
547
  }
548
+ /**
549
+ * Retrieve a paginated list of chunks without embeddings, with optional
550
+ * language and file-path filters pushed into the SQL WHERE clause.
551
+ *
552
+ * @param offset - Number of rows to skip (for pagination).
553
+ * @param limit - Maximum number of rows to return.
554
+ * @param lang - Optional exact language filter.
555
+ * @param filePrefix - Optional filePath prefix filter.
556
+ * @returns The page of chunks plus the total count of matching rows.
557
+ */
558
+ async getChunksFiltered(offset, limit, lang, filePrefix) {
559
+ const conditions = [];
560
+ if (lang)
561
+ conditions.push(`language = '${lang.replace(/'/g, "''")}'`);
562
+ if (filePrefix)
563
+ conditions.push(`filePath LIKE '${filePrefix.replace(/'/g, "''")}%'`);
564
+ const where = conditions.length > 0 ? conditions.join(" AND ") : undefined;
565
+ return this.withCorruptionRecovery(async () => {
566
+ const table = await this.getTable();
567
+ let query = table.query().select(QUERY_COLUMNS);
568
+ let countQuery = table.query().select(["id"]);
569
+ if (where) {
570
+ query = query.where(where);
571
+ countQuery = countQuery.where(where);
572
+ }
573
+ const [rows, countRows] = await Promise.all([
574
+ query.offset(Math.max(0, offset)).limit(Math.max(1, Math.min(limit, 1000))).toArray(),
575
+ countQuery.toArray(),
576
+ ]);
577
+ return {
578
+ chunks: rows.map((row) => ({
579
+ id: row.id,
580
+ filePath: row.filePath,
581
+ language: row.language,
582
+ startLine: row.startLine,
583
+ endLine: row.endLine,
584
+ content: row.content,
585
+ description: row.description ?? "",
586
+ kind: row.kind ?? "",
587
+ quirkType: row.quirkType ?? "",
588
+ tags: row.tags ?? "",
589
+ })),
590
+ total: countRows.length,
591
+ };
592
+ });
593
+ }
594
+ /** Look up a single chunk by its ID. Returns undefined when not found. */
595
+ async getChunkById(id) {
596
+ return this.withCorruptionRecovery(async () => {
597
+ const table = await this.getTable();
598
+ const rows = await table.query()
599
+ .select(QUERY_COLUMNS)
600
+ .where(`id = '${id.replace(/'/g, "''")}'`)
601
+ .limit(1)
602
+ .toArray();
603
+ const row = rows[0];
604
+ if (!row)
605
+ return undefined;
606
+ return {
607
+ id: row.id,
608
+ filePath: row.filePath,
609
+ language: row.language,
610
+ startLine: row.startLine,
611
+ endLine: row.endLine,
612
+ content: row.content,
613
+ description: row.description ?? "",
614
+ kind: row.kind ?? "",
615
+ quirkType: row.quirkType ?? "",
616
+ tags: row.tags ?? "",
617
+ };
618
+ });
619
+ }
620
+ /** Fetch chunks by their IDs (for the compare view). Bounded to 100 ids. */
621
+ async getChunksByIds(ids) {
622
+ const bounded = ids.slice(0, 100);
623
+ if (bounded.length === 0)
624
+ return [];
625
+ return this.withCorruptionRecovery(async () => {
626
+ const table = await this.getTable();
627
+ const idList = bounded.map((id) => `'${id.replace(/'/g, "''")}'`).join(", ");
628
+ const rows = await table.query()
629
+ .select(QUERY_COLUMNS)
630
+ .where(`id IN (${idList})`)
631
+ .toArray();
632
+ return rows.map((row) => ({
633
+ id: row.id,
634
+ filePath: row.filePath,
635
+ language: row.language,
636
+ startLine: row.startLine,
637
+ endLine: row.endLine,
638
+ content: row.content,
639
+ description: row.description ?? "",
640
+ kind: row.kind ?? "",
641
+ quirkType: row.quirkType ?? "",
642
+ tags: row.tags ?? "",
643
+ }));
644
+ });
645
+ }
537
646
  /**
538
647
  * Fetch chunks with their embedding vectors included (for embedding projection).
539
648
  * @param limit - Maximum number of rows to return.
@@ -614,8 +723,11 @@ export class LanceDbStore {
614
723
  return 0;
615
724
  const table = await this.getTable();
616
725
  const COUNT_TIMEOUT_MS = 30_000;
726
+ // Attach a no-op catch to the race loser so a late rejection (after the
727
+ // timeout resolved `null`) cannot become an unhandled rejection.
728
+ const countPromise = table.countRows().catch(() => 0);
617
729
  const result = await Promise.race([
618
- table.countRows(),
730
+ countPromise,
619
731
  new Promise((resolve) => setTimeout(() => resolve(null), COUNT_TIMEOUT_MS)),
620
732
  ]);
621
733
  if (result === null) {
@@ -644,6 +756,21 @@ export class LanceDbStore {
644
756
  // GC deleted fragments that the current version still referenced.
645
757
  const threshold = new Date(Date.now() - 60 * 60 * 1000);
646
758
  await table.optimize({ cleanupOlderThan: threshold, deleteUnverified: false });
759
+ // Build the ANN vector index — without it every vectorSearch() is a
760
+ // brute-force O(N) flat scan (slow at 50k+ chunks).
761
+ try {
762
+ const count = await table.countRows().catch(() => 0);
763
+ const numPartitions = count > 0 ? Math.max(16, Math.min(256, Math.floor(count / 256))) : 16;
764
+ // The index metric MUST match the search metric ("cosine", see
765
+ // searchInternal). The ivfFlat default is "l2", which makes LanceDB
766
+ // ignore the index and fall back to brute-force on every query.
767
+ await table.createIndex("embedding", {
768
+ config: lancedb.Index.ivfFlat({ numPartitions, distanceType: "cosine" }),
769
+ });
770
+ }
771
+ catch {
772
+ // Index creation is best-effort — queries still work, just slower.
773
+ }
647
774
  }
648
775
  catch {
649
776
  // Optimize is best-effort �?" must not break indexing.
@@ -684,20 +811,39 @@ export class LanceDbStore {
684
811
  * @param newPath - Optional new filesystem path for the LanceDB database.
685
812
  */
686
813
  async reopen(newPath) {
687
- await this.table?.close();
688
- await this.db?.close();
689
- this.table = null;
690
- this.db = null;
691
- this.tableInit = null;
814
+ await this.close();
692
815
  if (newPath)
693
816
  this.dbPath = newPath;
694
817
  }
695
- /** Close the database connection and release resources. */
818
+ /**
819
+ * Close the database connection and release resources.
820
+ *
821
+ * The native LanceDB `close()` can hang indefinitely on Windows, so the
822
+ * whole close is raced against a timeout — callers (CLI commands, plugin
823
+ * reload, web server) must never block forever on shutdown.
824
+ */
696
825
  async close() {
697
- await this.table?.close();
698
- this.table = null;
699
- await this.db?.close();
700
- this.db = null;
826
+ await Promise.race([
827
+ (async () => {
828
+ try {
829
+ await this.table?.close();
830
+ }
831
+ catch {
832
+ // best-effort — the connection may already be gone
833
+ }
834
+ this.table = null;
835
+ try {
836
+ await this.db?.close();
837
+ }
838
+ catch {
839
+ // best-effort — the connection may already be gone
840
+ }
841
+ this.db = null;
842
+ })(),
843
+ new Promise((resolve) => {
844
+ setTimeout(resolve, 5000).unref();
845
+ }),
846
+ ]);
701
847
  }
702
848
  /**
703
849
  * Back up the chunks.lance directory before a destructive operation.
@@ -13,7 +13,11 @@ export class InMemoryVectorStore {
13
13
  .filter((c) => matchesFilter(c, filter))
14
14
  .map((chunk) => {
15
15
  const sim = cosineSimilarity(embedding, chunk.embedding);
16
- return { chunk, score: sim };
16
+ // Normalize cosine [-1,1] into [0,1] so scores are comparable with
17
+ // the LanceDB store's clamped `1 - distance/2` domain (minScore and
18
+ // displayed scores mean the same thing across both stores).
19
+ const normalized = Math.min(1, Math.max(0, (sim + 1) / 2));
20
+ return { chunk, score: normalized };
17
21
  })
18
22
  .sort((a, b) => b.score - a.score)
19
23
  .slice(0, topK);
package/dist/watcher.js CHANGED
@@ -37,7 +37,7 @@ export function createBackgroundIndexer(options) {
37
37
  const runPass = async (filterPaths) => {
38
38
  updateStatus({ running: true, lastRunAt: Date.now() });
39
39
  try {
40
- await runIndexPass({
40
+ const stats = await runIndexPass({
41
41
  cwd,
42
42
  storePath,
43
43
  config,
@@ -54,6 +54,20 @@ export function createBackgroundIndexer(options) {
54
54
  debug: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message: `DEBUG: ${message}`, severity: "debug" }, logLevel),
55
55
  },
56
56
  });
57
+ // A lock-skipped pass did NO work — retry shortly so the workspace
58
+ // does not stay unindexed until the next file event.
59
+ if (stats.skipped) {
60
+ appendDebugLog(logFilePath, {
61
+ scope: "autoIndex",
62
+ message: "Index pass skipped (another pass holds the lock) — retrying in 30s",
63
+ }, logLevel);
64
+ if (!ac.signal.aborted) {
65
+ setTimeout(() => {
66
+ if (!ac.signal.aborted)
67
+ scheduler.notifyChange(filterPaths);
68
+ }, 30_000).unref();
69
+ }
70
+ }
57
71
  updateStatus({ running: false, lastRunAt: Date.now() });
58
72
  }
59
73
  catch (err) {
@@ -106,21 +120,33 @@ export function createBackgroundIndexer(options) {
106
120
  error,
107
121
  }, logLevel);
108
122
  });
109
- // Periodic timer: only needed for git backend (chokidar gets real FS events)
123
+ // Periodic timer: only needed for git backend (chokidar gets real FS events).
124
+ // Note: with git mode BOTH backends run — the scheduler coalesces redundant
125
+ // passes into one, so this only adds a safety net for missed events.
110
126
  const watcherBackend = autoIndexCfg.watcher ?? "chokidar";
111
127
  const periodicTimer = watcherBackend === "git"
112
128
  ? setInterval(() => {
113
129
  scheduler.notifyChange();
114
130
  }, autoIndexCfg.intervalMs)
115
131
  : undefined;
132
+ // Never keep the process alive just for the periodic scan
133
+ periodicTimer?.unref();
116
134
  return {
117
135
  async close() {
118
136
  if (periodicTimer)
119
137
  clearInterval(periodicTimer);
120
138
  ac.abort();
121
139
  scheduler.close();
122
- await scheduler.waitForIdle();
123
- await watcher.close();
140
+ // chokidar close() can hang on some Windows setups — guard it like
141
+ // the CLI does.
142
+ await Promise.race([
143
+ scheduler.waitForIdle(),
144
+ new Promise((resolve) => setTimeout(resolve, 5000).unref()),
145
+ ]);
146
+ await Promise.race([
147
+ watcher.close(),
148
+ new Promise((resolve) => setTimeout(resolve, 5000).unref()),
149
+ ]);
124
150
  const statusPath = path.join(storePath, "watcher-status.json");
125
151
  if (existsSync(statusPath)) {
126
152
  try {
package/dist/web/api.d.ts CHANGED
@@ -5,7 +5,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
5
5
  import { LanceDbStore } from "../vectorstore/lancedb.js";
6
6
  import { KeywordIndex } from "../retriever/keyword-index.js";
7
7
  import type { RagConfig } from "../core/config.js";
8
- import type { EmbeddingProvider } from "../core/interfaces.js";
8
+ import { type EmbeddingProvider } from "../core/interfaces.js";
9
9
  /** Internal shape for a JSON API response: an HTTP status code and a serialisable body. */
10
10
  interface ApiResponse {
11
11
  status: number;
@@ -28,7 +28,7 @@ interface ApiResponse {
28
28
  * @param cfg - Active RAG configuration (used by quirk endpoints).
29
29
  * @returns An async handler that returns `true` when a route matched or `false` otherwise.
30
30
  */
31
- export declare function createApiHandler(store: LanceDbStore, keywordIndex: KeywordIndex, storePath: string, cwd?: string, cfg?: RagConfig, getEmbedder?: () => Promise<EmbeddingProvider>): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
31
+ export declare function createApiHandler(store: LanceDbStore, keywordIndex: KeywordIndex, storePath: string, cwd?: string, cfg?: RagConfig, getEmbedder?: () => Promise<EmbeddingProvider>, token?: string): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
32
32
  /**
33
33
  * Perform token-usage analysis for a single evaluation session.
34
34
  *
@@ -52,4 +52,8 @@ export declare function handleEvalTokenCompare(storePath: string, params: URLSea
52
52
  * `avgReadsPerQueryWithRAG`, `queryCount`.
53
53
  */
54
54
  export declare function handleEvalProjectSavings(body: unknown): ApiResponse;
55
+ /** Thrown when the request body exceeds {@link MAX_BODY_BYTES}; mapped to a 413 response. */
56
+ export declare class BodyTooLargeError extends Error {
57
+ constructor();
58
+ }
55
59
  export {};