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
@@ -38,6 +38,8 @@ export interface IndexRunStats {
38
38
  }>;
39
39
  /** Number of files where description generation failed. */
40
40
  descriptionFailedFiles: number;
41
+ /** True when the pass was skipped because another pass holds the lock. */
42
+ skipped: boolean;
41
43
  }
42
44
  /** Summary of the current index health without running a full pass. */
43
45
  export interface IndexStatusSummary {
@@ -24,6 +24,7 @@ export function createIndexStats(totalFiles, manifestStatus) {
24
24
  extractionFailures: 0,
25
25
  extractionErrors: [],
26
26
  descriptionFailedFiles: 0,
27
+ skipped: false,
27
28
  };
28
29
  }
29
30
  //# sourceMappingURL=stats.js.map
@@ -120,9 +120,16 @@ export function createWatchIgnore(cwd, config, storePath) {
120
120
  const manifestPath = manifestPathFor(storePath);
121
121
  const dirMatcher = createExcludeMatcher(config.indexing.excludeDirs);
122
122
  const fileMatcher = createExcludeMatcher(config.indexing.excludeFiles ?? []);
123
+ // Prefix check with a trailing separator so sibling dirs like
124
+ // `<storePath>2` are NOT ignored; case-insensitive on win32 so a
125
+ // differently-cased store path cannot cause self-triggering watch loops.
126
+ const storePrefix = storePath.endsWith(path.sep) ? storePath : storePath + path.sep;
127
+ const win32 = process.platform === "win32";
123
128
  return (watchedPath) => {
124
129
  const resolved = path.resolve(watchedPath);
125
- if (resolved.startsWith(storePath))
130
+ const compareResolved = win32 ? resolved.toLowerCase() : resolved;
131
+ const compareStorePrefix = win32 ? storePrefix.toLowerCase() : storePrefix;
132
+ if (resolved === storePath || compareResolved.startsWith(compareStorePrefix))
126
133
  return true;
127
134
  if (resolved === manifestPath)
128
135
  return true;
@@ -128,6 +128,21 @@ export async function prepareFile(file, cwd, previous, config, keywordIndex, des
128
128
  },
129
129
  };
130
130
  }
131
+ // No chunks from non-empty content: file may genuinely be chunk-less.
132
+ // BUT if the content is empty and the file is unchanged (hash matches),
133
+ // this is the re-describe fast-path edge — keep the old index entry
134
+ // instead of deleting it (a delete would wipe previously-indexed chunks
135
+ // just because we lacked content to re-chunk).
136
+ if (previous && previous.hash === file.hash && file.content.trim().length === 0) {
137
+ return {
138
+ normalizedPath: file.normalizedPath, hash: file.hash, fileLabel,
139
+ isModified: false,
140
+ earlyResult: {
141
+ normalizedPath: file.normalizedPath, hash: file.hash, chunkCount: previous.chunkCount, fileLabel,
142
+ isNew: false, isModified: false, isUnchanged: true, isEmpty: false, isTooSmall: false, isRemoved: false, hadChunks: previous.chunkCount > 0,
143
+ },
144
+ };
145
+ }
131
146
  return {
132
147
  normalizedPath: file.normalizedPath, hash: file.hash, fileLabel,
133
148
  isModified: false,
@@ -138,6 +153,12 @@ export async function prepareFile(file, cwd, previous, config, keywordIndex, des
138
153
  };
139
154
  }
140
155
  logger.debug(` ${fileLabel}: ${chunks.length} chunks produced`);
156
+ // Remove stale keyword entries for a previously-indexed revision of this
157
+ // file (new UUIDs are generated for every chunk) so hybrid search never
158
+ // surfaces old content and the persisted index stops growing unboundedly.
159
+ if (previous) {
160
+ keywordIndex?.removeByFilePath(file.normalizedPath);
161
+ }
141
162
  keywordIndex?.addChunks(chunks);
142
163
  const docPrefix = config.embedding.documentPrefix ?? "";
143
164
  const relPath = path.relative(cwd, file.filePath).replace(/\\/g, "/");
package/dist/mcp/cli.js CHANGED
@@ -16,6 +16,10 @@ export async function runMcpServer(options) {
16
16
  }
17
17
  process.on("SIGINT", shutdown);
18
18
  process.on("SIGTERM", shutdown);
19
+ // If the parent MCP client disconnects, stdin closes — exit instead of
20
+ // becoming a zombie that holds the store open forever.
21
+ process.stdin.on("end", shutdown);
22
+ process.stdin.on("close", shutdown);
19
23
  await new Promise(() => { });
20
24
  }
21
25
  //# sourceMappingURL=cli.js.map
@@ -8,7 +8,7 @@ import { optimizeContext, DEFAULT_CONTEXT_OPTIMIZATION } from "../retriever/cont
8
8
  import { Parser } from "web-tree-sitter";
9
9
  import { initParser, loadLanguage, walkTree } from "../chunker/grammar.js";
10
10
  import { readFileSync } from "node:fs";
11
- import { resolve, isAbsolute, relative } from "node:path";
11
+ import { resolve, isAbsolute, relative, sep as pathSep } from "node:path";
12
12
  /**
13
13
  * Resolve a user-supplied file path against the worktree root, refusing to
14
14
  * escape the worktree. Absolute paths are accepted only if they already reside
@@ -23,7 +23,9 @@ export function resolveFilePath(filePath, worktree) {
23
23
  const rel = relative(root, candidate);
24
24
  if (rel === "")
25
25
  return root;
26
- if (rel.startsWith("..") || isAbsolute(rel)) {
26
+ // ".." or "../..." escapes; a sibling literally named "..foo" must NOT be
27
+ // rejected. isAbsolute(rel) covers cross-drive paths on Windows.
28
+ if (rel === ".." || rel.startsWith(`..${pathSep}`) || isAbsolute(rel)) {
27
29
  throw new Error(`Path "${filePath}" escapes worktree root "${root}" — access denied.`);
28
30
  }
29
31
  return candidate;
@@ -308,7 +310,10 @@ export async function handleFindUsages(params, embedder, store, cfg, keywordInde
308
310
  }
309
311
  for (let i = 0; i < lines.length; i++) {
310
312
  const line = lines[i];
311
- const lineNum = i + 1;
313
+ // tree-sitter line numbers are absolute file lines (startLine + offset),
314
+ // NOT chunk-relative — chunk-relative numbers were wrong for every
315
+ // chunk not starting at line 1 and broke the definition skip.
316
+ const lineNum = m.startLine + i;
312
317
  if (definitionLine !== undefined && lineNum === definitionLine)
313
318
  continue;
314
319
  const symbolPattern = new RegExp(`\\b${escapeRegex(symbolName)}\\b`);
@@ -323,7 +328,12 @@ export async function handleFindUsages(params, embedder, store, cfg, keywordInde
323
328
  fileGroups.set(m.filePath, group);
324
329
  }
325
330
  if (!group.usages.some((g) => g.line === lineNum)) {
326
- group.usages.push({ line: lineNum, content: line.trimEnd(), context: contextLines });
331
+ group.usages.push({
332
+ line: lineNum,
333
+ content: line.trimEnd(),
334
+ context: contextLines,
335
+ matchedIndex: i - ctxStart,
336
+ });
327
337
  }
328
338
  }
329
339
  }
@@ -346,7 +356,8 @@ export async function handleFindUsages(params, embedder, store, cfg, keywordInde
346
356
  formattedLines.push("|------|------|");
347
357
  for (const usage of group.usages) {
348
358
  const ctx = usage.context;
349
- const codeLine = ctx[Math.min(2, ctx.length - 1)] ?? usage.content;
359
+ // Show the actual matched line (not an arbitrary middle context line)
360
+ const codeLine = ctx[usage.matchedIndex] ?? usage.content;
350
361
  const escaped = codeLine.length > 100 ? codeLine.slice(0, 97) + "..." : codeLine;
351
362
  formattedLines.push(`| ${usage.line} | \`${escaped}\` |`);
352
363
  allMatches.push({ ...usage, filePath, language: group.language });
@@ -97,6 +97,9 @@ export async function createMcpServer(options) {
97
97
  await server.close();
98
98
  await ctx.store.close();
99
99
  ctx.keywordIndex.close();
100
+ // Release keep-alive sockets held by the embedder/description providers
101
+ const { destroyAllPooledConnections } = await import("../embedder/http.js");
102
+ destroyAllPooledConnections();
100
103
  },
101
104
  };
102
105
  }
@@ -59,7 +59,11 @@ export function createRagReadTool(options) {
59
59
  let rawResults;
60
60
  if (sessionID && sessionRetrievalCache) {
61
61
  const cached = sessionRetrievalCache.get(sessionID);
62
- if (cached && cached.messageText === messageText) {
62
+ // The cache key must include the FILE and line range: without
63
+ // them, the first read of a message reused its retrieval results
64
+ // for every later read of a DIFFERENT file in the same message.
65
+ const cacheKey = `${messageText}::${resolvedPath}::${normalized.startLine ?? ""}-${normalized.endLine ?? ""}`;
66
+ if (cached && cached.messageText === cacheKey) {
63
67
  rawResults = cached.rawResults;
64
68
  }
65
69
  else {
@@ -71,7 +75,7 @@ export function createRagReadTool(options) {
71
75
  if (oldest !== undefined)
72
76
  sessionRetrievalCache.delete(oldest);
73
77
  }
74
- sessionRetrievalCache.set(sessionID, { messageText, rawResults });
78
+ sessionRetrievalCache.set(sessionID, { messageText: cacheKey, rawResults });
75
79
  }
76
80
  }
77
81
  else {
@@ -121,9 +125,16 @@ export function createRagReadTool(options) {
121
125
  }
122
126
  catch (err) {
123
127
  const message = err instanceof Error ? err.message : String(err);
128
+ const code = err?.code;
129
+ // Distinguish path problems (ENOENT / outside workspace) from RAG
130
+ // retrieval failures — "OpenCodeRAG retrieval failed" was misleading
131
+ // for a simple missing file.
132
+ const isPathProblem = code === "ENOENT" || code === "EACCES" || /ENOENT|outside the workspace|does not exist/i.test(message);
124
133
  return {
125
134
  title: "Read",
126
- output: retrievalErrorMessage(message),
135
+ output: isPathProblem
136
+ ? `Could not read file: ${message}`
137
+ : retrievalErrorMessage(message),
127
138
  metadata: {
128
139
  tool: "read",
129
140
  filePath: resolvedPath,
@@ -2,6 +2,7 @@
2
2
  * @fileoverview Normalizes and validates read tool arguments, and resolves file paths relative to the workspace root.
3
3
  */
4
4
  import path from "node:path";
5
+ import { realpathSync } from "node:fs";
5
6
  /**
6
7
  * Normalize raw read tool arguments into a consistent internal shape.
7
8
  *
@@ -58,10 +59,31 @@ export function resolveWorkspacePath(worktree, inputPath) {
58
59
  : path.resolve(worktree, inputPath);
59
60
  const normalizedWorktree = path.resolve(worktree);
60
61
  const normalizedResolved = path.resolve(resolved);
61
- if (!normalizedResolved.startsWith(normalizedWorktree + path.sep) &&
62
+ // Case-insensitive on win32: an absolute path with different casing must
63
+ // not be falsely rejected as "outside the workspace".
64
+ const cmp = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
65
+ if (!cmp(normalizedResolved).startsWith(cmp(normalizedWorktree) + path.sep) &&
62
66
  normalizedResolved !== normalizedWorktree) {
63
67
  throw new Error(`read path "${inputPath}" resolves outside the workspace "${normalizedWorktree}"`);
64
68
  }
69
+ // Resolve symlinks/junctions: a link INSIDE the workspace pointing outside
70
+ // must not let the agent read arbitrary files (node_modules junctions are
71
+ // common on Windows). realpath failure (e.g. missing file) falls back to
72
+ // the lexical check above — the read itself will fail with ENOENT.
73
+ try {
74
+ const realResolved = realpathSync(normalizedResolved);
75
+ const realWorktree = realpathSync(normalizedWorktree);
76
+ if (!cmp(realResolved).startsWith(cmp(realWorktree) + path.sep) &&
77
+ realResolved !== realWorktree) {
78
+ throw new Error(`read path "${inputPath}" resolves outside the workspace "${normalizedWorktree}" (via symlink "${realResolved}")`);
79
+ }
80
+ }
81
+ catch (err) {
82
+ if (err instanceof Error && /outside the workspace/.test(err.message)) {
83
+ throw err;
84
+ }
85
+ // otherwise: realpath unavailable — keep the lexical result
86
+ }
65
87
  return toForwardSlash(normalizedResolved);
66
88
  }
67
89
  /**
package/dist/plugin.js CHANGED
@@ -18,7 +18,7 @@ import { createBackgroundIndexer } from "./watcher.js";
18
18
  import { createRagReadTool } from "./opencode/create-read-tool.js";
19
19
  import { createFileSkeletonTool, createFindUsagesTool, createDescribeImageTool, createRecallQuirksTool, createAddQuirkTool, createUpdateQuirkTool, createDeleteQuirkTool, } from "./opencode/tools.js";
20
20
  import { resolveApiKey } from "./core/resolve-api-key.js";
21
- import { consumePendingRagInjection } from "./core/rag-injection-flag.js";
21
+ import { consumePendingRagInjection, peekPendingRagInjection } from "./core/rag-injection-flag.js";
22
22
  import { loadDocProgress, markSubdirectoryDocumented } from "./core/doc-progress.js";
23
23
  import { loadManifest } from "./core/manifest.js";
24
24
  import { createSessionLogger } from "./eval/session-logger.js";
@@ -31,17 +31,16 @@ import { autoCaptureQuirks } from "./quirks/auto-capture.js";
31
31
  import { buildSystemGuidanceLines } from "./opencode/system-guidance.js";
32
32
  import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
33
33
  import path from "node:path";
34
- import { fileURLToPath } from "node:url";
35
- import { spawn, execSync } from "node:child_process";
36
- import { tmpdir } from "node:os";
37
34
  /** Maximum entries per session map before oldest entries are evicted. */
38
35
  const MAX_SESSION_MAP_SIZE = 50;
36
+ /** Maximum tool results kept per session (for quirk extraction). */
37
+ const MAX_SESSION_TOOL_RESULTS = 20;
39
38
  /** Cache of loaded RAG configurations keyed by workspace directory. */
40
39
  const configCache = new Map();
41
40
  /** Active background indexer instances keyed by workspace directory. */
42
41
  const backgroundIndexers = new Map();
43
- /** Active MCP server instances keyed by workspace directory. */
44
- const mcpServers = new Map();
42
+ /** Active vector stores keyed by workspace directory — closed on reload/shutdown. */
43
+ const ragStores = new Map();
45
44
  /** Pending update notifications keyed by workspace directory. */
46
45
  const pendingUpdateInfo = new Map();
47
46
  /** Workspace directories that have already been prompted about an update this session. */
@@ -50,7 +49,7 @@ const notifiedUpdateDirs = new Set();
50
49
  const pendingRestartNotice = new Map();
51
50
  /** Guard flag to prevent re-entrant shutdown. */
52
51
  let shutdownRegistered = false;
53
- /** Close all active background indexers and MCP servers, then destroy idle sockets. */
52
+ /** Close all active background indexers and vector stores, then destroy idle sockets. */
54
53
  async function shutdownPluginResources() {
55
54
  for (const [dir, indexer] of backgroundIndexers) {
56
55
  try {
@@ -59,12 +58,12 @@ async function shutdownPluginResources() {
59
58
  catch { /* best-effort */ }
60
59
  backgroundIndexers.delete(dir);
61
60
  }
62
- for (const [dir, server] of mcpServers) {
61
+ for (const [dir, store] of ragStores) {
63
62
  try {
64
- await server.close();
63
+ await store.close();
65
64
  }
66
65
  catch { /* best-effort */ }
67
- mcpServers.delete(dir);
66
+ ragStores.delete(dir);
68
67
  }
69
68
  configCache.clear();
70
69
  pendingUpdateInfo.clear();
@@ -766,7 +765,10 @@ export function createRagHooks(options) {
766
765
  try {
767
766
  const results = sessionToolResults.get(input.sessionID) ?? [];
768
767
  results.push({ tool: input.tool, output: output.output ?? "" });
769
- boundedSet(sessionToolResults, input.sessionID, results, MAX_SESSION_MAP_SIZE);
768
+ // Cap the per-session list — long sessions accumulate full tool
769
+ // outputs; only the recent entries matter for quirk extraction.
770
+ const capped = results.length > MAX_SESSION_TOOL_RESULTS ? results.slice(-MAX_SESSION_TOOL_RESULTS) : results;
771
+ boundedSet(sessionToolResults, input.sessionID, capped, MAX_SESSION_MAP_SIZE);
770
772
  }
771
773
  catch {
772
774
  // Non-critical — must never throw
@@ -825,9 +827,17 @@ export function createRagHooks(options) {
825
827
  if (query.length > 0) {
826
828
  const quirkDeps = { embedder, store, keywordIndex: keywordIndex, cfg: getEffectiveCfg(), storePath: options.storePath };
827
829
  const budgetMs = memCfg.autoInjectLatencyBudgetMs ?? 2000;
830
+ let timer;
828
831
  let quirkResults = await Promise.race([
829
- recallQuirks(quirkDeps, query, { topK: memCfg.autoInjectTopK ?? 2, minScore: memCfg.autoInjectMinScore }),
830
- new Promise((resolve) => setTimeout(() => resolve([]), budgetMs)),
832
+ recallQuirks(quirkDeps, query, { topK: memCfg.autoInjectTopK ?? 2, minScore: memCfg.autoInjectMinScore })
833
+ .then((r) => {
834
+ if (timer)
835
+ clearTimeout(timer);
836
+ return r;
837
+ }),
838
+ new Promise((resolve) => {
839
+ timer = setTimeout(() => resolve([]), budgetMs);
840
+ }),
831
841
  ]);
832
842
  // Dedup: skip quirks already injected into this session
833
843
  const injectedSet = getOrCreateSessionSet(sessionInjectedQuirks, sessionId, MAX_SESSION_MAP_SIZE);
@@ -894,8 +904,8 @@ export function createRagHooks(options) {
894
904
  }
895
905
  }
896
906
  }
897
- sessionPrevUserReq.set(input.sessionID, text);
898
- sessionToolResults.set(input.sessionID, []);
907
+ boundedSet(sessionPrevUserReq, input.sessionID, text, MAX_SESSION_MAP_SIZE);
908
+ boundedSet(sessionToolResults, input.sessionID, [], MAX_SESSION_MAP_SIZE);
899
909
  boundedSet(sessionLastMessage, input.sessionID, text, MAX_SESSION_MAP_SIZE);
900
910
  // Handle /doc slash command
901
911
  if (text.startsWith("/doc")) {
@@ -1123,9 +1133,17 @@ export function createRagHooks(options) {
1123
1133
  // Use the stricter recallMinScore for user-prompt injection
1124
1134
  // (system prompt injection uses the lower autoInjectMinScore)
1125
1135
  const minScore = quirkMemoryCfg.recallMinScore ?? 0.72;
1136
+ let timer;
1126
1137
  let quirkResults = await Promise.race([
1127
- recallQuirks(quirkDeps, quirkQuery, { topK: quirkMemoryCfg.autoInjectTopK ?? 2, minScore }),
1128
- new Promise((resolve) => setTimeout(() => resolve([]), budgetMs)),
1138
+ recallQuirks(quirkDeps, quirkQuery, { topK: quirkMemoryCfg.autoInjectTopK ?? 2, minScore })
1139
+ .then((r) => {
1140
+ if (timer)
1141
+ clearTimeout(timer);
1142
+ return r;
1143
+ }),
1144
+ new Promise((resolve) => {
1145
+ timer = setTimeout(() => resolve([]), budgetMs);
1146
+ }),
1129
1147
  ]);
1130
1148
  // Dedup: skip quirks already injected into this session
1131
1149
  const injectedSet = getOrCreateSessionSet(sessionInjectedQuirks, input.sessionID, MAX_SESSION_MAP_SIZE);
@@ -1168,15 +1186,19 @@ export function createRagHooks(options) {
1168
1186
  }
1169
1187
  }
1170
1188
  // Handle hotkey-triggered RAG injection (Ctrl+Enter / Ctrl+Alt+Enter)
1171
- const pendingInjection = consumePendingRagInjection(options.storePath);
1189
+ // Peek FIRST and only consume once we know the index is usable —
1190
+ // otherwise the flag is dropped on an empty index and the hotkey
1191
+ // press is silently lost.
1192
+ const pendingInjection = peekPendingRagInjection(options.storePath);
1172
1193
  if (pendingInjection) {
1194
+ const count = await store.count();
1195
+ if (count === 0)
1196
+ return;
1197
+ consumePendingRagInjection(options.storePath);
1173
1198
  appendDebugLog(options.logFilePath, {
1174
1199
  scope: "chat.message",
1175
1200
  message: `pending injection: ${pendingInjection}`,
1176
1201
  });
1177
- const count = await store.count();
1178
- if (count === 0)
1179
- return;
1180
1202
  const effectiveCfg = getEffectiveCfg();
1181
1203
  const hybridCfg = effectiveCfg.retrieval.hybridSearch;
1182
1204
  const retrievalStart = Date.now();
@@ -1274,122 +1296,12 @@ async function loadKeywordIndex(storePath, logFilePath, logLevel) {
1274
1296
  return new KeywordIndex(storePath);
1275
1297
  }
1276
1298
  }
1277
- let _cachedNodeExecutable;
1278
- /**
1279
- * Resolve the path to the Node.js executable, caching the result.
1280
- * Checks `process.execPath` first, then falls back to `where`/`which`.
1281
- */
1282
- function resolveNodeExecutable() {
1283
- if (_cachedNodeExecutable !== undefined)
1284
- return _cachedNodeExecutable;
1285
- const execPath = process.execPath;
1286
- const basename = path.basename(execPath).toLowerCase();
1287
- if (basename === "node" || basename === "node.exe") {
1288
- _cachedNodeExecutable = execPath;
1289
- return _cachedNodeExecutable;
1290
- }
1291
- const tryCmd = (cmd) => {
1292
- try {
1293
- const result = execSync(cmd, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
1294
- return result.split("\n")[0]?.trim() || null;
1295
- }
1296
- catch {
1297
- return null;
1298
- }
1299
- };
1300
- const found = process.platform === "win32"
1301
- ? tryCmd("where node")
1302
- : tryCmd("which node");
1303
- _cachedNodeExecutable = found;
1304
- return _cachedNodeExecutable;
1305
- }
1306
- /**
1307
- * Resolve the MCP CLI entry point script path.
1308
- * Checks `dist/cli.js` first, then falls back to `src/cli.ts`.
1309
- */
1310
- function resolveMcpCliEntry() {
1311
- const selfDir = path.dirname(fileURLToPath(import.meta.url));
1312
- const packageRoot = path.resolve(selfDir, "..");
1313
- const distCli = path.join(packageRoot, "dist", "cli.js");
1314
- if (existsSync(distCli))
1315
- return distCli;
1316
- const srcCli = path.join(packageRoot, "src", "cli.ts");
1317
- if (existsSync(srcCli))
1318
- return srcCli;
1319
- return null;
1320
- }
1321
- /**
1322
- * Start an MCP server process for the given workspace directory.
1323
- * Spawns the CLI in MCP mode as a child process and wires stdout/stderr
1324
- * to the debug log. Returns a handle for graceful shutdown.
1325
- */
1326
- function startMcpServerProcess(cwd, logFilePath, logLevel) {
1327
- const cliEntry = resolveMcpCliEntry();
1328
- if (!cliEntry) {
1329
- appendDebugLog(logFilePath, {
1330
- scope: "mcp",
1331
- message: "Could not resolve MCP CLI entry point; skipping autostart",
1332
- }, logLevel);
1333
- return { close: async () => { } };
1334
- }
1335
- const nodeExec = resolveNodeExecutable();
1336
- if (!nodeExec) {
1337
- appendDebugLog(logFilePath, {
1338
- scope: "mcp",
1339
- message: "Could not resolve Node.js executable; skipping MCP autostart",
1340
- }, logLevel);
1341
- return { close: async () => { } };
1342
- }
1343
- const args = cliEntry.endsWith(".ts")
1344
- ? ["--import", "tsx", cliEntry, "mcp"]
1345
- : [cliEntry, "mcp"];
1346
- appendDebugLog(logFilePath, {
1347
- scope: "mcp",
1348
- message: `Starting MCP server: ${nodeExec} ${args.join(" ")}`,
1349
- }, logLevel);
1350
- const child = spawn(nodeExec, args, {
1351
- cwd,
1352
- stdio: "pipe",
1353
- detached: false,
1354
- });
1355
- child.stdout?.on("data", (chunk) => {
1356
- appendDebugLog(logFilePath, {
1357
- scope: "mcp",
1358
- message: `stdout: ${chunk.toString().trim()}`,
1359
- }, logLevel);
1360
- });
1361
- child.stderr?.on("data", (chunk) => {
1362
- appendDebugLog(logFilePath, {
1363
- scope: "mcp",
1364
- message: `stderr: ${chunk.toString().trim()}`,
1365
- }, logLevel);
1366
- });
1367
- child.on("error", (err) => {
1368
- appendDebugLog(logFilePath, {
1369
- scope: "mcp",
1370
- message: "MCP server process error",
1371
- error: err,
1372
- }, logLevel);
1373
- });
1374
- child.on("exit", (code, signal) => {
1375
- appendDebugLog(logFilePath, {
1376
- scope: "mcp",
1377
- message: `MCP server exited (code=${code}, signal=${signal})`,
1378
- });
1379
- mcpServers.delete(cwd);
1380
- });
1381
- return {
1382
- close: async () => {
1383
- if (child.exitCode !== null || child.killed)
1384
- return;
1385
- appendDebugLog(logFilePath, {
1386
- scope: "mcp",
1387
- message: "Shutting down MCP server",
1388
- });
1389
- child.kill("SIGTERM");
1390
- },
1391
- };
1392
- }
1299
+ // NOTE: There is deliberately NO autostart of an MCP child process here.
1300
+ // A stdio MCP server can only be reached by the client that spawns it —
1301
+ // a plugin-spawned child with an unwired stdin would sit idle forever,
1302
+ // holding its own LanceDB connection (and locking the store directory on
1303
+ // Windows). External MCP clients must run `opencode-rag mcp` themselves;
1304
+ // the plugin's agent tools run in-process regardless.
1393
1305
  /**
1394
1306
  * OpenCodeRAG plugin factory — invoked by OpenCode's plugin system for each workspace.
1395
1307
  *
@@ -1444,20 +1356,22 @@ export const ragPlugin = async (input, _options) => {
1444
1356
  }
1445
1357
  backgroundIndexers.delete(input.directory);
1446
1358
  }
1447
- // Close existing MCP server for this directory if one exists (e.g. on plugin reload)
1448
- const existingMcp = mcpServers.get(input.directory);
1449
- if (existingMcp) {
1359
+ // Close the previous plugin instance's vector store without this every
1360
+ // reload leaks a LanceDB connection (and on Windows holds the store
1361
+ // directory open, breaking the atomic swap on `index --force`).
1362
+ const existingStore = ragStores.get(input.directory);
1363
+ if (existingStore) {
1450
1364
  try {
1451
- await existingMcp.close();
1365
+ await existingStore.close();
1452
1366
  }
1453
1367
  catch (err) {
1454
1368
  appendDebugLog(logFilePath, {
1455
1369
  scope: "plugin",
1456
- message: "Failed to close existing MCP server",
1370
+ message: "Failed to close existing vector store",
1457
1371
  error: err,
1458
1372
  }, logLevel);
1459
1373
  }
1460
- mcpServers.delete(input.directory);
1374
+ ragStores.delete(input.directory);
1461
1375
  }
1462
1376
  // Clean up stale config cache and pending update info for this directory
1463
1377
  configCache.delete(input.directory);
@@ -1508,6 +1422,7 @@ export const ragPlugin = async (input, _options) => {
1508
1422
  }
1509
1423
  }
1510
1424
  const store = createVectorStore(effectiveCfg, storePath, vectorDimension);
1425
+ ragStores.set(input.directory, store);
1511
1426
  // Load or create keyword index for hybrid search
1512
1427
  const keywordIndex = await loadKeywordIndex(storePath, logFilePath, logLevel);
1513
1428
  // Create description provider (enabled by default)
@@ -1555,15 +1470,14 @@ export const ragPlugin = async (input, _options) => {
1555
1470
  catch { /* ignore */ }
1556
1471
  }
1557
1472
  }
1558
- // Auto-start standalone MCP server if enabled (default: off). Agent tools
1559
- // (search_semantic, get_file_skeleton, find_usages, describe_image) and the
1560
- // chat.message hook run in-process regardless. Enable only when an external
1561
- // MCP client connects to `opencode-rag mcp`.
1562
- const mcpCfg = effectiveCfg.mcp ?? { enabled: false };
1563
- const isTempDir = path.resolve(input.directory).startsWith(tmpdir());
1564
- if (mcpCfg.enabled && !isTempDir) {
1565
- const mcpInstance = startMcpServerProcess(input.directory, logFilePath, logLevel);
1566
- mcpServers.set(input.directory, mcpInstance);
1473
+ // NOTE: no MCP child-process autostart a stdio MCP server is only
1474
+ // reachable by the client that spawns it. External MCP clients run
1475
+ // `opencode-rag mcp` themselves; agent tools run in-process regardless.
1476
+ if (effectiveCfg.mcp?.enabled) {
1477
+ appendDebugLog(logFilePath, {
1478
+ scope: "mcp",
1479
+ message: "mcp.enabled is set, but the plugin does not autostart a child server — run `opencode-rag mcp` from your MCP client",
1480
+ }, logLevel);
1567
1481
  }
1568
1482
  // Auto-update check (non-blocking, best-effort). On by default; can be
1569
1483
  // disabled via `autoUpdate.enabled: false` in opencode-rag.json. When a newer
@@ -21,6 +21,7 @@ function buildExchangeText(exchanges) {
21
21
  }
22
22
  return lines.join("\n");
23
23
  }
24
+ const VALID_QUIRK_TYPES = new Set(["gotcha", "preference", "decision", "environment-constraint"]);
24
25
  function parseExtractionOutput(text) {
25
26
  const results = [];
26
27
  for (const line of text.split("\n")) {
@@ -34,6 +35,10 @@ function parseExtractionOutput(text) {
34
35
  const content = trimmed.slice(pipeIdx + 1).trim();
35
36
  if (!quirkType || !content || content.length > 200)
36
37
  continue;
38
+ // An LLM emitting a garbage type would otherwise pollute recall filters —
39
+ // skip unknown types (or default them to "gotcha").
40
+ if (!VALID_QUIRK_TYPES.has(quirkType))
41
+ continue;
37
42
  results.push({ quirkType, content });
38
43
  }
39
44
  return results;
@@ -44,7 +44,7 @@ export declare function lexicalSimilarity(a: string, b: string): number;
44
44
  * Count of meaningful word tokens shared between two texts (Jaccard numerator).
45
45
  *
46
46
  * Tokens are whitespace/punctuation-split, lowercased, and filtered to those
47
- * with length `minTokenLen` (default 3 skips short filler like "the").
47
+ * with length ≥ `minTokenLen` (default 3 — skips short filler like "the").
48
48
  *
49
49
  * Used by the quirk auto-inject gate: candidate quirks that share no tokens
50
50
  * with the user's *current* message (i.e. they matched only against the prior