opencode-rag-plugin 1.19.4 → 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 (72) hide show
  1. package/dist/chunker/base.js +19 -5
  2. package/dist/chunker/factory.js +27 -9
  3. package/dist/chunker/grammar.d.ts +18 -1
  4. package/dist/chunker/grammar.js +48 -10
  5. package/dist/chunker/pdf.js +30 -14
  6. package/dist/cli/commands/init-helpers.js +15 -2
  7. package/dist/cli/commands/init.js +31 -21
  8. package/dist/cli/commands/quirk.js +9 -3
  9. package/dist/cli/commands/setup.js +5 -2
  10. package/dist/cli/commands/status.js +14 -5
  11. package/dist/cli/commands/ui.js +23 -9
  12. package/dist/cli/commands/update.js +4 -5
  13. package/dist/cli/format.d.ts +5 -2
  14. package/dist/cli/format.js +14 -5
  15. package/dist/content/image.js +33 -11
  16. package/dist/content/reader.js +74 -13
  17. package/dist/core/bootstrap.js +10 -3
  18. package/dist/core/config.js +31 -0
  19. package/dist/core/desc-cache.d.ts +8 -2
  20. package/dist/core/desc-cache.js +10 -3
  21. package/dist/core/doc-progress.js +5 -2
  22. package/dist/core/provider-defaults.d.ts +2 -0
  23. package/dist/core/provider-defaults.js +19 -4
  24. package/dist/core/runtime-overrides.d.ts +0 -6
  25. package/dist/core/version-check.d.ts +5 -0
  26. package/dist/core/version-check.js +8 -2
  27. package/dist/describer/anthropic.d.ts +2 -2
  28. package/dist/describer/anthropic.js +19 -5
  29. package/dist/describer/describer.js +15 -2
  30. package/dist/describer/gemini.js +25 -10
  31. package/dist/embedder/factory.d.ts +5 -3
  32. package/dist/embedder/factory.js +41 -8
  33. package/dist/embedder/health.js +19 -19
  34. package/dist/embedder/http.d.ts +14 -1
  35. package/dist/embedder/http.js +60 -6
  36. package/dist/eval/session-logger.js +7 -0
  37. package/dist/eval/storage.js +8 -0
  38. package/dist/indexer/git-diff.d.ts +1 -1
  39. package/dist/indexer/git-diff.js +5 -1
  40. package/dist/indexer/pipeline.js +421 -344
  41. package/dist/indexer/stats.d.ts +2 -0
  42. package/dist/indexer/stats.js +1 -0
  43. package/dist/indexer/watch.js +8 -1
  44. package/dist/indexer/worker.js +21 -0
  45. package/dist/mcp/cli.js +4 -0
  46. package/dist/mcp/handlers.js +16 -5
  47. package/dist/mcp/server.js +3 -0
  48. package/dist/opencode/create-read-tool.js +14 -3
  49. package/dist/opencode/tool-args.js +23 -1
  50. package/dist/plugin.js +66 -152
  51. package/dist/quirks/auto-capture.js +5 -0
  52. package/dist/quirks/quirk-store.d.ts +1 -1
  53. package/dist/quirks/quirk-store.js +56 -17
  54. package/dist/retriever/context-optimizer.js +18 -4
  55. package/dist/retriever/keyword-index.d.ts +2 -0
  56. package/dist/retriever/keyword-index.js +38 -4
  57. package/dist/retriever/retriever.js +6 -1
  58. package/dist/tui.js +41 -4
  59. package/dist/vectorstore/lancedb.d.ts +25 -1
  60. package/dist/vectorstore/lancedb.js +157 -11
  61. package/dist/vectorstore/memory.js +5 -1
  62. package/dist/watcher.js +30 -4
  63. package/dist/web/api.d.ts +5 -1
  64. package/dist/web/api.js +195 -69
  65. package/dist/web/server.d.ts +2 -0
  66. package/dist/web/server.js +66 -28
  67. package/dist/web/static.d.ts +5 -2
  68. package/dist/web/static.js +9 -5
  69. package/dist/web/ui/assets/index-BDPYdtA1.js +3 -0
  70. package/dist/web/ui/index.html +1 -1
  71. package/package.json +1 -1
  72. package/dist/web/ui/assets/index-CJBvt6e0.js +0 -3
@@ -8,6 +8,7 @@
8
8
  import pc from "picocolors";
9
9
  import { resolveRagContext } from "../core/bootstrap.js";
10
10
  import { destroyAllPooledConnections } from "../embedder/http.js";
11
+ import { appendDebugLog } from "../core/fileLogger.js";
11
12
  // ── Color palette ───────────────────────────────────────────────
12
13
  /**
13
14
  * Semantic color helpers for consistent CLI output styling.
@@ -61,9 +62,9 @@ export const c = {
61
62
  * @param message - Human-readable error message.
62
63
  * @param error - Optional error object for structured logging.
63
64
  */
64
- export function logCliError(_logFilePath, _scope, message, _error) {
65
+ export function logCliError(logFilePath, scope, message, error) {
65
66
  console.error(c.error(message));
66
- //appendDebugLog(logFilePath, { scope, message, error });
67
+ appendDebugLog(logFilePath, { scope, message, error });
67
68
  }
68
69
  /**
69
70
  * Log an informational message to stdout and optionally append to the debug log.
@@ -72,9 +73,9 @@ export function logCliError(_logFilePath, _scope, message, _error) {
72
73
  * @param scope - Logical scope (e.g. "index", "query") for log filtering.
73
74
  * @param message - Human-readable info message.
74
75
  */
75
- export function logCliInfo(_logFilePath, _scope, message) {
76
+ export function logCliInfo(logFilePath, scope, message) {
76
77
  console.log(message);
77
- //appendDebugLog(logFilePath, { scope, message });
78
+ appendDebugLog(logFilePath, { scope, message });
78
79
  }
79
80
  // ── Context resolution ──────────────────────────────────────────
80
81
  /**
@@ -109,10 +110,18 @@ function logConfigDetails(logFilePath, config) {
109
110
  /**
110
111
  * Gracefully close a `RagContext` — closes the vector store and destroys pooled HTTP connections.
111
112
  *
113
+ * The store close is raced against a timeout because LanceDB's native `close()`
114
+ * can hang indefinitely on Windows; callers must never be blocked forever.
115
+ *
112
116
  * @param ctx - The `RagContext` to clean up.
113
117
  */
114
118
  export async function cleanupContext(ctx) {
115
- await ctx.store.close();
119
+ await Promise.race([
120
+ ctx.store.close(),
121
+ new Promise((resolve) => {
122
+ setTimeout(resolve, 5000).unref();
123
+ }),
124
+ ]);
116
125
  destroyAllPooledConnections();
117
126
  }
118
127
  // ── Formatting helpers ──────────────────────────────────────────
@@ -95,25 +95,47 @@ export async function resizeImage(buffer, filePath, maxDimension) {
95
95
  const { pixels, width, height, channels } = decodeBmp(buffer);
96
96
  const ch = channels;
97
97
  if (width <= maxDimension && height <= maxDimension) {
98
- return sharp(pixels, { raw: { width, height, channels: ch } })
99
- .jpeg({ quality: 80 })
100
- .toBuffer();
98
+ const pipeline = sharp(pixels, { raw: { width, height, channels: ch } })
99
+ .jpeg({ quality: 80 });
100
+ try {
101
+ return await pipeline.toBuffer();
102
+ }
103
+ finally {
104
+ pipeline.destroy();
105
+ }
101
106
  }
102
- return sharp(pixels, { raw: { width, height, channels: ch } })
107
+ const pipeline = sharp(pixels, { raw: { width, height, channels: ch } })
103
108
  .resize({ width: maxDimension, fit: "inside", withoutEnlargement: true })
104
- .jpeg({ quality: 80 })
105
- .toBuffer();
109
+ .jpeg({ quality: 80 });
110
+ try {
111
+ return await pipeline.toBuffer();
112
+ }
113
+ finally {
114
+ pipeline.destroy();
115
+ }
106
116
  }
107
- const meta = await sharp(buffer).metadata();
117
+ const metaPipeline = sharp(buffer);
118
+ const meta = await metaPipeline.metadata().finally(() => metaPipeline.destroy());
108
119
  const w = meta.width ?? 0;
109
120
  const h = meta.height ?? 0;
110
121
  if (w <= maxDimension && h <= maxDimension) {
111
- return sharp(buffer).jpeg({ quality: 80 }).toBuffer();
122
+ const pipeline = sharp(buffer).jpeg({ quality: 80 });
123
+ try {
124
+ return await pipeline.toBuffer();
125
+ }
126
+ finally {
127
+ pipeline.destroy();
128
+ }
112
129
  }
113
- return sharp(buffer)
130
+ const pipeline = sharp(buffer)
114
131
  .resize({ width: maxDimension, fit: "inside", withoutEnlargement: true })
115
- .jpeg({ quality: 80 })
116
- .toBuffer();
132
+ .jpeg({ quality: 80 });
133
+ try {
134
+ return await pipeline.toBuffer();
135
+ }
136
+ finally {
137
+ pipeline.destroy();
138
+ }
117
139
  }
118
140
  catch (err) {
119
141
  throw new Error(`Image resize failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -145,13 +145,57 @@ export async function scanWorkspaceFiles(cwd, config, logger, manifest, filterPa
145
145
  const stat = await fs.stat(filePath);
146
146
  const entry = manifest.files[normalizedPath];
147
147
  if (entry.mtime === stat.mtimeMs && entry.size === stat.size) {
148
+ // Fast path — BUT only when the description config is unchanged:
149
+ // when descHash differs, the worker needs the full content to
150
+ // re-chunk and re-describe the file. Returning empty content here
151
+ // would make chunkFile() yield zero chunks and the pipeline would
152
+ // DELETE the file from the index instead of re-describing it.
153
+ // Mirrors pipeline.ts: `descriptionProvider ? computeDescriptionConfigHash(config) : undefined`.
154
+ const currentDescHash = config.description?.enabled
155
+ ? (computeDescriptionConfigHash(config) ?? "")
156
+ : "";
157
+ if (!currentDescHash || entry.descHash === currentDescHash) {
158
+ completed++;
159
+ return {
160
+ filePath,
161
+ normalizedPath,
162
+ content: "",
163
+ hash: entry.hash,
164
+ isEmpty: false,
165
+ isTooSmall: false,
166
+ extractionStatus: "ok",
167
+ mtime: stat.mtimeMs,
168
+ size: stat.size,
169
+ };
170
+ }
171
+ }
172
+ }
173
+ catch {
174
+ /* stat failed, fall through to full read */
175
+ }
176
+ }
177
+ const isImage = imageVisionProvider !== null && imageExtractor.isImageFile(filePath);
178
+ const ext = path.extname(filePath).toLowerCase();
179
+ const isBinary = pdfExtractor.PDF_EXTENSIONS.has(ext) ||
180
+ docxExtractor.DOCX_EXTENSIONS.has(ext) ||
181
+ docExtractor.DOC_EXTENSIONS.has(ext) ||
182
+ excelExtractor.EXCEL_EXTENSIONS.has(ext) ||
183
+ isImage;
184
+ // Reject oversized binaries BEFORE buffering them — a 2 GB PDF was
185
+ // previously read fully into memory only to be rejected by the
186
+ // 100 MB check inside the PDF extractor.
187
+ if (isBinary && pdfExtractor.PDF_EXTENSIONS.has(ext)) {
188
+ try {
189
+ const stat = await fs.stat(filePath);
190
+ if (stat.size > 100 * 1024 * 1024) {
191
+ logger?.warn(` ${filePath} (PDF exceeds 100 MB — skipping)`);
148
192
  completed++;
149
193
  return {
150
194
  filePath,
151
195
  normalizedPath,
152
196
  content: "",
153
- hash: entry.hash,
154
- isEmpty: false,
197
+ hash: computeFileHash(""),
198
+ isEmpty: true,
155
199
  isTooSmall: false,
156
200
  extractionStatus: "ok",
157
201
  mtime: stat.mtimeMs,
@@ -160,17 +204,10 @@ export async function scanWorkspaceFiles(cwd, config, logger, manifest, filterPa
160
204
  }
161
205
  }
162
206
  catch {
163
- /* stat failed, fall through to full read */
207
+ /* stat failed, fall through to read */
164
208
  }
165
209
  }
166
- const isImage = imageVisionProvider !== null && imageExtractor.isImageFile(filePath);
167
- const ext = path.extname(filePath).toLowerCase();
168
- const isBinary = pdfExtractor.PDF_EXTENSIONS.has(ext) ||
169
- docxExtractor.DOCX_EXTENSIONS.has(ext) ||
170
- docExtractor.DOC_EXTENSIONS.has(ext) ||
171
- excelExtractor.EXCEL_EXTENSIONS.has(ext) ||
172
- isImage;
173
- logger?.info(`Reading: ${filePath}`);
210
+ logger?.debug(`Reading: ${filePath}`);
174
211
  const buffer = isBinary ? await fs.readFile(filePath) : Buffer.alloc(0);
175
212
  // For images, check the persistent description cache before calling the vision provider
176
213
  if (isImage && descCache && imageDescConfigHash) {
@@ -206,15 +243,39 @@ export async function scanWorkspaceFiles(cwd, config, logger, manifest, filterPa
206
243
  logger?.info(` Describing image: ${filePath}`);
207
244
  }
208
245
  const result = await dispatchExtraction(filePath, buffer, imageVisionProvider, imagePrompt, imageResizeMaxDimension);
209
- // Cache the image description for future runs
246
+ // Cache the image description for future runs. Saves are throttled —
247
+ // a full cache rewrite per image was O(n²) I/O for image-heavy workspaces.
248
+ // The pipeline saves the cache again at the end of a pass.
210
249
  if (isImage && result.ok && descCache && imageDescConfigHash) {
211
250
  const imageBytesHash = computeFileHash(buffer.toString("base64"));
212
251
  const cacheKey = DescriptionCache.imageKey(imageBytesHash, imageDescConfigHash);
213
252
  descCache.set(cacheKey, result.content);
214
- await descCache.save();
253
+ if (completed % 25 === 0) {
254
+ await descCache.save();
255
+ }
215
256
  }
216
257
  if (!result.ok) {
217
258
  logger?.warn(` ${filePath} (extraction failed: ${result.error})`);
259
+ // A transient extraction failure (file lock, antivirus, timeout) must
260
+ // NOT delete a previously-good index entry. Report the file as
261
+ // unchanged with the OLD hash so the worker keeps the old chunks;
262
+ // the entry stays and the file is re-attempted on a later pass.
263
+ const previous = manifest?.files[normalizedPath];
264
+ if (previous) {
265
+ completed++;
266
+ return {
267
+ filePath,
268
+ normalizedPath,
269
+ content: "",
270
+ hash: previous.hash,
271
+ isEmpty: false,
272
+ isTooSmall: false,
273
+ extractionStatus: "failed",
274
+ extractionError: result.error,
275
+ mtime: previous.mtime,
276
+ size: previous.size,
277
+ };
278
+ }
218
279
  }
219
280
  const content = result.content;
220
281
  const byteLength = Buffer.byteLength(content, "utf-8");
@@ -3,7 +3,7 @@
3
3
  * creates embedder, vector store, keyword index, and description provider.
4
4
  */
5
5
  import path from "node:path";
6
- import { loadConfig, findConfigFile, DEFAULT_CONFIG } from "./config.js";
6
+ import { loadConfig, findConfigFile, resolveLogConfig, DEFAULT_CONFIG } from "./config.js";
7
7
  import { resolveApiKey } from "./resolve-api-key.js";
8
8
  import { loadChunkersFromConfig } from "../chunker/loader.js";
9
9
  import { createEmbedder } from "../embedder/factory.js";
@@ -21,6 +21,10 @@ async function probeDimension(embedder) {
21
21
  catch {
22
22
  // fallback to 384
23
23
  }
24
+ // A wrong dimension is only discovered later as cryptic LanceDB errors —
25
+ // surface the fallback loudly so misconfigured providers are easy to spot.
26
+ console.warn("[bootstrap] Could not probe embedding dimension — falling back to 384. " +
27
+ "If indexing later fails with dimension errors, set embedding.vectorDimension explicitly.");
24
28
  return 384;
25
29
  }
26
30
  /** Load the keyword index from disk, or create a new empty one if loading fails. */
@@ -50,9 +54,12 @@ export async function resolveRagContext(opts = {}) {
50
54
  await loadChunkersFromConfig(cfg, path.dirname(configPath));
51
55
  }
52
56
  else {
53
- cfg = { ...DEFAULT_CONFIG };
57
+ // Deep-clone so resolveApiKey (below) cannot mutate the shared
58
+ // DEFAULT_CONFIG singleton.
59
+ cfg = structuredClone(DEFAULT_CONFIG);
60
+ resolveApiKey(cfg, workDir);
54
61
  }
55
- const logFilePath = path.resolve(workDir, cfg.logging?.logFilePath ?? ".opencode/opencode-rag.log");
62
+ const logFilePath = path.resolve(workDir, resolveLogConfig(cfg).logFilePath);
56
63
  const embedder = createEmbedder(cfg);
57
64
  const dimension = opts.skipProbe ? 384 : await probeDimension(embedder);
58
65
  const storePath = path.resolve(workDir, cfg.vectorStore.path);
@@ -378,6 +378,23 @@ export function validateConfig(config) {
378
378
  if (config.memory?.autoInjectTopK != null && config.memory.autoInjectTopK < 1) {
379
379
  warnings.push("memory.autoInjectTopK must be >= 1");
380
380
  }
381
+ if (config.memory?.minConfidence != null) {
382
+ const r = config.memory.minConfidence;
383
+ if (r < 0 || r > 1)
384
+ warnings.push("memory.minConfidence must be between 0 and 1");
385
+ }
386
+ if (config.memory?.autoCaptureDedupThreshold != null) {
387
+ const t = config.memory.autoCaptureDedupThreshold;
388
+ // > 1 would make dedup reject every candidate; < 0 disables it silently
389
+ if (t < 0 || t > 1)
390
+ warnings.push("memory.autoCaptureDedupThreshold must be between 0 and 1");
391
+ }
392
+ if (config.memory?.decay && config.memory.decay.halfLifeDays <= 0) {
393
+ warnings.push("memory.decay.halfLifeDays must be > 0");
394
+ }
395
+ if (config.openCode.maxReadOutputChars != null && config.openCode.maxReadOutputChars <= 0) {
396
+ warnings.push("openCode.maxReadOutputChars must be > 0");
397
+ }
381
398
  if (config.openCode.maxContextChunks <= 0) {
382
399
  warnings.push("openCode.maxContextChunks must be > 0");
383
400
  }
@@ -480,6 +497,14 @@ export function loadConfig(filePath, validate = true) {
480
497
  ...DEFAULT_CONFIG.retrieval.hybridSearch,
481
498
  ...(safeObj(parsed.retrieval?.hybridSearch) ?? {}),
482
499
  },
500
+ // contextOptimization MUST be nested-merged too — a shallow spread
501
+ // would replace the whole default object with a partial user object,
502
+ // silently leaving maxPerFile/mergeAdjacent/similarityThreshold etc.
503
+ // undefined (which disables the features via NaN comparisons).
504
+ contextOptimization: {
505
+ ...DEFAULT_CONFIG.retrieval.contextOptimization,
506
+ ...(safeObj(parsed.retrieval?.contextOptimization) ?? {}),
507
+ },
483
508
  },
484
509
  openCode: (() => {
485
510
  const base = DEFAULT_CONFIG.openCode;
@@ -528,6 +553,12 @@ export function loadConfig(filePath, validate = true) {
528
553
  memory: {
529
554
  ...DEFAULT_CONFIG.memory,
530
555
  ...(safeObj(parsed.memory) ?? {}),
556
+ // decay is a nested object — a user config with only
557
+ // `memory.decay.enabled` must not lose the halfLifeDays default.
558
+ decay: {
559
+ ...DEFAULT_CONFIG.memory.decay,
560
+ ...(safeObj((safeObj(parsed.memory) ?? {})?.decay) ?? {}),
561
+ },
531
562
  },
532
563
  ui: {
533
564
  ...DEFAULT_CONFIG.ui,
@@ -22,8 +22,14 @@ export declare class DescriptionCache {
22
22
  has(key: string): boolean;
23
23
  /** Persist the cache to disk if dirty. Safe to call multiple times. */
24
24
  save(): Promise<void>;
25
- /** Build a cache key for a code chunk. */
26
- static codeKey(content: string, descConfigHash: string): string;
25
+ /**
26
+ * Build a cache key for a code chunk.
27
+ *
28
+ * The optional `context` (relative path + line range) is included because
29
+ * the description prompt contains the file path — identical chunk content
30
+ * in two different files must not reuse a contextually wrong description.
31
+ */
32
+ static codeKey(content: string, descConfigHash: string, context?: string): string;
27
33
  /** Build a cache key for an image file. */
28
34
  static imageKey(imageBytesHash: string, imageDescConfigHash: string): string;
29
35
  /** Remove old entries to stay under the limit. */
@@ -89,10 +89,17 @@ export class DescriptionCache {
89
89
  });
90
90
  return this.savePromise;
91
91
  }
92
- /** Build a cache key for a code chunk. */
93
- static codeKey(content, descConfigHash) {
92
+ /**
93
+ * Build a cache key for a code chunk.
94
+ *
95
+ * The optional `context` (relative path + line range) is included because
96
+ * the description prompt contains the file path — identical chunk content
97
+ * in two different files must not reuse a contextually wrong description.
98
+ */
99
+ static codeKey(content, descConfigHash, context) {
94
100
  const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 16);
95
- return contentHash + "_" + descConfigHash.slice(0, 16);
101
+ const contextHash = context ? createHash("sha256").update(context).digest("hex").slice(0, 8) : "";
102
+ return contentHash + "_" + descConfigHash.slice(0, 16) + (contextHash ? "_" + contextHash : "");
96
103
  }
97
104
  /** Build a cache key for an image file. */
98
105
  static imageKey(imageBytesHash, imageDescConfigHash) {
@@ -47,17 +47,20 @@ export function markFileDocumented(storePath, filePath) {
47
47
  export function markSubdirectoryDocumented(storePath, subdir, allFilePaths) {
48
48
  const normalized = subdir.replace(/\\/g, "/").replace(/\/$/, "");
49
49
  const progress = loadDocProgress(storePath);
50
+ // Set lookups avoid the O(n·m) `includes` scan for large workspaces
51
+ const documentedSet = new Set(progress.documented);
50
52
  let changed = false;
51
53
  for (const filePath of allFilePaths) {
52
54
  const normalizedFile = filePath.replace(/\\/g, "/");
53
55
  if (normalizedFile.startsWith(normalized + "/") || normalizedFile === normalized) {
54
- if (!progress.documented.includes(filePath)) {
55
- progress.documented.push(filePath);
56
+ if (!documentedSet.has(filePath)) {
57
+ documentedSet.add(filePath);
56
58
  changed = true;
57
59
  }
58
60
  }
59
61
  }
60
62
  if (changed) {
63
+ progress.documented = [...documentedSet];
61
64
  progress.lastUpdated = Date.now();
62
65
  saveDocProgress(storePath, progress);
63
66
  }
@@ -19,3 +19,5 @@ export declare const PROVIDER_DEFAULTS: Record<string, ProviderDefaults>;
19
19
  export declare function getProviderDefault(provider: string): ProviderDefaults | undefined;
20
20
  /** Check whether a given provider uses an OpenAI-compatible API format. */
21
21
  export declare function isOpenAiCompatible(provider: string): boolean;
22
+ /** Whether the given provider can produce embeddings. */
23
+ export declare function supportsEmbedding(provider: string): boolean;
@@ -81,11 +81,26 @@ export const PROVIDER_DEFAULTS = {
81
81
  export function getProviderDefault(provider) {
82
82
  return PROVIDER_DEFAULTS[provider];
83
83
  }
84
+ /** OpenAI-compatible providers (embedding + chat via /v1-style APIs). */
85
+ const OPENAI_COMPATIBLE_PROVIDERS = new Set([
86
+ "openai",
87
+ "nvidia",
88
+ "azure",
89
+ "mistral",
90
+ "together",
91
+ "groq",
92
+ "deepseek",
93
+ "fireworks",
94
+ ]);
84
95
  /** Check whether a given provider uses an OpenAI-compatible API format. */
85
96
  export function isOpenAiCompatible(provider) {
86
- if (provider === "ollama" || provider === "anthropic" || provider === "google" || provider === "cohere") {
87
- return false;
88
- }
89
- return true;
97
+ // Only KNOWN OpenAI-compatible providers qualify an unknown/typo'd
98
+ // provider name must fail fast instead of silently attempting
99
+ // OpenAI-style calls against a nonsense base URL.
100
+ return OPENAI_COMPATIBLE_PROVIDERS.has(provider);
101
+ }
102
+ /** Whether the given provider can produce embeddings. */
103
+ export function supportsEmbedding(provider) {
104
+ return PROVIDER_DEFAULTS[provider]?.supportsEmbedding ?? false;
90
105
  }
91
106
  //# sourceMappingURL=provider-defaults.js.map
@@ -19,12 +19,6 @@ export interface RuntimeOverrides {
19
19
  debounceMs?: number;
20
20
  watcher?: string;
21
21
  };
22
- autoInject?: {
23
- enabled?: boolean;
24
- minScore?: number;
25
- maxChunks?: number;
26
- contentType?: string;
27
- };
28
22
  };
29
23
  embedding?: {
30
24
  provider?: string;
@@ -45,6 +45,11 @@ type NpmRunner = (command: string, options: {
45
45
  export declare function getCurrentVersion(): string;
46
46
  /**
47
47
  * Compare two semver-ish strings.
48
+ *
49
+ * Pre-release suffixes are stripped before comparison — "1.2.3-beta.1"
50
+ * must compare equal to "1.2.3" (parseInt would turn "3-beta.1" into 3,
51
+ * and non-numeric segments into NaN which compares equal).
52
+ *
48
53
  * @returns 1 if a > b, -1 if a < b, 0 if equal.
49
54
  */
50
55
  export declare function compareVersions(a: string, b: string): number;
@@ -38,11 +38,17 @@ function normalizeVersion(tag) {
38
38
  }
39
39
  /**
40
40
  * Compare two semver-ish strings.
41
+ *
42
+ * Pre-release suffixes are stripped before comparison — "1.2.3-beta.1"
43
+ * must compare equal to "1.2.3" (parseInt would turn "3-beta.1" into 3,
44
+ * and non-numeric segments into NaN which compares equal).
45
+ *
41
46
  * @returns 1 if a > b, -1 if a < b, 0 if equal.
42
47
  */
43
48
  export function compareVersions(a, b) {
44
- const pa = a.split(".").map((s) => parseInt(s, 10));
45
- const pb = b.split(".").map((s) => parseInt(s, 10));
49
+ const clean = (s) => s.split("-", 1)[0] ?? s;
50
+ const pa = clean(a).split(".").map((s) => parseInt(s, 10));
51
+ const pb = clean(b).split(".").map((s) => parseInt(s, 10));
46
52
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
47
53
  const na = pa[i] ?? 0;
48
54
  const nb = pb[i] ?? 0;
@@ -25,8 +25,8 @@ export declare class AnthropicDescriptionProvider implements DescriptionProvider
25
25
  generateBatchDescriptions(chunks: Chunk[], logger?: DescriptionLogger, opts?: BatchDescriptionOptions): Promise<Map<string, string>>;
26
26
  /**
27
27
  * Sends a request to the Anthropic Messages API with retry and exponential backoff.
28
- * The system prompt is combined with all user messages into a single message payload, and
29
- * the assistant placeholder is appended to elicit a completion.
28
+ * The system prompt is sent via the native `system` field (never concatenated into
29
+ * the user message, so chunk content cannot compete with it).
30
30
  *
31
31
  * @param messages - The user messages to send.
32
32
  * @param timeoutMs - Request timeout in milliseconds.
@@ -59,8 +59,8 @@ export class AnthropicDescriptionProvider {
59
59
  }
60
60
  /**
61
61
  * Sends a request to the Anthropic Messages API with retry and exponential backoff.
62
- * The system prompt is combined with all user messages into a single message payload, and
63
- * the assistant placeholder is appended to elicit a completion.
62
+ * The system prompt is sent via the native `system` field (never concatenated into
63
+ * the user message, so chunk content cannot compete with it).
64
64
  *
65
65
  * @param messages - The user messages to send.
66
66
  * @param timeoutMs - Request timeout in milliseconds.
@@ -74,7 +74,8 @@ export class AnthropicDescriptionProvider {
74
74
  const body = {
75
75
  model: this.config.model,
76
76
  max_tokens: 4096,
77
- messages: [{ role: "user", content: systemPrompt + "\n\n" + messages.map((m) => `${m.role}: ${m.content}`).join("\n\n") + "\n\nassistant:" }],
77
+ system: systemPrompt,
78
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
78
79
  };
79
80
  const headers = {
80
81
  "x-api-key": apiKey,
@@ -85,7 +86,20 @@ export class AnthropicDescriptionProvider {
85
86
  const retryBaseDelayMs = this.config.retryBaseDelayMs ?? 1000;
86
87
  let lastError;
87
88
  for (let attempt = 0; attempt <= retryMax; attempt++) {
88
- const response = await postJson(`${baseUrl}/messages`, body, headers, timeoutMs, this.config.proxy);
89
+ let response;
90
+ try {
91
+ response = await postJson(`${baseUrl}/messages`, body, headers, timeoutMs, this.config.proxy);
92
+ }
93
+ catch (err) {
94
+ // Network-level failures (ECONNREFUSED, socket timeouts) are transient —
95
+ // treat them like retryable HTTP statuses.
96
+ lastError = err instanceof Error ? err : new Error(String(err));
97
+ if (attempt === retryMax)
98
+ throw lastError;
99
+ const delayMs = retryBaseDelayMs * Math.pow(2, attempt) * (0.8 + Math.random() * 0.4);
100
+ await sleep(delayMs);
101
+ continue;
102
+ }
89
103
  if (response.ok) {
90
104
  const json = (await response.json());
91
105
  const text = json.content?.[0]?.text;
@@ -100,7 +114,7 @@ export class AnthropicDescriptionProvider {
100
114
  throw error;
101
115
  }
102
116
  lastError = error;
103
- const delayMs = retryBaseDelayMs * Math.pow(2, attempt);
117
+ const delayMs = retryBaseDelayMs * Math.pow(2, attempt) * (0.8 + Math.random() * 0.4);
104
118
  await sleep(delayMs);
105
119
  }
106
120
  throw lastError ?? new Error("Anthropic LLM request failed: unknown error");
@@ -85,7 +85,20 @@ export class LlmDescriptionProvider {
85
85
  const retryBaseDelayMs = this.config.retryBaseDelayMs ?? 1000;
86
86
  let lastError;
87
87
  for (let attempt = 0; attempt <= retryMax; attempt++) {
88
- const response = await postJson(url, body, headers, timeoutMs, this.config.proxy);
88
+ let response;
89
+ try {
90
+ response = await postJson(url, body, headers, timeoutMs, this.config.proxy);
91
+ }
92
+ catch (err) {
93
+ // Network-level failures (ECONNREFUSED, socket timeouts) are transient —
94
+ // treat them like retryable HTTP statuses.
95
+ lastError = err instanceof Error ? err : new Error(String(err));
96
+ if (attempt === retryMax)
97
+ throw lastError;
98
+ const delayMs = retryBaseDelayMs * Math.pow(2, attempt) * (0.8 + Math.random() * 0.4);
99
+ await sleep(delayMs);
100
+ continue;
101
+ }
89
102
  if (response.ok) {
90
103
  const json = (await response.json());
91
104
  return extractResponseText(json, isOllama);
@@ -96,7 +109,7 @@ export class LlmDescriptionProvider {
96
109
  throw error;
97
110
  }
98
111
  lastError = error;
99
- const delayMs = retryBaseDelayMs * Math.pow(2, attempt);
112
+ const delayMs = retryBaseDelayMs * Math.pow(2, attempt) * (0.8 + Math.random() * 0.4);
100
113
  await sleep(delayMs);
101
114
  }
102
115
  throw lastError ?? new Error("Description LLM request failed: unknown error");
@@ -57,24 +57,39 @@ export class GeminiDescriptionProvider {
57
57
  const apiKey = this.config.apiKey ?? "";
58
58
  const model = this.config.model;
59
59
  const systemPrompt = systemOverride ?? this.config.systemPrompt;
60
- const allParts = [{ text: systemPrompt }];
61
- for (const c of contents) {
62
- allParts.push(...c.parts);
63
- }
64
60
  const body = {
65
- contents: [{ role: "user", parts: allParts }],
61
+ // The system prompt goes into the native systemInstruction field, never
62
+ // into the same content parts as the chunk text.
63
+ systemInstruction: { parts: [{ text: systemPrompt }] },
64
+ contents: contents.map((c) => ({ role: c.role, parts: c.parts })),
66
65
  };
67
66
  const headers = {
68
67
  "Content-Type": "application/json",
69
68
  };
70
- const url = apiKey
71
- ? `${baseUrl}/models/${model}:generateContent?key=${apiKey}`
72
- : `${baseUrl}/models/${model}:generateContent`;
69
+ // Send the key via header so it never appears in URLs (which are echoed in
70
+ // redirect-limit error messages) and cannot be mangled by URL encoding.
71
+ if (apiKey) {
72
+ headers["x-goog-api-key"] = apiKey;
73
+ }
74
+ const url = `${baseUrl}/models/${model}:generateContent`;
73
75
  const retryMax = this.config.retryMax ?? 3;
74
76
  const retryBaseDelayMs = this.config.retryBaseDelayMs ?? 1000;
75
77
  let lastError;
76
78
  for (let attempt = 0; attempt <= retryMax; attempt++) {
77
- const response = await postJson(url, body, headers, timeoutMs);
79
+ let response;
80
+ try {
81
+ response = await postJson(url, body, headers, timeoutMs, this.config.proxy);
82
+ }
83
+ catch (err) {
84
+ // Network-level failures (ECONNREFUSED, socket timeouts) are transient —
85
+ // treat them like retryable HTTP statuses.
86
+ lastError = err instanceof Error ? err : new Error(String(err));
87
+ if (attempt === retryMax)
88
+ throw lastError;
89
+ const delayMs = retryBaseDelayMs * Math.pow(2, attempt) * (0.8 + Math.random() * 0.4);
90
+ await sleep(delayMs);
91
+ continue;
92
+ }
78
93
  if (response.ok) {
79
94
  const json = (await response.json());
80
95
  const text = json.candidates?.[0]?.content?.parts?.[0]?.text;
@@ -89,7 +104,7 @@ export class GeminiDescriptionProvider {
89
104
  throw error;
90
105
  }
91
106
  lastError = error;
92
- const delayMs = retryBaseDelayMs * Math.pow(2, attempt);
107
+ const delayMs = retryBaseDelayMs * Math.pow(2, attempt) * (0.8 + Math.random() * 0.4);
93
108
  await sleep(delayMs);
94
109
  }
95
110
  throw lastError ?? new Error("Gemini LLM request failed: unknown error");
@@ -22,9 +22,11 @@ export declare function createEmbedder(config: RagConfig): EmbeddingProvider;
22
22
  * (or concurrently when `concurrency > 1`). When concurrency is limited, uses
23
23
  * `p-limit` to cap the number of in-flight requests.
24
24
  *
25
- * Each batch is retried up to `retryMax` times with exponential backoff. If all
26
- * retries are exhausted, the batch is skipped and empty arrays are returned for
27
- * those texts so the caller can still process successfully embedded batches.
25
+ * Each batch is retried up to `retryMax` times with exponential backoff (only
26
+ * for transient failures auth/validation errors are not retried). If all
27
+ * retries are exhausted or the provider returns a mismatched embedding count,
28
+ * the batch is skipped and empty arrays are returned for those texts so the
29
+ * caller can still process successfully embedded batches.
28
30
  *
29
31
  * @param embedder - The embedding provider to use
30
32
  * @param texts - Array of text strings to embed