opencode-rag-plugin 1.22.1 → 1.23.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.
package/ReadMe.md CHANGED
@@ -189,7 +189,7 @@ opencode-rag mcp
189
189
  | `search_semantic` | Vector + keyword hybrid search across the indexed codebase |
190
190
  | `get_file_skeleton` | AST-based file outline (functions, classes, methods) |
191
191
  | `find_usages` | Find all references to a symbol by name |
192
- | `describe_image` | Return the pre-generated description for an indexed image file |
192
+ | `describe_image` | Describe an image file with the configured vision model (`imageDescription.onDemand` overrides apply) |
193
193
 
194
194
  Clients can configure the MCP server manually, or `opencode-rag init` auto-registers it.
195
195
 
@@ -211,7 +211,7 @@ OpenCodeRAG registers tools that agents can invoke directly. Agents discover the
211
211
  | `search_semantic` | General-purpose code retrieval | Before any code task when you haven't read the relevant code |
212
212
  | `get_file_skeleton` | Quick file overview via AST | Before reading a large file to decide which sections matter |
213
213
  | `find_usages` | Symbol reference search | **Before editing** any function, variable, or class |
214
- | `describe_image` | Retrieve pre-generated image description | When a user asks about a screenshot, diagram, or visual asset |
214
+ | `describe_image` | Live image description via the configured vision model | When a user asks about a screenshot, diagram, or visual asset |
215
215
  | `read` (optional) | RAG-enhanced file read | Full file contents with supplementary context chunks |
216
216
 
217
217
  ## OpenCode Integration
@@ -20,6 +20,20 @@ export declare function getMimeType(ext: string): string;
20
20
  export interface ImageVisionProvider {
21
21
  describeImage(imageBase64: string, mimeType: string, prompt: string, systemPrompt?: string, abort?: AbortSignal): Promise<string>;
22
22
  }
23
+ /**
24
+ * Resolve the effective image-description configuration for on-demand
25
+ * `describe_image` calls (OpenCode plugin tool, MCP server, CLI
26
+ * `describe-image`).
27
+ *
28
+ * Applies the optional `imageDescription.onDemand` overrides on top of the
29
+ * indexing settings. The indexing pipeline always uses the base config and
30
+ * ignores `onDemand`. `null`/`undefined` override values are ignored so a
31
+ * partially written config cannot blank out required fields.
32
+ *
33
+ * @param config - The base image description configuration.
34
+ * @returns A copy with on-demand overrides applied and `onDemand` stripped.
35
+ */
36
+ export declare function resolveOnDemandImageConfig(config: ImageDescriptionConfig): ImageDescriptionConfig;
23
37
  /**
24
38
  * Factory function that creates the appropriate {@link ImageVisionProvider}
25
39
  * implementation based on the `provider` field in the config.
@@ -306,6 +306,32 @@ class GeminiImageVisionProvider {
306
306
  throw lastError;
307
307
  }
308
308
  }
309
+ /**
310
+ * Resolve the effective image-description configuration for on-demand
311
+ * `describe_image` calls (OpenCode plugin tool, MCP server, CLI
312
+ * `describe-image`).
313
+ *
314
+ * Applies the optional `imageDescription.onDemand` overrides on top of the
315
+ * indexing settings. The indexing pipeline always uses the base config and
316
+ * ignores `onDemand`. `null`/`undefined` override values are ignored so a
317
+ * partially written config cannot blank out required fields.
318
+ *
319
+ * @param config - The base image description configuration.
320
+ * @returns A copy with on-demand overrides applied and `onDemand` stripped.
321
+ */
322
+ export function resolveOnDemandImageConfig(config) {
323
+ const override = config.onDemand;
324
+ const resolved = { ...config };
325
+ delete resolved.onDemand;
326
+ if (!override)
327
+ return resolved;
328
+ for (const [key, value] of Object.entries(override)) {
329
+ if (value === undefined || value === null)
330
+ continue;
331
+ resolved[key] = value;
332
+ }
333
+ return resolved;
334
+ }
309
335
  /**
310
336
  * Factory function that creates the appropriate {@link ImageVisionProvider}
311
337
  * implementation based on the `provider` field in the config.
@@ -36,7 +36,7 @@ export function registerDescribeImageCommand(program) {
36
36
  process.exit(1);
37
37
  }
38
38
  const ext = path.extname(resolvedPath).toLowerCase();
39
- const { SUPPORTED_IMAGE_EXTENSIONS, createImageVisionProvider, getMimeType } = await import("../../chunker/image.js");
39
+ const { SUPPORTED_IMAGE_EXTENSIONS, createImageVisionProvider, resolveOnDemandImageConfig, getMimeType } = await import("../../chunker/image.js");
40
40
  if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
41
41
  const exts = [...SUPPORTED_IMAGE_EXTENSIONS].join(", ");
42
42
  logCliError(logFilePath, "describe-image", `\nUnsupported file extension "${ext}". Supported: ${exts}`, undefined);
@@ -48,16 +48,20 @@ export function registerDescribeImageCommand(program) {
48
48
  process.exit(1);
49
49
  }
50
50
  const { resizeImage } = await import("../../content/image.js");
51
+ const effectiveImageConfig = resolveOnDemandImageConfig(imageDescriptionConfig);
51
52
  logCliInfo(logFilePath, "describe-image", `\n${c.heading("Describing image:")} ${c.file(filePath)}`);
52
- logCliInfo(logFilePath, "describe-image", ` ${c.label("Provider:")} ${c.value(imageDescriptionConfig.provider)}`);
53
- logCliInfo(logFilePath, "describe-image", ` ${c.label("Model:")} ${c.value(imageDescriptionConfig.model)}`);
53
+ logCliInfo(logFilePath, "describe-image", ` ${c.label("Provider:")} ${c.value(effectiveImageConfig.provider)}`);
54
+ logCliInfo(logFilePath, "describe-image", ` ${c.label("Model:")} ${c.value(effectiveImageConfig.model)}`);
55
+ if (imageDescriptionConfig.onDemand) {
56
+ logCliInfo(logFilePath, "describe-image", ` ${c.label("Source:")} imageDescription.onDemand override`);
57
+ }
54
58
  const buffer = readFileSync(resolvedPath);
55
59
  const mimeType = getMimeType(ext);
56
- const maxDimension = imageDescriptionConfig.resizeMaxDimension ?? 1024;
60
+ const maxDimension = effectiveImageConfig.resizeMaxDimension ?? 1024;
57
61
  const sized = maxDimension > 0 ? await resizeImage(buffer, resolvedPath, maxDimension) : buffer;
58
62
  const b64 = sized.toString("base64");
59
- const provider = createImageVisionProvider(imageDescriptionConfig);
60
- const description = await provider.describeImage(b64, mimeType, imageDescriptionConfig.prompt, options.systemPrompt);
63
+ const provider = createImageVisionProvider(effectiveImageConfig);
64
+ const description = await provider.describeImage(b64, mimeType, effectiveImageConfig.prompt, options.systemPrompt);
61
65
  logCliInfo(logFilePath, "describe-image", `\n${c.desc(description)}\n`);
62
66
  await cleanupContext(ctx);
63
67
  }
@@ -98,6 +98,7 @@ export function registerIndexCommand(program) {
98
98
  }
99
99
  }
100
100
  logCliInfo(logFilePath, "index", `${c.label("Scanning:")} ${c.file(cwd)}`);
101
+ let incomplete = false;
101
102
  const runPass = async (watchTriggered = false, abortSignal, filterPaths) => {
102
103
  const passStarted = Date.now();
103
104
  const stats = await runIndexPass({
@@ -116,6 +117,14 @@ export function registerIndexCommand(program) {
116
117
  });
117
118
  if (!watchTriggered) {
118
119
  logIndexSummary(logFilePath, stats);
120
+ if (stats.embeddingUnavailable) {
121
+ incomplete = true;
122
+ logCliInfo(logFilePath, "index", `\n${c.error("Indexing incomplete:")} the embedding provider was unavailable — nothing was stored. Fix the provider and run the index again.`);
123
+ }
124
+ else if (stats.totalChunks === 0 && stats.newFiles + stats.modifiedFiles > 0) {
125
+ incomplete = true;
126
+ logCliInfo(logFilePath, "index", `\n${c.warn("Indexing incomplete:")} files were processed but no chunks were stored. Check the log for details.`);
127
+ }
119
128
  logCliInfo(logFilePath, "index", `\n${c.success("Indexing complete.")} ${c.num(stats.finalCount)} chunks stored (${formatDuration(Date.now() - passStarted)}).`);
120
129
  }
121
130
  if (sigReceived && !watchTriggered) {
@@ -128,7 +137,7 @@ export function registerIndexCommand(program) {
128
137
  process.removeListener("SIGTERM", handleSigint);
129
138
  if (!options.watch) {
130
139
  await cleanupContext(ctx);
131
- process.exit(sigReceived ? 130 : 0);
140
+ process.exit(sigReceived ? 130 : incomplete ? 1 : 0);
132
141
  }
133
142
  // Only one watcher may run per workspace — a background auto-indexer
134
143
  // in an OpenCode session (or another `index --watch`) may already own
@@ -261,7 +261,7 @@ export function registerInitCommand(program) {
261
261
  const results = await healthPromise;
262
262
  for (const r of results) {
263
263
  const icon = r.status === "ok" ? c.success("✓") : r.status === "missing" ? c.warn("○") : c.error("✗");
264
- const typeLabel = r.type === "image_description" ? "image description" : r.type;
264
+ const typeLabel = r.type === "image_description" ? "image description" : r.type === "image_description_on_demand" ? "image description (on-demand)" : r.type;
265
265
  const label = `${typeLabel} model`;
266
266
  console.log(` ${icon} ${c.value(r.model)} (${r.provider}) — ${label}: ${r.status}`);
267
267
  if (r.error)
@@ -280,6 +280,14 @@ export function registerInitCommand(program) {
280
280
  if (r.type === "image_description" && ragConfig.imageDescription) {
281
281
  return { model: r.model, baseUrl: ragConfig.imageDescription.baseUrl, proxy: ragConfig.imageDescription.proxy };
282
282
  }
283
+ if (r.type === "image_description_on_demand" && ragConfig.imageDescription) {
284
+ const onDemand = ragConfig.imageDescription.onDemand;
285
+ return {
286
+ model: r.model,
287
+ baseUrl: onDemand?.baseUrl ?? ragConfig.imageDescription.baseUrl,
288
+ proxy: onDemand?.proxy ?? ragConfig.imageDescription.proxy,
289
+ };
290
+ }
283
291
  return { model: r.model, baseUrl: ragConfig.embedding.baseUrl, proxy: ragConfig.embedding.proxy };
284
292
  });
285
293
  console.log(`\n ${c.warn("Models not found:")} ${pullEntries.map((e) => e.model).join(", ")}`);
@@ -75,6 +75,20 @@ export function registerStatusCommand(program) {
75
75
  logCliInfo(logFilePath, "status", `${c.label("Store path:")} ${c.file(storePath)}`);
76
76
  logCliInfo(logFilePath, "status", `${c.label("Embedding provider:")} ${c.value(config.embedding.provider)}`);
77
77
  logCliInfo(logFilePath, "status", `${c.label("Embedding model:")} ${c.value(config.embedding.model)}`);
78
+ const storeDimension = await store.getVectorDimension?.();
79
+ const embedderDimension = config.embedding.vectorDimension;
80
+ if (embedderDimension && embedderDimension > 0) {
81
+ logCliInfo(logFilePath, "status", `${c.label("Embedder dim:")} ${c.num(embedderDimension)}`);
82
+ }
83
+ if (storeDimension !== undefined) {
84
+ logCliInfo(logFilePath, "status", `${c.label("Store dimension:")} ${c.num(storeDimension)}`);
85
+ }
86
+ if (storeDimension !== undefined &&
87
+ embedderDimension !== undefined &&
88
+ embedderDimension > 0 &&
89
+ storeDimension !== embedderDimension) {
90
+ logCliInfo(logFilePath, "status", `${c.label("Dimension mismatch:")} ${c.warn("yes")} — the store was built with a different embedding model. Run 'opencode-rag index' to rebuild.`);
91
+ }
78
92
  logCliInfo(logFilePath, "status", `${c.label("File extensions:")} ${config.indexing.includeExtensions.join(", ")}`);
79
93
  logCliInfo(logFilePath, "status", `${c.label("Excluded dirs:")} ${config.indexing.excludeDirs.join(", ")}`);
80
94
  logCliInfo(logFilePath, "status", `${c.label("Default top-K:")} ${c.num(config.retrieval.topK)}`);
@@ -92,7 +92,7 @@ export async function resolveCliContext(opt, logFilePath, bootstrapOpts) {
92
92
  configPath: opt.config,
93
93
  ...bootstrapOpts,
94
94
  });
95
- logCliInfo(logFilePath, "config", `${c.label("Config:")} ${c.file(ctx.logFilePath)}`);
95
+ logCliInfo(logFilePath, "config", `${c.label("Config:")} ${c.file(ctx.configPath ?? "(built-in defaults)")}`);
96
96
  logConfigDetails(logFilePath, ctx.config);
97
97
  return ctx;
98
98
  }
@@ -105,6 +105,9 @@ export async function resolveCliContext(opt, logFilePath, bootstrapOpts) {
105
105
  function logConfigDetails(logFilePath, config) {
106
106
  logCliInfo(logFilePath, "config", ` ${c.label("Embedding provider:")} ${c.value(config.embedding.provider)}`);
107
107
  logCliInfo(logFilePath, "config", ` ${c.label("Embedding model:")} ${c.value(config.embedding.model)}`);
108
+ if (config.embedding.vectorDimension && config.embedding.vectorDimension > 0) {
109
+ logCliInfo(logFilePath, "config", ` ${c.label("Embedding dim:")} ${c.num(config.embedding.vectorDimension)}`);
110
+ }
108
111
  logCliInfo(logFilePath, "config", ` ${c.label("Vector store:")} ${c.file(config.vectorStore.path)}`);
109
112
  }
110
113
  /**
@@ -153,7 +156,13 @@ export function logIndexSummary(logFilePath, stats) {
153
156
  if (stats.descriptionFailedFiles > 0) {
154
157
  logCliInfo(logFilePath, "index", ` ${c.label("Desc failed:")} ${c.num(stats.descriptionFailedFiles)}`);
155
158
  }
159
+ if (stats.embeddingFailures > 0) {
160
+ logCliInfo(logFilePath, "index", ` ${c.label("Embed failed:")} ${c.num(stats.embeddingFailures)} chunk(s) without vectors — retried next pass`);
161
+ }
156
162
  logCliInfo(logFilePath, "index", ` ${c.label("Chunks written:")} ${c.num(stats.totalChunks)}`);
163
+ if (stats.embeddingUnavailable) {
164
+ logCliInfo(logFilePath, "index", ` ${c.error("Embedding provider unavailable during this pass — no chunks were stored.")}`);
165
+ }
157
166
  }
158
167
  /**
159
168
  * Format a millisecond duration into a compact human-readable string.
@@ -35,6 +35,8 @@ export interface RagContext {
35
35
  dimension: number;
36
36
  /** Resolved path to the debug log file. */
37
37
  logFilePath: string;
38
+ /** Resolved path to the config file, or undefined when built-in defaults are used. */
39
+ configPath?: string;
38
40
  }
39
41
  /** Bootstrap the full RAG pipeline context: load config, resolve API keys, create embedder, vector store, keyword index, and description provider. */
40
42
  export declare function resolveRagContext(opts?: BootstrapOptions): Promise<RagContext>;
@@ -6,26 +6,46 @@ import path from "node:path";
6
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
- import { createEmbedder } from "../embedder/factory.js";
9
+ import { createEmbedder, probeEmbeddingDimension } from "../embedder/factory.js";
10
10
  import { createDescriptionProvider } from "../describer/factory.js";
11
11
  import { createVectorStore } from "../vectorstore/factory.js";
12
+ import { readStoreDimension } from "../vectorstore/lancedb.js";
12
13
  import { KeywordIndex } from "../retriever/keyword-index.js";
13
- /** Probe the embedding provider to determine the vector dimension. Falls back to 384 on failure. */
14
- async function probeDimension(embedder) {
15
- try {
16
- const probe = await embedder.embed(["dimension-probe"], "query");
17
- if (probe && probe[0] && probe[0].length > 0 && typeof probe[0][0] === "number") {
18
- return probe[0].length;
19
- }
14
+ /**
15
+ * Resolve the embedding dimension for the store.
16
+ *
17
+ * Precedence: an explicit `embedding.vectorDimension` in the config wins (it
18
+ * is persisted after the first successful probe), then a live probe of the
19
+ * provider, then the dimension of an existing store table, then the 384
20
+ * fallback. The old behavior always probed and fell back to 384 — which
21
+ * silently created 384-dimensional stores whenever the provider was down,
22
+ * even though the config declared the real dimension.
23
+ *
24
+ * @param embedder - Configured embedding provider (probed only when needed).
25
+ * @param storePath - Vector store path, used to read an existing schema.
26
+ * @param skipProbe - When true, never call the provider (read-only commands).
27
+ * @returns The resolved dimension.
28
+ */
29
+ async function resolveDimension(embedder, storePath, skipProbe, configured) {
30
+ if (configured && configured > 0) {
31
+ return configured;
20
32
  }
21
- catch {
22
- // fallback to 384
33
+ if (!skipProbe) {
34
+ const probe = await probeEmbeddingDimension(embedder);
35
+ if (probe.dimension !== undefined) {
36
+ return probe.dimension;
37
+ }
38
+ const storeDimension = await readStoreDimension(storePath);
39
+ if (storeDimension !== undefined) {
40
+ console.warn(`[bootstrap] Could not probe embedding dimension (${probe.error?.message ?? "unknown error"}) — ` +
41
+ `using the existing store's dimension ${storeDimension}.`);
42
+ return storeDimension;
43
+ }
44
+ console.warn(`[bootstrap] Could not probe embedding dimension (${probe.error?.message ?? "unknown error"}) — ` +
45
+ "falling back to 384. Set embedding.vectorDimension explicitly if the provider produces a different size.");
46
+ return 384;
23
47
  }
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.");
28
- return 384;
48
+ return (await readStoreDimension(storePath)) ?? 384;
29
49
  }
30
50
  /** Load the keyword index from disk, or create a new empty one if loading fails. */
31
51
  async function loadKeywordIndex(storePath) {
@@ -61,8 +81,16 @@ export async function resolveRagContext(opts = {}) {
61
81
  }
62
82
  const logFilePath = path.resolve(workDir, resolveLogConfig(cfg).logFilePath);
63
83
  const embedder = createEmbedder(cfg);
64
- const dimension = opts.skipProbe ? 384 : await probeDimension(embedder);
65
84
  const storePath = path.resolve(workDir, cfg.vectorStore.path);
85
+ const dimension = await resolveDimension(embedder, storePath, opts.skipProbe ?? false, cfg.embedding.vectorDimension);
86
+ // Warn (but do not block) when the existing store schema disagrees with the
87
+ // resolved dimension: the next index pass rebuilds automatically, and search
88
+ // callers would otherwise see only cryptic LanceDB errors.
89
+ const storeDimension = await readStoreDimension(storePath);
90
+ if (storeDimension !== undefined && storeDimension !== dimension) {
91
+ console.warn(`[bootstrap] Store was built with vector dimension ${storeDimension} but the current embedder produces ${dimension} — ` +
92
+ "run 'opencode-rag index' to rebuild the index with the current model.");
93
+ }
66
94
  const store = createVectorStore(cfg, storePath, dimension);
67
95
  const keywordIndex = opts.skipKeywordIndex
68
96
  ? new KeywordIndex(storePath)
@@ -80,6 +108,7 @@ export async function resolveRagContext(opts = {}) {
80
108
  descriptionProvider,
81
109
  dimension,
82
110
  logFilePath,
111
+ configPath,
83
112
  };
84
113
  }
85
114
  //# sourceMappingURL=bootstrap.js.map
@@ -85,6 +85,36 @@ export interface DescriptionConfig {
85
85
  maxContentChars?: number;
86
86
  }
87
87
  /** Configuration for vision-model-based image description generation. */
88
+ /**
89
+ * Optional overrides for on-demand `describe_image` calls (OpenCode plugin
90
+ * tool, MCP server, CLI `describe-image`). Fields omitted here fall back to
91
+ * the indexing settings in {@link ImageDescriptionConfig}; the indexing
92
+ * pipeline itself always uses the base settings.
93
+ */
94
+ export interface ImageDescriptionOnDemandConfig {
95
+ /** Vision provider name ("ollama", "openai", "anthropic", "google"). */
96
+ provider?: string;
97
+ /** Vision model name. */
98
+ model?: string;
99
+ /** Base URL of the vision API. */
100
+ baseUrl?: string;
101
+ /** API key for providers that require authentication. */
102
+ apiKey?: string;
103
+ /** Request timeout in milliseconds. */
104
+ timeoutMs?: number;
105
+ /** Prompt template sent to the vision model. */
106
+ prompt?: string;
107
+ /** Whether to include chain-of-thought tokens. */
108
+ think?: boolean;
109
+ /** Context window size. */
110
+ numCtx?: number;
111
+ /** Ollama keep_alive value (e.g. "-1" for keep-in-memory) sent with /api/chat requests. */
112
+ keepAlive?: string;
113
+ /** Proxy configuration. */
114
+ proxy?: ProxyConfig;
115
+ /** Maximum image dimension (pixels) — larger images are resized before sending. */
116
+ resizeMaxDimension?: number;
117
+ }
88
118
  export interface ImageDescriptionConfig {
89
119
  /** Whether image description is enabled. */
90
120
  enabled: boolean;
@@ -110,8 +140,13 @@ export interface ImageDescriptionConfig {
110
140
  proxy?: ProxyConfig;
111
141
  /** Maximum image dimension (pixels) — larger images are resized before sending. */
112
142
  resizeMaxDimension?: number;
143
+ /**
144
+ * Optional overrides for on-demand `describe_image` calls (OpenCode plugin
145
+ * tool, MCP server, CLI `describe-image`). Omitted fields fall back to the
146
+ * indexing settings above. The indexing pipeline ignores this section.
147
+ */
148
+ onDemand?: ImageDescriptionOnDemandConfig;
113
149
  }
114
- /** Configuration for the built-in web dashboard UI. */
115
150
  export interface UiConfig {
116
151
  /** HTTP port for the UI server. */
117
152
  port: number;
@@ -296,6 +331,18 @@ export interface RagConfig {
296
331
  ollamaMaxBatchSize?: number;
297
332
  /** Maximum concurrent description generation requests. */
298
333
  descriptionConcurrency?: number;
334
+ /**
335
+ * Include LLM-generated chunk descriptions in the text that gets embedded.
336
+ *
337
+ * Descriptions help general-purpose embedding models align natural-language
338
+ * queries with raw code. Code-specialized models (e.g. jina-code-embeddings,
339
+ * trained for NL→code retrieval with instruction prefixes) do not need the
340
+ * crutch — and their passage prompt expects a code snippet, not prose.
341
+ * When false, descriptions are still generated and stored (search results,
342
+ * web UI), but only path/meta header/content are embedded.
343
+ * @default true
344
+ */
345
+ embedDescriptions?: boolean;
299
346
  /**
300
347
  * Maximum file size in bytes for SVG/XML files before chunking is skipped.
301
348
  * Large SVGs can cause tree-sitter to hang. Set to 0 for no limit.
@@ -117,6 +117,7 @@ export const DEFAULT_CONFIG = {
117
117
  embedConcurrency: 3,
118
118
  ollamaMaxBatchSize: 100,
119
119
  descriptionConcurrency: 4,
120
+ embedDescriptions: true,
120
121
  maxSvgSizeBytes: 1_048_576,
121
122
  optimizeIntervalWindows: 8,
122
123
  },
@@ -443,6 +444,10 @@ export function validateConfig(config) {
443
444
  warnings.push("imageDescription.timeoutMs must be > 0");
444
445
  }
445
446
  }
447
+ const onDemand = config.imageDescription.onDemand;
448
+ if (onDemand?.timeoutMs !== undefined && onDemand.timeoutMs <= 0) {
449
+ warnings.push("imageDescription.onDemand.timeoutMs must be > 0");
450
+ }
446
451
  }
447
452
  return { valid: warnings.length === 0, warnings };
448
453
  }
@@ -224,6 +224,14 @@ export interface VectorStore {
224
224
  * Used by the index pipeline to detect silent corruption before scanning.
225
225
  */
226
226
  checkIntegrity?(): Promise<boolean>;
227
+ /**
228
+ * Return the actual embedding dimension of the stored vector column, or
229
+ * `undefined` when the store has no table yet. Used to detect a store that
230
+ * was built with a different embedding model than the currently configured
231
+ * one — a mismatch makes every vector search fail and silently corrupts
232
+ * writes, so the pipeline rebuilds the index instead.
233
+ */
234
+ getVectorDimension?(): Promise<number | undefined>;
227
235
  }
228
236
  /** Filter criteria for narrowing search results by file path, language, kind, or extension. */
229
237
  export interface MetadataFilter {
@@ -14,6 +14,11 @@ export function resolveApiKey(cfg, worktree) {
14
14
  if (cfg.imageDescription?.enabled && cfg.imageDescription.provider !== "ollama") {
15
15
  resolveForSection(cfg.imageDescription.provider, cfg.imageDescription, worktree);
16
16
  }
17
+ const imageOnDemand = cfg.imageDescription?.onDemand;
18
+ if (imageOnDemand) {
19
+ const provider = imageOnDemand.provider ?? cfg.imageDescription?.provider ?? "ollama";
20
+ resolveForSection(provider, imageOnDemand, worktree);
21
+ }
17
22
  }
18
23
  function isPlaceholder(value) {
19
24
  return value === "public" || value === "" || value === "PLACEHOLDER";
@@ -15,6 +15,26 @@ import type { RagConfig } from "../core/config.js";
15
15
  * @throws If the provider is unsupported or a required apiKey is not set
16
16
  */
17
17
  export declare function createEmbedder(config: RagConfig): EmbeddingProvider;
18
+ /** Result of probing an embedding provider for its output dimension. */
19
+ export interface EmbeddingProbeResult {
20
+ /** Detected vector dimension, or `undefined` when the probe failed. */
21
+ dimension?: number;
22
+ /** Error raised by the provider (or an empty response), when the probe failed. */
23
+ error?: Error;
24
+ }
25
+ /**
26
+ * Probe an embedding provider with a single short text to discover the
27
+ * dimension of the vectors it produces.
28
+ *
29
+ * Used as a health/dimension preflight before expensive index work: a provider
30
+ * that is down (server not running, model not pulled) fails here in
31
+ * milliseconds instead of after chunking and describing every file. Errors are
32
+ * captured in the result rather than thrown.
33
+ *
34
+ * @param embedder - The embedding provider to probe.
35
+ * @returns The detected dimension, or the failure reason.
36
+ */
37
+ export declare function probeEmbeddingDimension(embedder: EmbeddingProvider): Promise<EmbeddingProbeResult>;
18
38
  /**
19
39
  * Embed a list of texts in batches with optional concurrency control and per-batch retry.
20
40
  *
@@ -40,6 +40,30 @@ export function createEmbedder(config) {
40
40
  }
41
41
  throw new Error(`Unknown embedding provider: ${provider}`);
42
42
  }
43
+ /**
44
+ * Probe an embedding provider with a single short text to discover the
45
+ * dimension of the vectors it produces.
46
+ *
47
+ * Used as a health/dimension preflight before expensive index work: a provider
48
+ * that is down (server not running, model not pulled) fails here in
49
+ * milliseconds instead of after chunking and describing every file. Errors are
50
+ * captured in the result rather than thrown.
51
+ *
52
+ * @param embedder - The embedding provider to probe.
53
+ * @returns The detected dimension, or the failure reason.
54
+ */
55
+ export async function probeEmbeddingDimension(embedder) {
56
+ try {
57
+ const probe = await embedder.embed(["dimension-probe"], "query");
58
+ if (probe && probe[0] && probe[0].length > 0 && typeof probe[0][0] === "number") {
59
+ return { dimension: probe[0].length };
60
+ }
61
+ return { error: new Error("Embedding provider returned an empty probe vector") };
62
+ }
63
+ catch (err) {
64
+ return { error: err instanceof Error ? err : new Error(String(err)) };
65
+ }
66
+ }
43
67
  /**
44
68
  * HTTP statuses that indicate a permanent failure — retrying cannot help
45
69
  * (auth errors, bad requests, missing resources). Providers raise these as
@@ -10,7 +10,7 @@ export interface HealthCheckResult {
10
10
  /** Model identifier that was tested */
11
11
  model: string;
12
12
  /** Which capability was checked */
13
- type: "embedding" | "description" | "image_description";
13
+ type: "embedding" | "description" | "image_description" | "image_description_on_demand";
14
14
  /** Whether the provider is reachable and the model is available */
15
15
  status: "ok" | "missing" | "error";
16
16
  /** Human-readable error message when status is not "ok" */
@@ -1,3 +1,4 @@
1
+ import { resolveOnDemandImageConfig } from "../chunker/image.js";
1
2
  import { fetchWithProxy, postJson } from "./http.js";
2
3
  /**
3
4
  * Check connectivity and model availability for all configured providers.
@@ -13,6 +14,10 @@ export async function checkProviderHealth(config) {
13
14
  }
14
15
  if (config.imageDescription?.enabled) {
15
16
  checks.push(checkImageDescriptionModel(config, timeoutMs));
17
+ const onDemand = resolveOnDemandImageConfig(config.imageDescription);
18
+ if (onDemand.provider !== config.imageDescription.provider || onDemand.model !== config.imageDescription.model) {
19
+ checks.push(checkOnDemandImageDescriptionModel(onDemand));
20
+ }
16
21
  }
17
22
  return Promise.all(checks);
18
23
  }
@@ -56,19 +61,27 @@ async function checkImageDescriptionModel(config, _timeoutMs) {
56
61
  if (!img) {
57
62
  return { provider: "unknown", model: "unknown", type: "image_description", status: "error", error: "Image description config is undefined" };
58
63
  }
64
+ return checkVisionModel(img, "image_description");
65
+ }
66
+ /** Check the optional `imageDescription.onDemand` vision model used by the describe_image tool, MCP server, and CLI. */
67
+ async function checkOnDemandImageDescriptionModel(img) {
68
+ return checkVisionModel(img, "image_description_on_demand");
69
+ }
70
+ /** Dispatch a vision-model check (indexing or on-demand) to the correct provider-specific handler. */
71
+ async function checkVisionModel(img, type) {
59
72
  const { provider, baseUrl, model, apiKey } = img;
60
73
  const imgTimeout = img.timeoutMs ?? 60000;
61
74
  if (provider === "ollama") {
62
- return checkOllamaChat(baseUrl, model, imgTimeout, img.proxy, "image_description");
75
+ return checkOllamaChat(baseUrl, model, imgTimeout, img.proxy, type);
63
76
  }
64
77
  if (provider === "anthropic") {
65
- return checkAnthropicChat(baseUrl, model, apiKey, imgTimeout, img.proxy, "image_description");
78
+ return checkAnthropicChat(baseUrl, model, apiKey, imgTimeout, img.proxy, type);
66
79
  }
67
80
  if (provider === "google") {
68
- return checkGoogleChat(baseUrl, model, apiKey, imgTimeout, img.proxy, "image_description");
81
+ return checkGoogleChat(baseUrl, model, apiKey, imgTimeout, img.proxy, type);
69
82
  }
70
83
  // OpenAI-compatible chat endpoint
71
- return checkOpenAiChat(baseUrl, model, apiKey, imgTimeout, img.proxy, "image_description");
84
+ return checkOpenAiChat(baseUrl, model, apiKey, imgTimeout, img.proxy, type);
72
85
  }
73
86
  /** Check whether a provider name matches a known OpenAI-compatible provider. */
74
87
  function isOpenAiCompatible(provider) {
package/dist/index.d.ts CHANGED
@@ -14,9 +14,9 @@ export { optimizeContext, DEFAULT_CONTEXT_OPTIMIZATION } from "./retriever/conte
14
14
  export { loadConfig, DEFAULT_CONFIG } from "./core/config.js";
15
15
  export { createBackgroundIndexer } from "./watcher.js";
16
16
  export { createWatchIgnore } from "./indexer.js";
17
- export { ImageChunker, createImageVisionProvider, getMimeType, SUPPORTED_IMAGE_EXTENSIONS } from "./chunker/image.js";
17
+ export { ImageChunker, createImageVisionProvider, resolveOnDemandImageConfig, getMimeType, SUPPORTED_IMAGE_EXTENSIONS } from "./chunker/image.js";
18
18
  export { DescriptionCache } from "./core/desc-cache.js";
19
- export type { RagConfig, DescriptionConfig, ImageDescriptionConfig } from "./core/config.js";
19
+ export type { RagConfig, DescriptionConfig, ImageDescriptionConfig, ImageDescriptionOnDemandConfig } from "./core/config.js";
20
20
  export type { Chunk, SearchResult, OptimizedSearchResult, Chunker, DescriptionProvider, EmbeddingProvider, VectorStore } from "./core/interfaces.js";
21
21
  export type { ContextOptimizationConfig, ContextOptimizationOptions } from "./retriever/context-optimizer.js";
22
22
  /**
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ export { optimizeContext, DEFAULT_CONTEXT_OPTIMIZATION } from "./retriever/conte
14
14
  export { loadConfig, DEFAULT_CONFIG } from "./core/config.js";
15
15
  export { createBackgroundIndexer } from "./watcher.js";
16
16
  export { createWatchIgnore } from "./indexer.js";
17
- export { ImageChunker, createImageVisionProvider, getMimeType, SUPPORTED_IMAGE_EXTENSIONS } from "./chunker/image.js";
17
+ export { ImageChunker, createImageVisionProvider, resolveOnDemandImageConfig, getMimeType, SUPPORTED_IMAGE_EXTENSIONS } from "./chunker/image.js";
18
18
  export { DescriptionCache } from "./core/desc-cache.js";
19
19
  /**
20
20
  * High-level convenience API — search, index, and retrieve context in a single function call.