opencode-rag-plugin 1.21.1 → 1.22.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.
@@ -32,7 +32,11 @@ export function registerUiCommand(program) {
32
32
  const port = parseInt(options.port ?? String(config.ui?.port ?? 3210), 10);
33
33
  const openBrowser = options.open !== false && (config.ui?.openBrowser ?? true);
34
34
  const { startWebUi } = await import("../../web/server.js");
35
- const server = await startWebUi(storePath, port, cwd, config.embedding.vectorDimension ?? 384, config);
35
+ const { findConfigFile } = await import("../../core/config.js");
36
+ const configPath = options.config
37
+ ? path.resolve(cwd, options.config)
38
+ : findConfigFile(cwd);
39
+ const server = await startWebUi(storePath, port, cwd, config.embedding.vectorDimension ?? 384, config, configPath);
36
40
  const url = `http://127.0.0.1:${server.port}/?token=${server.token}`;
37
41
  logCliInfo(logFilePath, "ui", `\n${c.heading("OpenCodeRAG Web UI")}`);
38
42
  logCliInfo(logFilePath, "ui", ` ${c.label("URL:")} ${c.value(url)}`);
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import type { RagConfig } from "../core/config.js";
5
5
  import { type FileManifest } from "../core/manifest.js";
6
- import { type ExcludeMatcher } from "../core/exclude.js";
6
+ import { type ExcludeMatcher, type IncludedMatcher } from "../core/exclude.js";
7
7
  import { DescriptionCache } from "../core/desc-cache.js";
8
8
  import { type ImageVisionProvider } from "../chunker/image.js";
9
9
  /** Metadata and extracted content for a single workspace file discovered during scanning. */
@@ -28,7 +28,7 @@ interface Logger {
28
28
  * Recursively walk a directory tree and collect paths matching the given extension set,
29
29
  * respecting exclusion lists and configurable limits for max directories and results.
30
30
  */
31
- export declare function walkFiles(dir: string, extensions: Set<string>, excludeDirs: ExcludeMatcher, excludeFiles?: ExcludeMatcher, rootDir?: string, logger?: Logger, dirCount?: {
31
+ export declare function walkFiles(dir: string, extensions: Set<string>, excludeDirs: ExcludeMatcher, excludeFiles?: ExcludeMatcher, includeDirs?: IncludedMatcher, rootDir?: string, logger?: Logger, dirCount?: {
32
32
  value: number;
33
33
  }, maxDirs?: number, maxResults?: number): Promise<string[]>;
34
34
  /**
@@ -5,7 +5,7 @@ import fs from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import pLimit from "p-limit";
7
7
  import { computeFileHash, computeDescriptionConfigHash, normalizeFilePath } from "../core/manifest.js";
8
- import { createExcludeMatcher } from "../core/exclude.js";
8
+ import { createExcludeMatcher, createIncludeMatcher } from "../core/exclude.js";
9
9
  import { DescriptionCache } from "../core/desc-cache.js";
10
10
  import { createImageVisionProvider, } from "../chunker/image.js";
11
11
  import * as pdfExtractor from "./pdf.js";
@@ -17,13 +17,14 @@ import * as imageExtractor from "./image.js";
17
17
  * Recursively walk a directory tree and collect paths matching the given extension set,
18
18
  * respecting exclusion lists and configurable limits for max directories and results.
19
19
  */
20
- export async function walkFiles(dir, extensions, excludeDirs, excludeFiles, rootDir = dir, logger, dirCount, maxDirs = 10_000, maxResults = 100_000) {
20
+ export async function walkFiles(dir, extensions, excludeDirs, excludeFiles, includeDirs, rootDir = dir, logger, dirCount, maxDirs = 10_000, maxResults = 100_000) {
21
21
  const results = [];
22
22
  const entries = await fs.readdir(dir, { withFileTypes: true });
23
23
  for (const entry of entries) {
24
24
  const fullPath = path.join(dir, entry.name);
25
25
  if (entry.isDirectory()) {
26
- if (excludeDirs.excluded(path.relative(rootDir, fullPath)))
26
+ const rel = path.relative(rootDir, fullPath);
27
+ if ((includeDirs && !includeDirs.included(rel)) || excludeDirs.excluded(rel))
27
28
  continue;
28
29
  if (dirCount) {
29
30
  dirCount.value++;
@@ -39,7 +40,7 @@ export async function walkFiles(dir, extensions, excludeDirs, excludeFiles, root
39
40
  logger?.warn(`Exceeded ${maxResults} matching files — truncating walk at ${fullPath}`);
40
41
  return results;
41
42
  }
42
- results.push(...(await walkFiles(fullPath, extensions, excludeDirs, excludeFiles, rootDir, logger, dirCount, maxDirs, maxResults)));
43
+ results.push(...(await walkFiles(fullPath, extensions, excludeDirs, excludeFiles, includeDirs, rootDir, logger, dirCount, maxDirs, maxResults)));
43
44
  }
44
45
  else if (entry.isFile()) {
45
46
  if (results.length >= maxResults) {
@@ -104,6 +105,7 @@ export async function scanWorkspaceFiles(cwd, config, logger, manifest, filterPa
104
105
  }
105
106
  const excludeDirMatcher = createExcludeMatcher(config.indexing.excludeDirs);
106
107
  const excludeFileMatcher = createExcludeMatcher(config.indexing.excludeFiles ?? []);
108
+ const includeDirMatcher = createIncludeMatcher(config.indexing.includeDirs ?? []);
107
109
  let files;
108
110
  if (filterPaths && filterPaths.length > 0) {
109
111
  files = filterPaths
@@ -116,14 +118,14 @@ export async function scanWorkspaceFiles(cwd, config, logger, manifest, filterPa
116
118
  const rel = path.relative(cwd, fp);
117
119
  if (rel.startsWith(".."))
118
120
  return false;
119
- return !excludeDirMatcher.excluded(rel) && !excludeFileMatcher.excluded(rel);
121
+ return includeDirMatcher.included(rel) && !excludeDirMatcher.excluded(rel) && !excludeFileMatcher.excluded(rel);
120
122
  });
121
123
  }
122
124
  else {
123
125
  logger?.info("Walking directory tree...");
124
126
  const walkStart = Date.now();
125
127
  const dirCount = { value: 0 };
126
- files = await walkFiles(cwd, extensions, excludeDirMatcher, excludeFileMatcher, cwd, logger, dirCount);
128
+ files = await walkFiles(cwd, extensions, excludeDirMatcher, excludeFileMatcher, includeDirMatcher, cwd, logger, dirCount);
127
129
  const walkSec = ((Date.now() - walkStart) / 1000).toFixed(1);
128
130
  logger?.info(`Found ${files.length} matching files in ${walkSec}s (${dirCount.value} dirs traversed)`);
129
131
  }
@@ -265,6 +265,21 @@ export interface RagConfig {
265
265
  * patterns are anchored to the workspace root.
266
266
  */
267
267
  excludeFiles?: string[];
268
+ /**
269
+ * Workspace-relative folder paths to **restrict indexing to**.
270
+ *
271
+ * When non-empty, only files inside these folders (and their
272
+ * subfolders) are indexed; files directly in the workspace root are
273
+ * NOT indexed. `excludeDirs`/`excludeFiles` still apply inside the
274
+ * included folders. Empty or omitted = whole workspace (default).
275
+ *
276
+ * Semantics:
277
+ * - Entries are **anchored to the workspace root** (unlike
278
+ * `excludeDirs`, plain names do NOT match at any depth).
279
+ * - Supports globs (`docs/**`, `src/{a,b}`); use `/` as separator
280
+ * (converted automatically). Matching is case-insensitive.
281
+ */
282
+ includeDirs?: string[];
268
283
  /** Number of overlapping lines between adjacent chunks. */
269
284
  chunkOverlap: number;
270
285
  /** Minimum file size in bytes to index (0 = no minimum). */
@@ -415,3 +430,15 @@ export declare function loadConfig(filePath: string, validate?: boolean): RagCon
415
430
  * @param dimension - The vector dimension to persist.
416
431
  */
417
432
  export declare function persistProbedDimension(configPath: string, dimension: number): void;
433
+ /**
434
+ * Set a value at a dotted path inside the config JSON file, creating
435
+ * intermediate objects as needed, and write it back with 2-space
436
+ * indentation. Reads/writes the raw file, so comments or formatting of
437
+ * unrelated keys are preserved as parsed (the file is re-serialized).
438
+ *
439
+ * @param configPath - Absolute path to the config JSON file.
440
+ * @param path - Dotted path segments, e.g. `["indexing", "includeDirs"]`.
441
+ * @param value - Value to write at the target path.
442
+ * @returns `true` when the file was updated, `false` on any error.
443
+ */
444
+ export declare function updateConfigValue(configPath: string, path: string[], value: unknown): boolean;
@@ -617,4 +617,37 @@ export function persistProbedDimension(configPath, dimension) {
617
617
  // best-effort
618
618
  }
619
619
  }
620
+ /**
621
+ * Set a value at a dotted path inside the config JSON file, creating
622
+ * intermediate objects as needed, and write it back with 2-space
623
+ * indentation. Reads/writes the raw file, so comments or formatting of
624
+ * unrelated keys are preserved as parsed (the file is re-serialized).
625
+ *
626
+ * @param configPath - Absolute path to the config JSON file.
627
+ * @param path - Dotted path segments, e.g. `["indexing", "includeDirs"]`.
628
+ * @param value - Value to write at the target path.
629
+ * @returns `true` when the file was updated, `false` on any error.
630
+ */
631
+ export function updateConfigValue(configPath, path, value) {
632
+ try {
633
+ let raw = readFileSync(configPath, "utf-8");
634
+ if (raw.charCodeAt(0) === 0xfeff)
635
+ raw = raw.slice(1);
636
+ const data = JSON.parse(raw);
637
+ let target = data;
638
+ for (let i = 0; i < path.length - 1; i++) {
639
+ const key = path[i];
640
+ if (!target[key] || typeof target[key] !== "object") {
641
+ target[key] = {};
642
+ }
643
+ target = target[key];
644
+ }
645
+ target[path[path.length - 1]] = value;
646
+ writeFileSync(configPath, JSON.stringify(data, null, 2), "utf-8");
647
+ return true;
648
+ }
649
+ catch {
650
+ return false;
651
+ }
652
+ }
620
653
  //# sourceMappingURL=config.js.map
@@ -1,4 +1,18 @@
1
1
  export interface ExcludeMatcher {
2
2
  excluded(relPath: string): boolean;
3
3
  }
4
+ export interface IncludedMatcher {
5
+ included(relPath: string): boolean;
6
+ }
4
7
  export declare function createExcludeMatcher(patterns: string[]): ExcludeMatcher;
8
+ /**
9
+ * Create a matcher for `indexing.includeDirs`.
10
+ *
11
+ * Semantics differ from {@link createExcludeMatcher}: every pattern is
12
+ * **anchored to the workspace root** — it is matched as a glob against each
13
+ * ancestor prefix of the path, so `docs` includes `<root>/docs` and all its
14
+ * contents, but never a nested `docs` elsewhere in the tree. The workspace
15
+ * root itself (`""`) is always included so a walk can descend into it.
16
+ * Empty pattern lists include everything.
17
+ */
18
+ export declare function createIncludeMatcher(patterns: string[]): IncludedMatcher;
@@ -42,4 +42,41 @@ export function createExcludeMatcher(patterns) {
42
42
  }
43
43
  return { excluded };
44
44
  }
45
+ /**
46
+ * Create a matcher for `indexing.includeDirs`.
47
+ *
48
+ * Semantics differ from {@link createExcludeMatcher}: every pattern is
49
+ * **anchored to the workspace root** — it is matched as a glob against each
50
+ * ancestor prefix of the path, so `docs` includes `<root>/docs` and all its
51
+ * contents, but never a nested `docs` elsewhere in the tree. The workspace
52
+ * root itself (`""`) is always included so a walk can descend into it.
53
+ * Empty pattern lists include everything.
54
+ */
55
+ export function createIncludeMatcher(patterns) {
56
+ const matchers = [];
57
+ for (const pattern of patterns) {
58
+ const pat = pattern.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
59
+ if (!pat)
60
+ continue;
61
+ matchers.push(new Minimatch(pat, { nocase: true, dot: true }));
62
+ }
63
+ if (matchers.length === 0) {
64
+ return { included: () => true };
65
+ }
66
+ function included(relPath) {
67
+ const normalized = relPath.replace(/\\/g, "/");
68
+ if (!normalized)
69
+ return true;
70
+ let prefix = "";
71
+ for (const seg of normalized.split("/")) {
72
+ prefix = prefix ? `${prefix}/${seg}` : seg;
73
+ for (const mm of matchers) {
74
+ if (mm.match(prefix) || mm.match(`${prefix}/`))
75
+ return true;
76
+ }
77
+ }
78
+ return false;
79
+ }
80
+ return { included };
81
+ }
45
82
  //# sourceMappingURL=exclude.js.map
@@ -28,7 +28,9 @@ export declare function createWatchPassScheduler(runPass: (filterPaths?: string[
28
28
  /**
29
29
  * Build a predicate that returns `true` for paths that should be ignored by
30
30
  * a file watcher (store directory, manifest file, and configured exclude
31
- * directories).
31
+ * directories). When `indexing.includeDirs` is non-empty, any path outside
32
+ * the included folders is ignored as well, so out-of-scope changes never
33
+ * trigger re-index passes.
32
34
  *
33
35
  * @param cwd - Workspace root directory.
34
36
  * @param config - RAG configuration containing `indexing.excludeDirs`.
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import path from "node:path";
5
5
  import { manifestPathFor } from "../core/manifest.js";
6
- import { createExcludeMatcher } from "../core/exclude.js";
6
+ import { createExcludeMatcher, createIncludeMatcher } from "../core/exclude.js";
7
7
  /**
8
8
  * Create a scheduler that debounces calls to a re-index pass. While a pass is
9
9
  * running, subsequent notifications queue a single rerun. Useful for watching
@@ -108,7 +108,9 @@ export function createWatchPassScheduler(runPass, onError, debounceMs = 300) {
108
108
  /**
109
109
  * Build a predicate that returns `true` for paths that should be ignored by
110
110
  * a file watcher (store directory, manifest file, and configured exclude
111
- * directories).
111
+ * directories). When `indexing.includeDirs` is non-empty, any path outside
112
+ * the included folders is ignored as well, so out-of-scope changes never
113
+ * trigger re-index passes.
112
114
  *
113
115
  * @param cwd - Workspace root directory.
114
116
  * @param config - RAG configuration containing `indexing.excludeDirs`.
@@ -120,6 +122,7 @@ export function createWatchIgnore(cwd, config, storePath) {
120
122
  const manifestPath = manifestPathFor(storePath);
121
123
  const dirMatcher = createExcludeMatcher(config.indexing.excludeDirs);
122
124
  const fileMatcher = createExcludeMatcher(config.indexing.excludeFiles ?? []);
125
+ const includeMatcher = createIncludeMatcher(config.indexing.includeDirs ?? []);
123
126
  // Prefix check with a trailing separator so sibling dirs like
124
127
  // `<storePath>2` are NOT ignored; case-insensitive on win32 so a
125
128
  // differently-cased store path cannot cause self-triggering watch loops.
@@ -136,7 +139,7 @@ export function createWatchIgnore(cwd, config, storePath) {
136
139
  const relative = path.relative(cwd, resolved);
137
140
  if (!relative || relative.startsWith(".."))
138
141
  return false;
139
- return dirMatcher.excluded(relative) || fileMatcher.excluded(relative);
142
+ return !includeMatcher.included(relative) || dirMatcher.excluded(relative) || fileMatcher.excluded(relative);
140
143
  };
141
144
  }
142
145
  //# sourceMappingURL=watch.js.map
package/dist/tui.js CHANGED
@@ -8,7 +8,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";
10
10
  import { PROVIDER_DEFAULTS } from "./core/provider-defaults.js";
11
- import { loadConfig } from "./core/config.js";
11
+ import { loadConfig, updateConfigValue } from "./core/config.js";
12
12
  import { setPendingRagInjection } from "./core/rag-injection-flag.js";
13
13
  /** Cached plugin version string from package.json. */
14
14
  let _version;
@@ -275,26 +275,6 @@ function resolveProviderBaseUrl(provider) {
275
275
  * Write a single config value at a dotted path into the opencode-rag.json file.
276
276
  * Creates intermediate objects as needed.
277
277
  */
278
- function saveConfigValue(configPath, path, value) {
279
- try {
280
- let raw = readFileSync(configPath, "utf-8");
281
- if (raw.charCodeAt(0) === 0xfeff)
282
- raw = raw.slice(1);
283
- const data = JSON.parse(raw);
284
- let target = data;
285
- for (let i = 0; i < path.length - 1; i++) {
286
- const key = path[i];
287
- if (!target[key] || typeof target[key] !== "object") {
288
- target[key] = {};
289
- }
290
- target = target[key];
291
- }
292
- target[path[path.length - 1]] = value;
293
- writeFileSync(configPath, JSON.stringify(data, null, 2), "utf-8");
294
- }
295
- catch {
296
- }
297
- }
298
278
  /**
299
279
  * Mirror the watcher status file that the server plugin maintains, so the
300
280
  * sidebar reflects a watcher toggle from the settings dialog immediately:
@@ -334,12 +314,12 @@ function saveModelSelection(storePath, configPath, selectionValue, path, provide
334
314
  const ragProvider = providerIdToRagProvider(providerId);
335
315
  const baseUrl = provider ? resolveProviderBaseUrl(provider) : "";
336
316
  saveRuntimeOverride(storePath, [section, "provider"], ragProvider);
337
- saveConfigValue(configPath, [section, "provider"], ragProvider);
317
+ updateConfigValue(configPath, [section, "provider"], ragProvider);
338
318
  saveRuntimeOverride(storePath, [section, "model"], modelId);
339
- saveConfigValue(configPath, [section, "model"], modelId);
319
+ updateConfigValue(configPath, [section, "model"], modelId);
340
320
  if (baseUrl) {
341
321
  saveRuntimeOverride(storePath, [section, "baseUrl"], baseUrl);
342
- saveConfigValue(configPath, [section, "baseUrl"], baseUrl);
322
+ updateConfigValue(configPath, [section, "baseUrl"], baseUrl);
343
323
  }
344
324
  const apiKey = provider?.options?.apiKey ?? "";
345
325
  if (apiKey) {
@@ -704,7 +684,7 @@ async function openSettingsDialog(api) {
704
684
  else if (entry.type === "boolean") {
705
685
  const newVal = !entry.currentValue;
706
686
  saveRuntimeOverride(storePath, entry.path, newVal);
707
- saveConfigValue(configPath, entry.path, newVal);
687
+ updateConfigValue(configPath, entry.path, newVal);
708
688
  api.ui.toast({
709
689
  variant: "success",
710
690
  title: "Settings",
@@ -730,7 +710,7 @@ async function openSettingsDialog(api) {
730
710
  const num = parseFloat(input);
731
711
  if (!isNaN(num)) {
732
712
  saveRuntimeOverride(storePath, entry.path, num);
733
- saveConfigValue(configPath, entry.path, num);
713
+ updateConfigValue(configPath, entry.path, num);
734
714
  api.ui.toast({
735
715
  variant: "success",
736
716
  title: "Settings",
@@ -763,7 +743,7 @@ async function openSettingsDialog(api) {
763
743
  return;
764
744
  }
765
745
  saveRuntimeOverride(storePath, entry.path, parsed);
766
- saveConfigValue(configPath, entry.path, parsed);
746
+ updateConfigValue(configPath, entry.path, parsed);
767
747
  api.ui.toast({
768
748
  variant: "success",
769
749
  title: "Settings",
@@ -792,7 +772,7 @@ async function openSettingsDialog(api) {
792
772
  value: String(entry.currentValue),
793
773
  onConfirm: (input) => {
794
774
  saveRuntimeOverride(storePath, entry.path, input);
795
- saveConfigValue(configPath, entry.path, input);
775
+ updateConfigValue(configPath, entry.path, input);
796
776
  api.ui.toast({
797
777
  variant: "success",
798
778
  title: "Settings",
@@ -828,7 +808,7 @@ async function openSettingsDialog(api) {
828
808
  }
829
809
  else if (input) {
830
810
  saveRuntimeOverride(storePath, entry.path, input);
831
- saveConfigValue(configPath, entry.path, input);
811
+ updateConfigValue(configPath, entry.path, input);
832
812
  entry.currentValue = input;
833
813
  }
834
814
  api.onSettingsChanged?.();
@@ -7,10 +7,28 @@ export type StoreWarn = (message: string) => void;
7
7
  * store holds one per built index, while a store whose index commits fail
8
8
  * accumulates one per attempt (and eventually degrades / corrupts).
9
9
  *
10
+ * Empty directories are the husks of pruned versions (Lance removes the files
11
+ * but leaves the directory behind) and are harmless — they must not count
12
+ * toward the corruption threshold.
13
+ *
10
14
  * @param tablePath - Filesystem path of the table directory (e.g. `.../chunks.lance`).
15
+ * @param options - `withFiles: true` counts only directories that still contain index files.
11
16
  * @returns The number of index-version directories, or 0 when unavailable.
12
17
  */
13
- export declare function countIndexVersionDirs(tablePath: string): number;
18
+ export declare function countIndexVersionDirs(tablePath: string, options?: {
19
+ withFiles?: boolean;
20
+ }): number;
21
+ /**
22
+ * Remove stale empty index-version directories (husks of versions whose files
23
+ * Lance already pruned). Empty directories are never referenced by the index,
24
+ * so removing them is safe; only husks older than `MIN_HUSK_AGE_MS` are swept
25
+ * so a concurrent createIndex from another process can never lose its
26
+ * freshly-created (and momentarily empty) version directory.
27
+ *
28
+ * @param tablePath - Filesystem path of the table directory (e.g. `.../chunks.lance`).
29
+ * @returns The number of removed directories.
30
+ */
31
+ export declare function sweepEmptyIndexVersionDirs(tablePath: string): number;
14
32
  /**
15
33
  * L2-normalize a vector to unit length. Cosine models require unit vectors
16
34
  * for the dot product to equal cosine similarity.
@@ -30,18 +30,75 @@ const MAX_STALE_INDEX_VERSIONS = 40;
30
30
  * store holds one per built index, while a store whose index commits fail
31
31
  * accumulates one per attempt (and eventually degrades / corrupts).
32
32
  *
33
+ * Empty directories are the husks of pruned versions (Lance removes the files
34
+ * but leaves the directory behind) and are harmless — they must not count
35
+ * toward the corruption threshold.
36
+ *
33
37
  * @param tablePath - Filesystem path of the table directory (e.g. `.../chunks.lance`).
38
+ * @param options - `withFiles: true` counts only directories that still contain index files.
34
39
  * @returns The number of index-version directories, or 0 when unavailable.
35
40
  */
36
- export function countIndexVersionDirs(tablePath) {
41
+ export function countIndexVersionDirs(tablePath, options) {
37
42
  try {
38
43
  const entries = fsSync.readdirSync(path.join(tablePath, "_indices"), { withFileTypes: true });
39
- return entries.filter((e) => e.isDirectory()).length;
44
+ const dirs = entries.filter((e) => e.isDirectory());
45
+ if (!options?.withFiles)
46
+ return dirs.length;
47
+ let count = 0;
48
+ for (const dir of dirs) {
49
+ try {
50
+ if (fsSync.readdirSync(path.join(tablePath, "_indices", dir.name)).length > 0)
51
+ count++;
52
+ }
53
+ catch {
54
+ // unreadable dir — count it conservatively
55
+ count++;
56
+ }
57
+ }
58
+ return count;
40
59
  }
41
60
  catch {
42
61
  return 0;
43
62
  }
44
63
  }
64
+ /**
65
+ * Remove stale empty index-version directories (husks of versions whose files
66
+ * Lance already pruned). Empty directories are never referenced by the index,
67
+ * so removing them is safe; only husks older than `MIN_HUSK_AGE_MS` are swept
68
+ * so a concurrent createIndex from another process can never lose its
69
+ * freshly-created (and momentarily empty) version directory.
70
+ *
71
+ * @param tablePath - Filesystem path of the table directory (e.g. `.../chunks.lance`).
72
+ * @returns The number of removed directories.
73
+ */
74
+ export function sweepEmptyIndexVersionDirs(tablePath) {
75
+ const MIN_HUSK_AGE_MS = 10 * 60 * 1000;
76
+ let removed = 0;
77
+ try {
78
+ const indicesDir = path.join(tablePath, "_indices");
79
+ for (const name of fsSync.readdirSync(indicesDir)) {
80
+ const full = path.join(indicesDir, name);
81
+ try {
82
+ const stat = fsSync.statSync(full);
83
+ if (!stat.isDirectory())
84
+ continue;
85
+ if (Date.now() - stat.mtimeMs < MIN_HUSK_AGE_MS)
86
+ continue;
87
+ if (fsSync.readdirSync(full).length > 0)
88
+ continue;
89
+ fsSync.rmdirSync(full);
90
+ removed++;
91
+ }
92
+ catch {
93
+ // best-effort — skip unreadable/racing entries
94
+ }
95
+ }
96
+ }
97
+ catch {
98
+ // best-effort
99
+ }
100
+ return removed;
101
+ }
45
102
  /**
46
103
  * L2-normalize a vector to unit length. Cosine models require unit vectors
47
104
  * for the dot product to equal cosine similarity.
@@ -873,15 +930,16 @@ export class LanceDbStore {
873
930
  };
874
931
  try {
875
932
  // Guard against a store that never converges: each failed createIndex
876
- // leaves a new index-version directory behind. If many stale versions
877
- // accumulated, building yet another index only entrenches the problem.
878
- // The store must be rebuilt instead (see doc/troubleshooting.md).
879
- const staleVersions = this.dbPath.startsWith("memory://")
933
+ // leaves a new index-version directory behind. Empty directories are
934
+ // the husks of already-pruned versions and are harmless — only
935
+ // versions that still carry index files count (see countIndexVersionDirs).
936
+ const activeVersions = this.dbPath.startsWith("memory://")
880
937
  ? 0
881
- : countIndexVersionDirs(path.join(this.dbPath, TABLE_NAME + ".lance"));
882
- if (staleVersions > MAX_STALE_INDEX_VERSIONS) {
883
- report(`[lancedb] ${staleVersions} stale index versions detected in ${this.dbPath} — ` +
884
- `skipping index rebuild (the store is corrupted). Delete the rag_db directory and re-index.`);
938
+ : countIndexVersionDirs(path.join(this.dbPath, TABLE_NAME + ".lance"), { withFiles: true });
939
+ if (activeVersions > MAX_STALE_INDEX_VERSIONS) {
940
+ report(`[lancedb] ${activeVersions} active index versions detected in ${this.dbPath} — ` +
941
+ `index rebuild skipped to avoid retrain churn. The store cannot converge; ` +
942
+ `delete the rag_db directory and re-index.`);
885
943
  // Resolve the memo so no further attempts run this process.
886
944
  return;
887
945
  }
@@ -906,6 +964,16 @@ export class LanceDbStore {
906
964
  // race a background index-creation from another process.
907
965
  waitTimeoutSeconds: 120,
908
966
  });
967
+ // Verify the index actually registered. On a store whose index
968
+ // commits never register, createIndex can return successfully
969
+ // while leaving the table without a visible index — the failure
970
+ // mode that caused constant retraining and "partition N is empty,
971
+ // skipping" warning spam. Detect it and stop retraining.
972
+ const verify = await table.indexStats(idxName).catch(() => undefined);
973
+ if (!verify || verify.distanceType !== "cosine") {
974
+ throw new Error(`index build did not register (store cannot converge) — ` +
975
+ `delete the rag_db directory and re-index`);
976
+ }
909
977
  this.indexRepairFailures = 0;
910
978
  return;
911
979
  }
@@ -957,6 +1025,17 @@ export class LanceDbStore {
957
1025
  const threshold = new Date(Date.now() - 60 * 60 * 1000);
958
1026
  await table.optimize({ cleanupOlderThan: threshold, deleteUnverified: false });
959
1027
  }
1028
+ // Remove stale empty index-version directories (husks Lance leaves
1029
+ // behind after pruning a version's files). Keeps the directory count
1030
+ // from growing unboundedly and prevents false positives in the
1031
+ // index-repair stale-version guard.
1032
+ if (!this.dbPath.startsWith("memory://")) {
1033
+ const swept = sweepEmptyIndexVersionDirs(path.join(this.dbPath, TABLE_NAME + ".lance"));
1034
+ if (swept > 0) {
1035
+ const message = `[lancedb] swept ${swept} stale index-version directories`;
1036
+ (options?.logger ?? console.warn)(message);
1037
+ }
1038
+ }
960
1039
  // Build the ANN vector index — without it every vectorSearch() is a
961
1040
  // brute-force O(N) flat scan (slow at 50k+ chunks). Skips early when
962
1041
  // an index with the correct cosine metric already exists, and can be
package/dist/web/api.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  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
- import type { RagConfig } from "../core/config.js";
7
+ import { type RagConfig } from "../core/config.js";
8
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 {
@@ -26,9 +26,12 @@ interface ApiResponse {
26
26
  * @param storePath - Filesystem path to the store directory (used by eval endpoints).
27
27
  * @param cwd - Optional workspace root for resolving file paths.
28
28
  * @param cfg - Active RAG configuration (used by quirk endpoints).
29
+ * @param getEmbedder - Lazily-created embedder for /api/retrieve and reindex passes.
30
+ * @param token - Per-run auth token required on every request.
31
+ * @param configPath - Path to the opencode-rag.json config file, used by PUT /api/config.
29
32
  * @returns An async handler that returns `true` when a route matched or `false` otherwise.
30
33
  */
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>;
34
+ export declare function createApiHandler(store: LanceDbStore, keywordIndex: KeywordIndex, storePath: string, cwd?: string, cfg?: RagConfig, getEmbedder?: () => Promise<EmbeddingProvider>, token?: string, configPath?: string): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
32
35
  /**
33
36
  * Perform token-usage analysis for a single evaluation session.
34
37
  *