opencode-rag-plugin 1.19.5 → 1.19.8

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.
@@ -42,6 +42,7 @@ class OllamaImageVisionProvider {
42
42
  timeoutMs;
43
43
  think;
44
44
  numCtx;
45
+ keepAlive;
45
46
  proxy;
46
47
  constructor(config) {
47
48
  this.baseUrl = config.baseUrl.replace(/\/+$/, "");
@@ -49,6 +50,7 @@ class OllamaImageVisionProvider {
49
50
  this.timeoutMs = config.timeoutMs;
50
51
  this.think = config.think ?? false;
51
52
  this.numCtx = config.numCtx;
53
+ this.keepAlive = config.keepAlive;
52
54
  this.proxy = config.proxy;
53
55
  }
54
56
  async describeImage(imageBase64, _mimeType, prompt, abort) {
@@ -65,6 +67,9 @@ class OllamaImageVisionProvider {
65
67
  think: this.think,
66
68
  options: { num_ctx: this.numCtx },
67
69
  };
70
+ if (this.keepAlive) {
71
+ body.keep_alive = this.keepAlive;
72
+ }
68
73
  let lastError;
69
74
  for (let attempt = 0; attempt <= VISION_RETRY_MAX; attempt++) {
70
75
  const response = await postJson(`${this.baseUrl}/chat`, body, {}, this.timeoutMs, this.proxy, abort);
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @fileoverview Auto-detect the Ollama backend (CPU vs GPU) during `init` and
3
+ * pick embedding batch settings tuned for the detected backend.
4
+ */
5
+ /**
6
+ * Auto-detect whether Ollama runs models on the GPU or on the CPU and return
7
+ * matching embedding batch tuning.
8
+ *
9
+ * Detection uses `GET /api/ps`: loaded models report `size_vram` (bytes
10
+ * resident in VRAM). `size_vram > 0` means the model is (at least partially)
11
+ * offloaded to the GPU. If no model is loaded yet, a minimal `/api/embed`
12
+ * warmup loads the default embedding model first.
13
+ *
14
+ * Tuning is derived from benchmarks (see quirk memory):
15
+ * - GPU: batch 40 + concurrency 4 ≈ 86 texts/s (~97% of the ~88 texts/s ceiling)
16
+ * - CPU: flat ~3.5 texts/s regardless of batch size → small batches (20) with
17
+ * concurrency 1 keep each request fast and under the 4096-token context
18
+ * - unreachable/unknown: defaults (100 / 3 / 100)
19
+ */
20
+ import { type ProxyConfig } from "../../core/config.js";
21
+ /** Detected Ollama backend kind. */
22
+ export type OllamaBackend = "gpu" | "cpu" | "unreachable" | "unknown";
23
+ /** Embedding batch settings written into the generated config. */
24
+ export interface IndexingTuning {
25
+ embedBatchSize: number;
26
+ embedConcurrency: number;
27
+ ollamaMaxBatchSize: number;
28
+ }
29
+ /** Result of the backend detection. */
30
+ export interface OllamaBackendInfo {
31
+ backend: OllamaBackend;
32
+ /** Tuning to write into the generated config. */
33
+ tuning: IndexingTuning;
34
+ /** Human-readable summary for the init output. */
35
+ message: string;
36
+ }
37
+ interface PsModel {
38
+ name: string;
39
+ size_vram?: number;
40
+ }
41
+ /**
42
+ * Classify loaded Ollama models into a backend + tuning profile.
43
+ *
44
+ * Prefers the configured default embedding model when it is loaded, falling
45
+ * back to any loaded model (a loaded GPU model means the host has a working
46
+ * GPU that Ollama will also use for embeddings).
47
+ *
48
+ * @param models - The `models` array from `GET /api/ps`.
49
+ * @returns The backend info with the matching tuning profile.
50
+ */
51
+ export declare function classifyOllamaModels(models: PsModel[]): OllamaBackendInfo;
52
+ /**
53
+ * Detect the Ollama backend by probing `/api/ps`, warming up the default
54
+ * embedding model when nothing is loaded yet.
55
+ *
56
+ * @param baseUrl - Ollama API base URL (defaults to the config default).
57
+ * @param proxy - Optional proxy configuration.
58
+ * @returns Backend info; never throws.
59
+ */
60
+ export declare function detectOllamaBackend(baseUrl?: string, proxy?: ProxyConfig): Promise<OllamaBackendInfo>;
61
+ export {};
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @fileoverview Auto-detect the Ollama backend (CPU vs GPU) during `init` and
3
+ * pick embedding batch settings tuned for the detected backend.
4
+ */
5
+ /**
6
+ * Auto-detect whether Ollama runs models on the GPU or on the CPU and return
7
+ * matching embedding batch tuning.
8
+ *
9
+ * Detection uses `GET /api/ps`: loaded models report `size_vram` (bytes
10
+ * resident in VRAM). `size_vram > 0` means the model is (at least partially)
11
+ * offloaded to the GPU. If no model is loaded yet, a minimal `/api/embed`
12
+ * warmup loads the default embedding model first.
13
+ *
14
+ * Tuning is derived from benchmarks (see quirk memory):
15
+ * - GPU: batch 40 + concurrency 4 ≈ 86 texts/s (~97% of the ~88 texts/s ceiling)
16
+ * - CPU: flat ~3.5 texts/s regardless of batch size → small batches (20) with
17
+ * concurrency 1 keep each request fast and under the 4096-token context
18
+ * - unreachable/unknown: defaults (100 / 3 / 100)
19
+ */
20
+ import { DEFAULT_CONFIG } from "../../core/config.js";
21
+ import { fetchWithProxy, postJson } from "../../embedder/http.js";
22
+ /** Benchmarked optimum on a GPU-backed Ollama (RTX 4090, qwen3-embedding:0.6b). */
23
+ const GPU_TUNING = {
24
+ embedBatchSize: 40,
25
+ embedConcurrency: 4,
26
+ ollamaMaxBatchSize: 40,
27
+ };
28
+ /** CPU-backed Ollama: throughput is flat, so keep batches small and sequential. */
29
+ const CPU_TUNING = {
30
+ embedBatchSize: 20,
31
+ embedConcurrency: 1,
32
+ ollamaMaxBatchSize: 20,
33
+ };
34
+ /** Fallback when Ollama is unreachable or the backend cannot be determined. */
35
+ const DEFAULT_TUNING = {
36
+ embedBatchSize: DEFAULT_CONFIG.indexing.embedBatchSize,
37
+ embedConcurrency: DEFAULT_CONFIG.indexing.embedConcurrency ?? 3,
38
+ ollamaMaxBatchSize: DEFAULT_CONFIG.indexing.ollamaMaxBatchSize ?? 100,
39
+ };
40
+ /**
41
+ * Classify loaded Ollama models into a backend + tuning profile.
42
+ *
43
+ * Prefers the configured default embedding model when it is loaded, falling
44
+ * back to any loaded model (a loaded GPU model means the host has a working
45
+ * GPU that Ollama will also use for embeddings).
46
+ *
47
+ * @param models - The `models` array from `GET /api/ps`.
48
+ * @returns The backend info with the matching tuning profile.
49
+ */
50
+ export function classifyOllamaModels(models) {
51
+ if (!models || models.length === 0) {
52
+ return {
53
+ backend: "unknown",
54
+ tuning: DEFAULT_TUNING,
55
+ message: "Could not determine the Ollama backend (no models loaded) — using default batch settings.",
56
+ };
57
+ }
58
+ const embedModel = DEFAULT_CONFIG.embedding.model;
59
+ const probe = models.find((m) => m.name === embedModel) ?? models[0];
60
+ const onGpu = probe ? (probe.size_vram ?? 0) > 0 : false;
61
+ if (onGpu) {
62
+ return {
63
+ backend: "gpu",
64
+ tuning: GPU_TUNING,
65
+ message: `Ollama detected on GPU — tuned embedding for batch 40 / concurrency 4 (${(probe?.size_vram ?? 0) / (1024 * 1024) | 0} MiB in VRAM).`,
66
+ };
67
+ }
68
+ return {
69
+ backend: "cpu",
70
+ tuning: CPU_TUNING,
71
+ message: "Ollama detected on CPU — tuned embedding for batch 20 / concurrency 1.",
72
+ };
73
+ }
74
+ /** Fetch `GET /api/ps`, returning null when Ollama is unreachable or errors. */
75
+ async function getOllamaPs(baseUrl, proxy) {
76
+ const url = `${baseUrl.replace(/\/+$/, "")}/ps`;
77
+ try {
78
+ const res = await fetchWithProxy(url, { method: "GET", signal: AbortSignal.timeout(3000) }, proxy);
79
+ if (!res.ok)
80
+ return null;
81
+ const data = (await res.json());
82
+ return data.models ?? null;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ /**
89
+ * Detect the Ollama backend by probing `/api/ps`, warming up the default
90
+ * embedding model when nothing is loaded yet.
91
+ *
92
+ * @param baseUrl - Ollama API base URL (defaults to the config default).
93
+ * @param proxy - Optional proxy configuration.
94
+ * @returns Backend info; never throws.
95
+ */
96
+ export async function detectOllamaBackend(baseUrl = DEFAULT_CONFIG.embedding.baseUrl, proxy) {
97
+ let models = await getOllamaPs(baseUrl, proxy);
98
+ if (models === null) {
99
+ return {
100
+ backend: "unreachable",
101
+ tuning: DEFAULT_TUNING,
102
+ message: "Ollama not reachable — using default batch settings.",
103
+ };
104
+ }
105
+ if (models.length === 0) {
106
+ // Nothing loaded yet: a minimal embed request loads the default
107
+ // embedding model so /api/ps can report its backend.
108
+ const embedUrl = `${baseUrl.replace(/\/+$/, "")}/embed`;
109
+ try {
110
+ await postJson(embedUrl, { model: DEFAULT_CONFIG.embedding.model, input: "warmup" }, {}, 15000, proxy);
111
+ }
112
+ catch {
113
+ // Model missing or request failed — classification will stay unknown.
114
+ }
115
+ models = (await getOllamaPs(baseUrl, proxy)) ?? [];
116
+ }
117
+ return classifyOllamaModels(models);
118
+ }
119
+ //# sourceMappingURL=backend-detect.js.map
@@ -13,6 +13,7 @@ import readline from "node:readline";
13
13
  import chokidar from "chokidar";
14
14
  import { appendDebugLog } from "../../core/fileLogger.js";
15
15
  import { createWatchPassScheduler, createWatchIgnore, runIndexPass, } from "../../indexer.js";
16
+ import { tryAcquireWatcherLock, releaseWatcherLock } from "../../watcher.js";
16
17
  import { c, resolveCliContext, cleanupContext, logCliError, logCliInfo, logIndexSummary, formatDuration, } from "../format.js";
17
18
  /**
18
19
  * Build a logger that suppresses console output when watchTriggered is true.
@@ -129,6 +130,15 @@ export function registerIndexCommand(program) {
129
130
  await cleanupContext(ctx);
130
131
  process.exit(sigReceived ? 130 : 0);
131
132
  }
133
+ // Only one watcher may run per workspace — a background auto-indexer
134
+ // in an OpenCode session (or another `index --watch`) may already own
135
+ // this store. The initial pass above still ran; just don't start a
136
+ // duplicate watcher.
137
+ if (!tryAcquireWatcherLock(storePath)) {
138
+ logCliInfo(logFilePath, "index", c.warn("Another watcher is already running for this workspace (e.g. an OpenCode session with auto-index enabled) — not starting a second one. The index is up to date."));
139
+ await cleanupContext(ctx);
140
+ process.exit(0);
141
+ }
132
142
  logCliInfo(logFilePath, "index", `\n${c.heading("Watching for changes...")}`);
133
143
  const scheduler = createWatchPassScheduler(async (changedPaths) => { await runPass(true, undefined, changedPaths); }, (error) => {
134
144
  const message = error.message || String(error);
@@ -154,6 +164,7 @@ export function registerIndexCommand(program) {
154
164
  watcher.close(),
155
165
  new Promise((r) => setTimeout(r, 5000)),
156
166
  ]);
167
+ releaseWatcherLock(storePath);
157
168
  await cleanupContext(ctx);
158
169
  process.exit(0);
159
170
  };
@@ -6,6 +6,7 @@
6
6
  * dependency installation, and gitignore merging.
7
7
  */
8
8
  import type { PackageMetadata } from "../types.js";
9
+ import type { IndexingTuning } from "./backend-detect.js";
9
10
  /**
10
11
  * Build the workspace-local `.opencode/package.json` content.
11
12
  *
@@ -115,6 +116,8 @@ export declare function installPluginFromGlobal(opencodeDir: string, packageName
115
116
  /**
116
117
  * Generate the default `opencode-rag.json` configuration content.
117
118
  *
119
+ * @param tuning - Optional embedding batch tuning (auto-detected from the
120
+ * Ollama backend). Falls back to `DEFAULT_CONFIG` for any omitted field.
118
121
  * @returns A pretty-printed JSON string with all default configuration values.
119
122
  */
120
- export declare function generateDefaultConfigJson(): string;
123
+ export declare function generateDefaultConfigJson(tuning?: Partial<IndexingTuning>): string;
@@ -414,9 +414,11 @@ export async function installPluginFromGlobal(opencodeDir, packageName, skipInst
414
414
  /**
415
415
  * Generate the default `opencode-rag.json` configuration content.
416
416
  *
417
+ * @param tuning - Optional embedding batch tuning (auto-detected from the
418
+ * Ollama backend). Falls back to `DEFAULT_CONFIG` for any omitted field.
417
419
  * @returns A pretty-printed JSON string with all default configuration values.
418
420
  */
419
- export function generateDefaultConfigJson() {
421
+ export function generateDefaultConfigJson(tuning) {
420
422
  return JSON.stringify({
421
423
  embedding: {
422
424
  provider: DEFAULT_CONFIG.embedding.provider,
@@ -430,7 +432,9 @@ export function generateDefaultConfigJson() {
430
432
  chunkOverlap: DEFAULT_CONFIG.indexing.chunkOverlap,
431
433
  minFileSizeBytes: DEFAULT_CONFIG.indexing.minFileSizeBytes,
432
434
  concurrency: DEFAULT_CONFIG.indexing.concurrency,
433
- embedBatchSize: DEFAULT_CONFIG.indexing.embedBatchSize,
435
+ embedBatchSize: tuning?.embedBatchSize ?? DEFAULT_CONFIG.indexing.embedBatchSize,
436
+ embedConcurrency: tuning?.embedConcurrency ?? DEFAULT_CONFIG.indexing.embedConcurrency ?? 3,
437
+ ollamaMaxBatchSize: tuning?.ollamaMaxBatchSize ?? DEFAULT_CONFIG.indexing.ollamaMaxBatchSize ?? 100,
434
438
  },
435
439
  vectorStore: {
436
440
  path: DEFAULT_CONFIG.vectorStore.path,
@@ -17,6 +17,7 @@ import { destroyAllPooledConnections } from "../../embedder/http.js";
17
17
  import { c } from "../format.js";
18
18
  import { getPackageMetadata, readJsonObject, writeJsonFile } from "../helpers.js";
19
19
  import { buildOpencodeConfig, buildWorkspacePackageJson, generateDefaultConfigJson, generateSkillFile, generateWorkspacePluginFile, generateWorkspaceTuiPluginFile, installPluginFromGlobal, mergeAgentsMdContent, mergeGitignoreContent, } from "./init-helpers.js";
20
+ import { detectOllamaBackend } from "./backend-detect.js";
20
21
  /**
21
22
  * Register the `init` command on the given Commander program.
22
23
  *
@@ -29,7 +30,14 @@ import { buildOpencodeConfig, buildWorkspacePackageJson, generateDefaultConfigJs
29
30
  export function registerInitCommand(program) {
30
31
  program
31
32
  .command("init")
32
- .description("Configure the current workspace for OpenCodeRAG")
33
+ .description("Configure this workspace (files + auto-tuned opencode-rag.json)")
34
+ .addHelpText("after", "\nUse cases:\n" +
35
+ " - First-time workspace setup: creates .opencode/, the RAG skill file, AGENTS.md\n" +
36
+ " guidance, and opencode-rag.json with embedding batches auto-detected for the\n" +
37
+ " Ollama backend (GPU vs CPU).\n" +
38
+ " - Re-running in an existing workspace: re-syncs plugin/skill files and keeps the\n" +
39
+ " existing opencode-rag.json (overwriting requires interactive confirmation).\n" +
40
+ "\nWorkspace-level step — run AFTER 'opencode-rag setup' on this machine, then 'opencode-rag index'.\n")
33
41
  .option("-f, --force", "overwrite existing files")
34
42
  .option("--skip-install", "skip installing workspace-local plugin dependencies")
35
43
  .option("--skip-health-check", "skip provider connectivity and model availability check")
@@ -189,8 +197,27 @@ export function registerInitCommand(program) {
189
197
  console.log(` ${c.exists("Exists:")} .opencode/package.json`);
190
198
  }
191
199
  const configExists = existsSync(configPath);
200
+ // Detect the Ollama backend (CPU vs GPU) lazily — only when we are
201
+ // actually about to write a config — and tune embedding batches for it.
202
+ let detectedTuning;
203
+ const configContent = async () => {
204
+ if (!detectedTuning) {
205
+ try {
206
+ const info = await detectOllamaBackend();
207
+ detectedTuning = info.tuning;
208
+ const icon = info.backend === "gpu" ? c.success("GPU:") :
209
+ info.backend === "cpu" ? c.warn("CPU:") :
210
+ c.dim("Backend:");
211
+ console.log(` ${icon} ${info.message}`);
212
+ }
213
+ catch {
214
+ detectedTuning = undefined;
215
+ }
216
+ }
217
+ return generateDefaultConfigJson(detectedTuning);
218
+ };
192
219
  if (!configExists) {
193
- writeFileSync(configPath, generateDefaultConfigJson(), "utf-8");
220
+ writeFileSync(configPath, await configContent(), "utf-8");
194
221
  console.log(` ${c.created("Created:")} opencode-rag.json`);
195
222
  }
196
223
  else {
@@ -208,7 +235,7 @@ export function registerInitCommand(program) {
208
235
  if (overwrite) {
209
236
  copyFileSync(configPath, `${configPath}.bak`);
210
237
  console.log(` ${c.dim("Backup:")} opencode-rag.json.bak`);
211
- writeFileSync(configPath, generateDefaultConfigJson(), "utf-8");
238
+ writeFileSync(configPath, await configContent(), "utf-8");
212
239
  console.log(` ${c.updated("Updated:")} opencode-rag.json`);
213
240
  }
214
241
  else {
@@ -36,7 +36,13 @@ function checkOpenCodeRunning() {
36
36
  export function registerSetupCommand(program) {
37
37
  program
38
38
  .command("setup")
39
- .description("Set up the OpenCodeRAG runtime (~/.opencode/) for OpenCode plugin discovery")
39
+ .description("Install/update the OpenCodeRAG runtime once per machine")
40
+ .addHelpText("after", "\nUse cases:\n" +
41
+ " - First-time install: run once per machine to install the plugin runtime\n" +
42
+ " into ~/.opencode/ so OpenCode can discover the RAG plugin.\n" +
43
+ " - Updating: re-sync the runtime to the published plugin version.\n" +
44
+ " - Troubleshooting: use --check to inspect the runtime, or --force to reinstall.\n" +
45
+ "\nMachine-level step — run BEFORE 'opencode-rag init' (init configures each workspace).\n")
40
46
  .option("--uninstall", "remove the runtime and cleanup")
41
47
  .option("-f, --force", "force re-setup even if up-to-date")
42
48
  .option("--check", "check whether the runtime is correctly installed")
@@ -54,7 +54,18 @@ export interface DescriptionConfig {
54
54
  proxy?: ProxyConfig;
55
55
  /** System prompt instructing the LLM how to describe code. */
56
56
  systemPrompt: string;
57
- /** Maximum chunks per batch request. */
57
+ /**
58
+ * EXPERIMENTAL: enable multi-chunk batch description requests (Ollama
59
+ * provider only). Off by default — each chunk is described with its own
60
+ * request. When enabled, up to `batchMaxChunks` chunks share one request
61
+ * using ordinal labels ([CHUNK 1] ... reply "1: <desc>"); small models can
62
+ * mangle the structured output, so batches that fail to parse fall back to
63
+ * individual requests and batching auto-disables after 2 consecutive
64
+ * failures. Part of the description manifest fingerprint — toggling it
65
+ * re-describes files.
66
+ */
67
+ batchEnabled?: boolean;
68
+ /** Maximum chunks per batch request. Only applies when `batchEnabled` is true. */
58
69
  batchMaxChunks?: number;
59
70
  /** Timeout per batch request in milliseconds. */
60
71
  batchTimeoutMs?: number;
@@ -68,6 +79,8 @@ export interface DescriptionConfig {
68
79
  think?: boolean;
69
80
  /** Context window size for the LLM. */
70
81
  numCtx?: number;
82
+ /** Ollama keep_alive value (e.g. "-1" for keep-in-memory) sent with /api/chat requests. */
83
+ keepAlive?: string;
71
84
  /** Maximum content characters sent to the LLM. Chunks exceeding this use fallback descriptions. */
72
85
  maxContentChars?: number;
73
86
  }
@@ -91,6 +104,8 @@ export interface ImageDescriptionConfig {
91
104
  think?: boolean;
92
105
  /** Context window size. */
93
106
  numCtx?: number;
107
+ /** Ollama keep_alive value (e.g. "-1" for keep-in-memory) sent with /api/chat requests. */
108
+ keepAlive?: string;
94
109
  /** Proxy configuration. */
95
110
  proxy?: ProxyConfig;
96
111
  /** Maximum image dimension (pixels) — larger images are resized before sending. */
@@ -220,6 +235,8 @@ export interface RagConfig {
220
235
  queryPrefix?: string;
221
236
  /** Cached embedding vector dimension. Probed once on first startup, then persisted to config. */
222
237
  vectorDimension?: number;
238
+ /** Ollama keep_alive value (e.g. "-1" for keep-in-memory) sent with /api/embed requests. */
239
+ keepAlive?: string;
223
240
  };
224
241
  /** Indexing pipeline controls: what to index, concurrency, batch sizes. */
225
242
  indexing: {
@@ -268,6 +285,15 @@ export interface RagConfig {
268
285
  * @default 1_048_576 (1 MB)
269
286
  */
270
287
  maxSvgSizeBytes?: number;
288
+ /**
289
+ * Run vector-store compaction + version pruning every N windows during a
290
+ * long index pass. LanceDB keeps every committed version on disk, so
291
+ * without periodic maintenance the store phase slows down as the index
292
+ * grows (version-manifest accumulation). 0 disables mid-run optimization
293
+ * (the store is still optimized once at the end of a pass).
294
+ * @default 8
295
+ */
296
+ optimizeIntervalWindows?: number;
271
297
  };
272
298
  /** Vector storage backend configuration. */
273
299
  vectorStore: {
@@ -115,9 +115,10 @@ export const DEFAULT_CONFIG = {
115
115
  concurrency: 8,
116
116
  embedBatchSize: 100,
117
117
  embedConcurrency: 3,
118
- ollamaMaxBatchSize: 500,
118
+ ollamaMaxBatchSize: 100,
119
119
  descriptionConcurrency: 4,
120
120
  maxSvgSizeBytes: 1_048_576,
121
+ optimizeIntervalWindows: 8,
121
122
  },
122
123
  vectorStore: {
123
124
  path: "./.opencode/rag_db",
@@ -169,7 +170,10 @@ export const DEFAULT_CONFIG = {
169
170
  think: false,
170
171
  numCtx: 4096,
171
172
  timeoutMs: 60000,
172
- systemPrompt: "Describe this code in 2-3 sentences: purpose, key concepts, inputs/outputs, and dependencies. No code repetition.",
173
+ systemPrompt: "Describe this code in ONE concise sentence (max 20 words): purpose, key inputs/outputs. No code repetition.",
174
+ batchEnabled: false,
175
+ batchMaxChunks: 25,
176
+ batchTimeoutMs: 120000,
173
177
  batchConcurrency: 1,
174
178
  retryMax: 3,
175
179
  retryBaseDelayMs: 1000,
@@ -427,6 +431,10 @@ export function validateConfig(config) {
427
431
  if (config.description.timeoutMs != null && config.description.timeoutMs <= 0) {
428
432
  warnings.push("description.timeoutMs must be > 0");
429
433
  }
434
+ if (config.description.batchEnabled === true) {
435
+ warnings.push("description.batchEnabled is EXPERIMENTAL — batching several chunks into one LLM request (Ollama only) " +
436
+ "is unreliable on small models; disable it if descriptions look wrong");
437
+ }
430
438
  }
431
439
  if (config.imageDescription) {
432
440
  if (config.imageDescription.enabled) {
@@ -157,10 +157,31 @@ export interface FileSummary {
157
157
  language: string;
158
158
  chunkCount: number;
159
159
  }
160
+ /**
161
+ * A single file's chunk payload for a bulk store write.
162
+ * `dedup: true` removes prior-revision rows for the same file path that are
163
+ * not part of this write; `dedup: false` appends only (safe when writing into
164
+ * a freshly-created store where no rows can collide).
165
+ */
166
+ export interface BulkChunkWrite {
167
+ chunks: Chunk[];
168
+ dedup: boolean;
169
+ }
160
170
  /** Persistent vector storage and retrieval backend (LanceDB or in-memory). */
161
171
  export interface VectorStore {
162
- /** Store a batch of chunks with their embeddings. */
163
- addChunks(chunks: Chunk[]): Promise<void>;
172
+ /**
173
+ * Store a batch of chunks with their embeddings.
174
+ * @param options - `dedup: false` skips prior-revision cleanup (append-only).
175
+ */
176
+ addChunks(chunks: Chunk[], options?: {
177
+ dedup?: boolean;
178
+ }): Promise<void>;
179
+ /**
180
+ * Store chunks for multiple files in a single write transaction.
181
+ * Optional — stores without it fall back to per-file `addChunks` calls.
182
+ * @param items - Per-file chunk payloads with dedup flags.
183
+ */
184
+ addChunksBulk?(items: BulkChunkWrite[]): Promise<void>;
164
185
  /** Search for the top-K nearest neighbor chunks by embedding similarity. */
165
186
  search(embedding: number[], topK: number): Promise<SearchResult[]>;
166
187
  /** Search with optional metadata filtering. */
@@ -183,8 +204,15 @@ export interface VectorStore {
183
204
  getChunksByFilePath(filePath: string): Promise<Chunk[]>;
184
205
  /** Re-open the store, optionally pointing at a new database path. */
185
206
  reopen?(newPath?: string): Promise<void>;
186
- /** Compact fragments and prune old versions to prevent version-manifest accumulation. */
187
- optimize?(): Promise<void>;
207
+ /**
208
+ * Compact fragments and prune old versions to prevent version-manifest
209
+ * accumulation.
210
+ * @param options - `aggressive: true` prunes all but the current version
211
+ * (only safe when no other process reads the store, e.g. temp rebuilds).
212
+ */
213
+ optimize?(options?: {
214
+ aggressive?: boolean;
215
+ }): Promise<void>;
188
216
  /**
189
217
  * Verify that the store's data is actually readable.
190
218
  * Returns false if data integrity is compromised (e.g., data files missing from disk).
@@ -44,7 +44,7 @@ export function computeDescriptionConfigHash(config) {
44
44
  return undefined;
45
45
  const parts = [];
46
46
  if (desc) {
47
- parts.push(`desc:${desc.provider}|${desc.model}|${desc.baseUrl}|${desc.systemPrompt}`);
47
+ parts.push(`desc:${desc.provider}|${desc.model}|${desc.baseUrl}|${desc.systemPrompt}|batch:${desc.batchEnabled === true ? 1 : 0}`);
48
48
  }
49
49
  if (img) {
50
50
  parts.push(`img:${img.provider}|${img.model}|${img.baseUrl}|${img.prompt}`);
@@ -11,6 +11,10 @@ import type { DescriptionConfig } from "../core/config.js";
11
11
  */
12
12
  export declare class LlmDescriptionProvider implements DescriptionProvider {
13
13
  private readonly config;
14
+ /** Consecutive batches that needed individual fallback; disables batching past BATCH_MAX_STREAK. */
15
+ private batchFailStreak;
16
+ /** Whether multi-chunk batching is still active (adaptive, per provider instance / index run). */
17
+ private adaptiveBatchActive;
14
18
  /**
15
19
  * @param config - Configuration for the LLM provider, including base URL, model, API key, proxy, and retry settings.
16
20
  */
@@ -23,6 +27,22 @@ export declare class LlmDescriptionProvider implements DescriptionProvider {
23
27
  }): Promise<string>;
24
28
  /** @inheritdoc */
25
29
  generateBatchDescriptions(chunks: Chunk[], logger?: DescriptionLogger, opts?: BatchDescriptionOptions): Promise<Map<string, string>>;
30
+ /**
31
+ * Describe a group of chunks in a single LLM request and parse the response.
32
+ *
33
+ * Builds one chat request whose user message contains all chunks wrapped in
34
+ * `[CHUNK <n>]` markers and expects a `<n>: <description>` line per chunk.
35
+ * Labels are ordinals and are mapped back to chunks by position in the
36
+ * calling group; labels outside the group's range (hallucinations) are
37
+ * naturally dropped, prompting the caller to fall back to individual
38
+ * requests for the missing chunks.
39
+ *
40
+ * @param group - Chunks to describe in one request (length > 1)
41
+ * @param log - Logger for diagnostic messages
42
+ * @returns Map of ordinal label to description (may be partial or empty)
43
+ * @throws When the LLM request itself fails (caller falls back per chunk)
44
+ */
45
+ private batchDescribe;
26
46
  /**
27
47
  * Sends a chat completion request to the LLM API with retry and exponential backoff.
28
48
  * For Ollama, uses the `/api/chat` endpoint with streaming disabled; otherwise uses the standard `/v1/chat/completions` endpoint.