opencode-rag-plugin 1.19.4 → 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.
Files changed (87) 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/image.js +5 -0
  6. package/dist/chunker/pdf.js +30 -14
  7. package/dist/cli/commands/backend-detect.d.ts +61 -0
  8. package/dist/cli/commands/backend-detect.js +119 -0
  9. package/dist/cli/commands/index-command.js +11 -0
  10. package/dist/cli/commands/init-helpers.d.ts +4 -1
  11. package/dist/cli/commands/init-helpers.js +21 -4
  12. package/dist/cli/commands/init.js +61 -24
  13. package/dist/cli/commands/quirk.js +9 -3
  14. package/dist/cli/commands/setup.js +12 -3
  15. package/dist/cli/commands/status.js +14 -5
  16. package/dist/cli/commands/ui.js +23 -9
  17. package/dist/cli/commands/update.js +4 -5
  18. package/dist/cli/format.d.ts +5 -2
  19. package/dist/cli/format.js +14 -5
  20. package/dist/content/image.js +33 -11
  21. package/dist/content/reader.js +74 -13
  22. package/dist/core/bootstrap.js +10 -3
  23. package/dist/core/config.d.ts +27 -1
  24. package/dist/core/config.js +41 -2
  25. package/dist/core/desc-cache.d.ts +8 -2
  26. package/dist/core/desc-cache.js +10 -3
  27. package/dist/core/doc-progress.js +5 -2
  28. package/dist/core/interfaces.d.ts +32 -4
  29. package/dist/core/manifest.js +1 -1
  30. package/dist/core/provider-defaults.d.ts +2 -0
  31. package/dist/core/provider-defaults.js +19 -4
  32. package/dist/core/runtime-overrides.d.ts +0 -6
  33. package/dist/core/version-check.d.ts +5 -0
  34. package/dist/core/version-check.js +8 -2
  35. package/dist/describer/anthropic.d.ts +2 -2
  36. package/dist/describer/anthropic.js +19 -5
  37. package/dist/describer/describer.d.ts +20 -0
  38. package/dist/describer/describer.js +132 -16
  39. package/dist/describer/gemini.js +25 -10
  40. package/dist/describer/shared.d.ts +28 -0
  41. package/dist/describer/shared.js +60 -0
  42. package/dist/embedder/factory.d.ts +5 -3
  43. package/dist/embedder/factory.js +42 -9
  44. package/dist/embedder/health.js +19 -19
  45. package/dist/embedder/http.d.ts +14 -1
  46. package/dist/embedder/http.js +60 -6
  47. package/dist/embedder/ollama.d.ts +3 -1
  48. package/dist/embedder/ollama.js +12 -2
  49. package/dist/eval/session-logger.js +7 -0
  50. package/dist/eval/storage.js +8 -0
  51. package/dist/indexer/git-diff.d.ts +1 -1
  52. package/dist/indexer/git-diff.js +5 -1
  53. package/dist/indexer/pipeline.js +511 -346
  54. package/dist/indexer/stats.d.ts +2 -0
  55. package/dist/indexer/stats.js +1 -0
  56. package/dist/indexer/watch.js +8 -1
  57. package/dist/indexer/worker.js +21 -0
  58. package/dist/mcp/cli.js +4 -0
  59. package/dist/mcp/handlers.js +16 -5
  60. package/dist/mcp/server.js +3 -0
  61. package/dist/opencode/create-read-tool.js +14 -3
  62. package/dist/opencode/tool-args.js +23 -1
  63. package/dist/plugin.js +66 -152
  64. package/dist/quirks/auto-capture.js +5 -0
  65. package/dist/quirks/quirk-store.d.ts +1 -1
  66. package/dist/quirks/quirk-store.js +56 -17
  67. package/dist/retriever/context-optimizer.js +18 -4
  68. package/dist/retriever/keyword-index.d.ts +2 -0
  69. package/dist/retriever/keyword-index.js +38 -4
  70. package/dist/retriever/retriever.js +6 -1
  71. package/dist/tui.js +41 -4
  72. package/dist/vectorstore/lancedb.d.ts +104 -8
  73. package/dist/vectorstore/lancedb.js +345 -71
  74. package/dist/vectorstore/memory.d.ts +8 -3
  75. package/dist/vectorstore/memory.js +27 -4
  76. package/dist/watcher.d.ts +8 -0
  77. package/dist/watcher.js +237 -85
  78. package/dist/web/api.d.ts +5 -1
  79. package/dist/web/api.js +195 -69
  80. package/dist/web/server.d.ts +2 -0
  81. package/dist/web/server.js +66 -28
  82. package/dist/web/static.d.ts +5 -2
  83. package/dist/web/static.js +9 -5
  84. package/dist/web/ui/assets/index-BDPYdtA1.js +3 -0
  85. package/dist/web/ui/index.html +1 -1
  86. package/package.json +1 -1
  87. package/dist/web/ui/assets/index-CJBvt6e0.js +0 -3
@@ -11,12 +11,13 @@
11
11
  */
12
12
  import path from "node:path";
13
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from "node:fs";
14
- import { loadConfig } from "../../core/config.js";
14
+ import { loadConfig, findConfigFile } from "../../core/config.js";
15
15
  import { checkProviderHealth, pullOllamaModels } from "../../embedder/health.js";
16
16
  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")
@@ -37,7 +45,10 @@ export function registerInitCommand(program) {
37
45
  try {
38
46
  const cwd = process.cwd();
39
47
  const packageMetadata = getPackageMetadata();
40
- const configPath = path.join(cwd, "opencode-rag.json");
48
+ // Use findConfigFile so an existing config in .opencode/rag.json (or
49
+ // .opencode/opencode-rag.json) is respected instead of being shadowed
50
+ // by a new root-level opencode-rag.json.
51
+ const configPath = findConfigFile(cwd) ?? path.join(cwd, "opencode-rag.json");
41
52
  const opencodeDir = path.join(cwd, ".opencode");
42
53
  const gitignorePath = path.join(opencodeDir, ".gitignore");
43
54
  const opencodeConfigPath = path.join(opencodeDir, "opencode.json");
@@ -186,8 +197,27 @@ export function registerInitCommand(program) {
186
197
  console.log(` ${c.exists("Exists:")} .opencode/package.json`);
187
198
  }
188
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
+ };
189
219
  if (!configExists) {
190
- writeFileSync(configPath, generateDefaultConfigJson(), "utf-8");
220
+ writeFileSync(configPath, await configContent(), "utf-8");
191
221
  console.log(` ${c.created("Created:")} opencode-rag.json`);
192
222
  }
193
223
  else {
@@ -205,7 +235,7 @@ export function registerInitCommand(program) {
205
235
  if (overwrite) {
206
236
  copyFileSync(configPath, `${configPath}.bak`);
207
237
  console.log(` ${c.dim("Backup:")} opencode-rag.json.bak`);
208
- writeFileSync(configPath, generateDefaultConfigJson(), "utf-8");
238
+ writeFileSync(configPath, await configContent(), "utf-8");
209
239
  console.log(` ${c.updated("Updated:")} opencode-rag.json`);
210
240
  }
211
241
  else {
@@ -253,27 +283,34 @@ export function registerInitCommand(program) {
253
283
  return { model: r.model, baseUrl: ragConfig.embedding.baseUrl, proxy: ragConfig.embedding.proxy };
254
284
  });
255
285
  console.log(`\n ${c.warn("Models not found:")} ${pullEntries.map((e) => e.model).join(", ")}`);
256
- const readline = await import("node:readline");
257
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
258
- const answer = await new Promise((resolve) => {
259
- rl.question(` Pull ${pullEntries.length === 1 ? "this model" : "these models"} now? (y/n) `, resolve);
260
- });
261
- rl.close();
262
- if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
263
- console.log();
264
- try {
265
- await pullOllamaModels(pullEntries, (model, line) => {
266
- console.log(` ${c.value(model)}: ${line}`);
267
- });
268
- console.log(`\n ${c.success("Models pulled successfully.")}`);
269
- }
270
- catch (err) {
271
- console.error(`\n ${c.error("Pull failed:")} ${err.message}`);
272
- console.log(` ${c.dim("Pull manually with: ollama pull <model>")}`);
273
- }
286
+ // Non-TTY guard: rl.question would block forever with a still-open
287
+ // stdin pipe (e.g. `echo y | opencode-rag init`, CI runners).
288
+ if (!process.stdin.isTTY) {
289
+ console.log(` ${c.dim("Non-interactive shell skipping. Pull manually with: ollama pull <model>")}`);
274
290
  }
275
291
  else {
276
- console.log(` ${c.dim("Skipped. Pull manually with: ollama pull <model>")}`);
292
+ const readline = await import("node:readline");
293
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
294
+ const answer = await new Promise((resolve) => {
295
+ rl.question(` Pull ${pullEntries.length === 1 ? "this model" : "these models"} now? (y/n) `, resolve);
296
+ });
297
+ rl.close();
298
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
299
+ console.log();
300
+ try {
301
+ await pullOllamaModels(pullEntries, (model, line) => {
302
+ console.log(` ${c.value(model)}: ${line}`);
303
+ });
304
+ console.log(`\n ${c.success("Models pulled successfully.")}`);
305
+ }
306
+ catch (err) {
307
+ console.error(`\n ${c.error("Pull failed:")} ${err.message}`);
308
+ console.log(` ${c.dim("Pull manually with: ollama pull <model>")}`);
309
+ }
310
+ }
311
+ else {
312
+ console.log(` ${c.dim("Skipped. Pull manually with: ollama pull <model>")}`);
313
+ }
277
314
  }
278
315
  }
279
316
  const hasErrors = results.some((r) => r.status === "error");
@@ -16,6 +16,7 @@ export function registerQuirkCommand(program) {
16
16
  .option("-t, --type <type>", "quirk type: gotcha, preference, decision, environment-constraint")
17
17
  .option("--tag <tags...>", "tags for filtering")
18
18
  .option("--source-ref <path>", "source file path reference")
19
+ .option("-c, --config <path>", "path to config file")
19
20
  .action(async (content, options) => {
20
21
  try {
21
22
  const ctx = await resolveCliContext(options, resolveLogPath());
@@ -49,6 +50,11 @@ export function registerQuirkCommand(program) {
49
50
  .option("-c, --config <path>", "path to config file")
50
51
  .action(async (id, options) => {
51
52
  try {
53
+ const confidence = options.confidence;
54
+ if (confidence !== undefined && (Number.isNaN(confidence) || confidence < 0 || confidence > 1)) {
55
+ logCliError(resolveLogPath(), "quirk update", "Failed to update quirk: --confidence must be a number between 0 and 1");
56
+ process.exit(1);
57
+ }
52
58
  const ctx = await resolveCliContext(options, resolveLogPath());
53
59
  const { config, embedder, store, keywordIndex } = ctx;
54
60
  const tags = options.tag;
@@ -56,7 +62,7 @@ export function registerQuirkCommand(program) {
56
62
  content: options.content,
57
63
  quirkType: options.type,
58
64
  tags: tags ? (Array.isArray(tags) ? tags : [tags]) : undefined,
59
- confidence: options.confidence,
65
+ confidence,
60
66
  sourceRef: options.sourceRef,
61
67
  });
62
68
  logCliInfo(ctx.logFilePath, "quirk update", `\n${c.success("Quirk updated:")}`);
@@ -148,7 +154,7 @@ export function registerQuirkCommand(program) {
148
154
  const { config, embedder, store, keywordIndex } = ctx;
149
155
  const results = await recallQuirks({ embedder, store, keywordIndex: keywordIndex, cfg: config, storePath: ctx.storePath }, content, { topK: 5 });
150
156
  if (results.length > 0) {
151
- logCliInfo(ctx.logFilePath, "quirk test", c.success("\n Quirk has been appended:\n"));
157
+ logCliInfo(ctx.logFilePath, "quirk test", c.warn("\n Similar quirk(s) already exist — quirk has NOT been appended:\n"));
152
158
  for (const r of results) {
153
159
  const badge = r.chunk.metadata.quirkType ? `[${r.chunk.metadata.quirkType}] ` : "";
154
160
  const tags = r.chunk.metadata.tags?.length ? ` (${r.chunk.metadata.tags.join(", ")})` : "";
@@ -159,7 +165,7 @@ export function registerQuirkCommand(program) {
159
165
  }
160
166
  }
161
167
  else {
162
- logCliInfo(ctx.logFilePath, "quirk test", c.warn("\n No matching quirk found — quirk has not been appended\n"));
168
+ logCliInfo(ctx.logFilePath, "quirk test", c.success("\n No matching quirk found — safe to append\n"));
163
169
  }
164
170
  await cleanupContext(ctx);
165
171
  }
@@ -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")
@@ -81,9 +87,12 @@ export function registerSetupCommand(program) {
81
87
  if (options.uninstall) {
82
88
  console.log(`\n${c.heading("Removing OpenCodeRAG runtime...")}\n`);
83
89
  removeIfExists(runtimePluginDir);
84
- removeIfExists(runtimeSdkDir);
90
+ // Only remove the @opencode-ai/plugin SDK package — the scope dir may
91
+ // be shared with other OpenCode plugins/tools.
92
+ removeIfExists(runtimeSdkPluginDir);
85
93
  removeIfExists(versionFile);
86
- console.log(` ${c.updated("Removed:")} ${c.file(runtimeDir)}`);
94
+ console.log(` ${c.updated("Removed:")} ${c.file(runtimePluginDir)}`);
95
+ console.log(` ${c.updated("Removed:")} ${c.file(runtimeSdkPluginDir)}`);
87
96
  console.log(`\n ${c.success("Done.")} Run ${c.file("npm uninstall -g opencode-rag-plugin")} to remove the global package.\n`);
88
97
  return;
89
98
  }
@@ -147,14 +147,23 @@ export function registerStatusCommand(program) {
147
147
  logCliInfo(logFilePath, "status", `${c.label("Runtime:")} ${c.warn("version unknown — run `opencode-rag setup`")}`);
148
148
  }
149
149
  }
150
- // Async GitHub update check (fire-and-forget, 5s timeout).
151
- // Runs unless autoUpdate is explicitly disabled.
150
+ // GitHub update check. Runs unless autoUpdate is explicitly disabled.
151
+ // Awaited (raced against 3s) so the result can actually print —
152
+ // process.exit(0) below would previously kill the promise before it
153
+ // resolved, making the Update: line dead code.
152
154
  if (config.autoUpdate?.enabled) {
153
- checkForUpdate(pkg.version).then((info) => {
154
- if (info.updateAvailable) {
155
+ try {
156
+ const info = await Promise.race([
157
+ checkForUpdate(pkg.version),
158
+ new Promise((resolve) => setTimeout(() => resolve(null), 3000)),
159
+ ]);
160
+ if (info && info.updateAvailable) {
155
161
  process.stdout.write(` ${c.label("Update:")} ${c.warn(`v${info.latestVersion} available — run \`opencode-rag update\` to install`)}\n`);
156
162
  }
157
- }).catch(() => { });
163
+ }
164
+ catch {
165
+ /* ignore network errors */
166
+ }
158
167
  }
159
168
  // Force exit — avoid LanceDB close() hanging on Windows native bindings.
160
169
  // Status is read-only so there's no state to lose.
@@ -5,7 +5,7 @@
5
5
  * `ui` command — starts a local web UI for browsing the vector database.
6
6
  */
7
7
  import path from "node:path";
8
- import { c, resolveCliContext, logCliError, logCliInfo } from "../format.js";
8
+ import { c, resolveCliContext, cleanupContext, logCliError, logCliInfo } from "../format.js";
9
9
  /**
10
10
  * Register the `ui` command on the given Commander program.
11
11
  *
@@ -33,9 +33,10 @@ export function registerUiCommand(program) {
33
33
  const openBrowser = options.open !== false && (config.ui?.openBrowser ?? true);
34
34
  const { startWebUi } = await import("../../web/server.js");
35
35
  const server = await startWebUi(storePath, port, cwd, config.embedding.vectorDimension ?? 384, config);
36
- const url = `http://127.0.0.1:${server.port}`;
36
+ const url = `http://127.0.0.1:${server.port}/?token=${server.token}`;
37
37
  logCliInfo(logFilePath, "ui", `\n${c.heading("OpenCodeRAG Web UI")}`);
38
38
  logCliInfo(logFilePath, "ui", ` ${c.label("URL:")} ${c.value(url)}`);
39
+ logCliInfo(logFilePath, "ui", ` ${c.dim("The token in the URL authenticates this session — keep it private.")}`);
39
40
  logCliInfo(logFilePath, "ui", ` ${c.dim("Press Ctrl+C to stop")}\n`);
40
41
  if (openBrowser) {
41
42
  const { spawn } = await import("node:child_process");
@@ -52,14 +53,27 @@ export function registerUiCommand(program) {
52
53
  console.error(c.dim(`Could not open browser automatically. Open ${url} manually.`));
53
54
  }
54
55
  }
55
- process.on("SIGINT", async () => {
56
- await server.close();
57
- process.exit(0);
58
- });
59
- process.on("SIGTERM", async () => {
60
- await server.close();
56
+ let shuttingDown = false;
57
+ const shutdown = async () => {
58
+ if (shuttingDown)
59
+ return;
60
+ shuttingDown = true;
61
+ try {
62
+ await server.close();
63
+ }
64
+ catch {
65
+ // best-effort — the process is exiting anyway
66
+ }
67
+ try {
68
+ await cleanupContext(ctx);
69
+ }
70
+ catch {
71
+ // best-effort cleanup
72
+ }
61
73
  process.exit(0);
62
- });
74
+ };
75
+ process.on("SIGINT", shutdown);
76
+ process.on("SIGTERM", shutdown);
63
77
  }
64
78
  catch (err) {
65
79
  const message = err.message || String(err);
@@ -24,11 +24,10 @@ export function registerUpdateCommand(program) {
24
24
  console.log(`\n${c.heading("OpenCodeRAG Update")}\n`);
25
25
  console.log(` ${c.label("Current version:")} ${c.value(currentVersion)}`);
26
26
  console.log(` ${c.label("Checking...")} `);
27
- let info;
28
- try {
29
- info = await checkForUpdate(currentVersion);
30
- }
31
- catch {
27
+ // checkForUpdate never throws (all failures collapse to "no update"),
28
+ // so the try/catch below is defensive only.
29
+ const info = await checkForUpdate(currentVersion).catch(() => null);
30
+ if (!info) {
32
31
  console.log(`\n ${c.warn("Could not reach the update server. Check your network and try again.")}\n`);
33
32
  process.exit(1);
34
33
  return;
@@ -60,7 +60,7 @@ export declare const c: {
60
60
  * @param message - Human-readable error message.
61
61
  * @param error - Optional error object for structured logging.
62
62
  */
63
- export declare function logCliError(_logFilePath: string, _scope: string, message: string, _error: unknown): void;
63
+ export declare function logCliError(logFilePath: string, scope: string, message: string, error?: unknown): void;
64
64
  /**
65
65
  * Log an informational message to stdout and optionally append to the debug log.
66
66
  *
@@ -68,7 +68,7 @@ export declare function logCliError(_logFilePath: string, _scope: string, messag
68
68
  * @param scope - Logical scope (e.g. "index", "query") for log filtering.
69
69
  * @param message - Human-readable info message.
70
70
  */
71
- export declare function logCliInfo(_logFilePath: string, _scope: string, message: string): void;
71
+ export declare function logCliInfo(logFilePath: string, scope: string, message: string): void;
72
72
  /**
73
73
  * Resolve a full `RagContext` from CLI options and log the config details.
74
74
  *
@@ -82,6 +82,9 @@ export declare function resolveCliContext(opt: CliOptions, logFilePath: string,
82
82
  /**
83
83
  * Gracefully close a `RagContext` — closes the vector store and destroys pooled HTTP connections.
84
84
  *
85
+ * The store close is raced against a timeout because LanceDB's native `close()`
86
+ * can hang indefinitely on Windows; callers must never be blocked forever.
87
+ *
85
88
  * @param ctx - The `RagContext` to clean up.
86
89
  */
87
90
  export declare function cleanupContext(ctx: RagContext): Promise<void>;
@@ -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);