opencode-rag-plugin 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/ReadMe.md +6 -7
  2. package/dist/api.js +44 -24
  3. package/dist/chunker/base.d.ts +34 -3
  4. package/dist/chunker/base.js +70 -21
  5. package/dist/chunker/factory.d.ts +4 -1
  6. package/dist/chunker/factory.js +12 -1
  7. package/dist/chunker/grammar.js +3 -0
  8. package/dist/chunker/image.js +8 -8
  9. package/dist/chunker/pdf.js +11 -13
  10. package/dist/chunker/xml.d.ts +2 -0
  11. package/dist/chunker/xml.js +2 -0
  12. package/dist/cli/commands/index-command.js +0 -4
  13. package/dist/cli/commands/index.d.ts +1 -1
  14. package/dist/cli/commands/index.js +1 -1
  15. package/dist/cli/commands/init-helpers.d.ts +12 -0
  16. package/dist/cli/commands/init-helpers.js +84 -2
  17. package/dist/cli/commands/init.js +20 -2
  18. package/dist/cli/commands/query.js +1 -0
  19. package/dist/cli/commands/setup.d.ts +2 -0
  20. package/dist/cli/commands/setup.js +113 -0
  21. package/dist/cli/commands/status.js +64 -2
  22. package/dist/cli/index.js +2 -2
  23. package/dist/content/image.js +24 -3
  24. package/dist/content/reader.d.ts +6 -1
  25. package/dist/content/reader.js +49 -4
  26. package/dist/core/config.d.ts +19 -0
  27. package/dist/core/config.js +34 -4
  28. package/dist/core/desc-cache.d.ts +31 -0
  29. package/dist/core/desc-cache.js +124 -0
  30. package/dist/core/interfaces.d.ts +58 -3
  31. package/dist/core/manifest.d.ts +24 -1
  32. package/dist/core/manifest.js +34 -3
  33. package/dist/core/resolve-api-key.js +4 -2
  34. package/dist/core/runtime-overrides.js +9 -7
  35. package/dist/core/setup-runtime.d.ts +23 -0
  36. package/dist/core/setup-runtime.js +183 -0
  37. package/dist/core/version-check.d.ts +9 -0
  38. package/dist/core/version-check.js +49 -0
  39. package/dist/describer/anthropic.d.ts +2 -2
  40. package/dist/describer/anthropic.js +5 -7
  41. package/dist/describer/describer.d.ts +2 -2
  42. package/dist/describer/describer.js +6 -8
  43. package/dist/describer/gemini.d.ts +2 -2
  44. package/dist/describer/gemini.js +5 -7
  45. package/dist/embedder/factory.d.ts +3 -1
  46. package/dist/embedder/factory.js +7 -1
  47. package/dist/embedder/health.js +4 -0
  48. package/dist/embedder/http.d.ts +1 -1
  49. package/dist/embedder/http.js +74 -43
  50. package/dist/eval/compare-merge.d.ts +10 -0
  51. package/dist/eval/compare-merge.js +537 -0
  52. package/dist/eval/compare-rankings.d.ts +10 -0
  53. package/dist/eval/compare-rankings.js +245 -0
  54. package/dist/eval/dump-descriptions.d.ts +7 -0
  55. package/dist/eval/dump-descriptions.js +58 -0
  56. package/dist/eval/fast-index.d.ts +8 -0
  57. package/dist/eval/fast-index.js +283 -0
  58. package/dist/eval/run-branch-compare.d.ts +7 -0
  59. package/dist/eval/run-branch-compare.js +220 -0
  60. package/dist/eval/run-token-test.js +1 -0
  61. package/dist/eval/test-kw.d.ts +1 -0
  62. package/dist/eval/test-kw.js +22 -0
  63. package/dist/eval/update-descriptions.d.ts +7 -0
  64. package/dist/eval/update-descriptions.js +84 -0
  65. package/dist/index.d.ts +1 -0
  66. package/dist/index.js +1 -0
  67. package/dist/indexer/embed-stage.js +2 -1
  68. package/dist/indexer/git-diff.js +21 -9
  69. package/dist/indexer/pipeline.d.ts +6 -0
  70. package/dist/indexer/pipeline.js +290 -37
  71. package/dist/indexer/watch.js +1 -3
  72. package/dist/indexer/worker.d.ts +15 -2
  73. package/dist/indexer/worker.js +25 -12
  74. package/dist/mcp/handlers.d.ts +9 -0
  75. package/dist/mcp/handlers.js +23 -6
  76. package/dist/mcp/server.js +2 -0
  77. package/dist/opencode/create-read-tool.d.ts +2 -0
  78. package/dist/opencode/create-read-tool.js +8 -2
  79. package/dist/opencode/read-fallback.d.ts +1 -5
  80. package/dist/opencode/read-fallback.js +1 -18
  81. package/dist/opencode/read-format.js +5 -3
  82. package/dist/opencode/tools.js +5 -7
  83. package/dist/plugin.js +196 -81
  84. package/dist/retriever/keyword-index.d.ts +3 -2
  85. package/dist/retriever/keyword-index.js +25 -1
  86. package/dist/retriever/retriever.d.ts +4 -1
  87. package/dist/retriever/retriever.js +34 -56
  88. package/dist/vectorstore/lancedb.d.ts +35 -4
  89. package/dist/vectorstore/lancedb.js +146 -23
  90. package/dist/vectorstore/memory.d.ts +6 -1
  91. package/dist/vectorstore/memory.js +58 -0
  92. package/dist/watcher.js +3 -0
  93. package/dist/web/api.js +10 -2
  94. package/dist/web/server.js +18 -3
  95. package/package.json +8 -9
  96. package/scripts/postinstall-setup.js +82 -0
  97. package/dist/api.js.map +0 -1
  98. package/dist/chunker/base.js.map +0 -1
  99. package/dist/chunker/bash.js.map +0 -1
  100. package/dist/chunker/c.js.map +0 -1
  101. package/dist/chunker/cpp.js.map +0 -1
  102. package/dist/chunker/csharp.js.map +0 -1
  103. package/dist/chunker/css.js.map +0 -1
  104. package/dist/chunker/doc.js.map +0 -1
  105. package/dist/chunker/dockerfile.js.map +0 -1
  106. package/dist/chunker/docx.js.map +0 -1
  107. package/dist/chunker/excel.js.map +0 -1
  108. package/dist/chunker/factory.js.map +0 -1
  109. package/dist/chunker/fallback.js.map +0 -1
  110. package/dist/chunker/go.js.map +0 -1
  111. package/dist/chunker/grammar.js.map +0 -1
  112. package/dist/chunker/html.js.map +0 -1
  113. package/dist/chunker/image.js.map +0 -1
  114. package/dist/chunker/ini.js.map +0 -1
  115. package/dist/chunker/java.js.map +0 -1
  116. package/dist/chunker/javascript.js.map +0 -1
  117. package/dist/chunker/json.js.map +0 -1
  118. package/dist/chunker/kotlin.js.map +0 -1
  119. package/dist/chunker/loader.js.map +0 -1
  120. package/dist/chunker/markdown.js.map +0 -1
  121. package/dist/chunker/pdf.js.map +0 -1
  122. package/dist/chunker/php.js.map +0 -1
  123. package/dist/chunker/powershell.js.map +0 -1
  124. package/dist/chunker/python.js.map +0 -1
  125. package/dist/chunker/razor.js.map +0 -1
  126. package/dist/chunker/ruby.js.map +0 -1
  127. package/dist/chunker/rust.js.map +0 -1
  128. package/dist/chunker/sln.js.map +0 -1
  129. package/dist/chunker/sql.js.map +0 -1
  130. package/dist/chunker/ssl.js.map +0 -1
  131. package/dist/chunker/swift.js.map +0 -1
  132. package/dist/chunker/tex.js.map +0 -1
  133. package/dist/chunker/toml.js.map +0 -1
  134. package/dist/chunker/typescript.js.map +0 -1
  135. package/dist/chunker/uuid.js.map +0 -1
  136. package/dist/chunker/xml.js.map +0 -1
  137. package/dist/chunker/yaml.js.map +0 -1
  138. package/dist/cli/commands/clear.js.map +0 -1
  139. package/dist/cli/commands/describe-image.js.map +0 -1
  140. package/dist/cli/commands/dump.js.map +0 -1
  141. package/dist/cli/commands/eval.js.map +0 -1
  142. package/dist/cli/commands/index-command.js.map +0 -1
  143. package/dist/cli/commands/index.js.map +0 -1
  144. package/dist/cli/commands/init-helpers.js.map +0 -1
  145. package/dist/cli/commands/init.js.map +0 -1
  146. package/dist/cli/commands/list.js.map +0 -1
  147. package/dist/cli/commands/mcp.js.map +0 -1
  148. package/dist/cli/commands/query.js.map +0 -1
  149. package/dist/cli/commands/show.js.map +0 -1
  150. package/dist/cli/commands/status.js.map +0 -1
  151. package/dist/cli/commands/ui.js.map +0 -1
  152. package/dist/cli/commands/update.d.ts +0 -17
  153. package/dist/cli/commands/update.js +0 -79
  154. package/dist/cli/commands/update.js.map +0 -1
  155. package/dist/cli/format.js.map +0 -1
  156. package/dist/cli/helpers.js.map +0 -1
  157. package/dist/cli/index.js.map +0 -1
  158. package/dist/cli/progress.d.ts +0 -42
  159. package/dist/cli/progress.js +0 -137
  160. package/dist/cli/progress.js.map +0 -1
  161. package/dist/cli/types.js.map +0 -1
  162. package/dist/cli.js.map +0 -1
  163. package/dist/content/doc.js.map +0 -1
  164. package/dist/content/docx.js.map +0 -1
  165. package/dist/content/excel.js.map +0 -1
  166. package/dist/content/image.js.map +0 -1
  167. package/dist/content/pdf.js.map +0 -1
  168. package/dist/content/reader.js.map +0 -1
  169. package/dist/content/types.js.map +0 -1
  170. package/dist/core/bootstrap.js.map +0 -1
  171. package/dist/core/config.js.map +0 -1
  172. package/dist/core/doc-progress.js.map +0 -1
  173. package/dist/core/fileLogger.js.map +0 -1
  174. package/dist/core/interfaces.js.map +0 -1
  175. package/dist/core/manifest.js.map +0 -1
  176. package/dist/core/provider-defaults.js.map +0 -1
  177. package/dist/core/rag-injection-flag.js.map +0 -1
  178. package/dist/core/resolve-api-key.js.map +0 -1
  179. package/dist/core/runtime-overrides.js.map +0 -1
  180. package/dist/describer/anthropic.js.map +0 -1
  181. package/dist/describer/describer.js.map +0 -1
  182. package/dist/describer/factory.js.map +0 -1
  183. package/dist/describer/gemini.js.map +0 -1
  184. package/dist/describer/shared.js.map +0 -1
  185. package/dist/embedder/cohere.js.map +0 -1
  186. package/dist/embedder/factory.js.map +0 -1
  187. package/dist/embedder/health.js.map +0 -1
  188. package/dist/embedder/http.js.map +0 -1
  189. package/dist/embedder/ollama.js.map +0 -1
  190. package/dist/embedder/openai.js.map +0 -1
  191. package/dist/eval/index.js.map +0 -1
  192. package/dist/eval/run-token-test.js.map +0 -1
  193. package/dist/eval/session-logger.js.map +0 -1
  194. package/dist/eval/storage.js.map +0 -1
  195. package/dist/eval/token-analysis.js.map +0 -1
  196. package/dist/eval/token-counter.js.map +0 -1
  197. package/dist/eval/types.js.map +0 -1
  198. package/dist/index.js.map +0 -1
  199. package/dist/indexer/description-stage.js.map +0 -1
  200. package/dist/indexer/embed-stage.js.map +0 -1
  201. package/dist/indexer/git-diff.js.map +0 -1
  202. package/dist/indexer/metadata.js.map +0 -1
  203. package/dist/indexer/pipeline.js.map +0 -1
  204. package/dist/indexer/stats.js.map +0 -1
  205. package/dist/indexer/watch.js.map +0 -1
  206. package/dist/indexer/worker.js.map +0 -1
  207. package/dist/indexer.js.map +0 -1
  208. package/dist/mcp/cli.js.map +0 -1
  209. package/dist/mcp/handlers.js.map +0 -1
  210. package/dist/mcp/server.js.map +0 -1
  211. package/dist/opencode/create-read-tool.js.map +0 -1
  212. package/dist/opencode/read-fallback.js.map +0 -1
  213. package/dist/opencode/read-format.js.map +0 -1
  214. package/dist/opencode/read-query.js.map +0 -1
  215. package/dist/opencode/tool-args.js.map +0 -1
  216. package/dist/opencode/tools.js.map +0 -1
  217. package/dist/plugin-entry.js.map +0 -1
  218. package/dist/plugin.js.map +0 -1
  219. package/dist/retriever/context-optimizer.js.map +0 -1
  220. package/dist/retriever/keyword-index.js.map +0 -1
  221. package/dist/retriever/retriever.js.map +0 -1
  222. package/dist/tui.js.map +0 -1
  223. package/dist/updater.d.ts +0 -45
  224. package/dist/updater.js +0 -175
  225. package/dist/updater.js.map +0 -1
  226. package/dist/vectorstore/factory.js.map +0 -1
  227. package/dist/vectorstore/lancedb.js.map +0 -1
  228. package/dist/vectorstore/memory.js.map +0 -1
  229. package/dist/watcher.js.map +0 -1
  230. package/dist/web/api.js.map +0 -1
  231. package/dist/web/server.js.map +0 -1
  232. package/dist/web/static.js.map +0 -1
@@ -4,7 +4,26 @@ import { optimizeContext, DEFAULT_CONTEXT_OPTIMIZATION } from "../retriever/cont
4
4
  import { Parser } from "web-tree-sitter";
5
5
  import { initParser, loadLanguage, walkTree } from "../chunker/grammar.js";
6
6
  import { readFileSync } from "node:fs";
7
- import { resolve, isAbsolute } from "node:path";
7
+ import { resolve, isAbsolute, relative } from "node:path";
8
+ /**
9
+ * Resolve a user-supplied file path against the worktree root, refusing to
10
+ * escape the worktree. Absolute paths are accepted only if they already reside
11
+ * under the worktree; relative paths are joined to the worktree and any `..`
12
+ * segments that would escape are rejected.
13
+ *
14
+ * @throws {Error} if the resolved path lies outside `worktree`.
15
+ */
16
+ export function resolveFilePath(filePath, worktree) {
17
+ const root = resolve(worktree);
18
+ const candidate = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
19
+ const rel = relative(root, candidate);
20
+ if (rel === "")
21
+ return root;
22
+ if (rel.startsWith("..") || isAbsolute(rel)) {
23
+ throw new Error(`Path "${filePath}" escapes worktree root "${root}" — access denied.`);
24
+ }
25
+ return candidate;
26
+ }
8
27
  const SKELETON_CONFIGS = {
9
28
  ".ts": { grammarName: "typescript", nodeTypes: ["function_declaration", "method_definition", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration"] },
10
29
  ".tsx": { grammarName: "typescript", nodeTypes: ["function_declaration", "method_definition", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration", "arrow_function"] },
@@ -45,11 +64,6 @@ function getExtension(filePath) {
45
64
  const dot = filePath.lastIndexOf(".");
46
65
  return dot >= 0 ? filePath.slice(dot).toLowerCase() : "";
47
66
  }
48
- function resolveFilePath(filePath, worktree) {
49
- if (isAbsolute(filePath))
50
- return filePath;
51
- return resolve(worktree, filePath);
52
- }
53
67
  async function extractSkeleton(content, ext) {
54
68
  const config = SKELETON_CONFIGS[ext];
55
69
  if (!config) {
@@ -86,6 +100,8 @@ async function extractSkeleton(content, ext) {
86
100
  return [];
87
101
  const nodeTypes = new Set(config.nodeTypes);
88
102
  const astNodes = walkTree(tree.rootNode, nodeTypes, content, 15);
103
+ tree.delete();
104
+ parser.delete();
89
105
  return astNodes.map((node) => ({
90
106
  type: node.type,
91
107
  name: extractNodeName(node.text, node.type),
@@ -160,6 +176,7 @@ export async function handleSearchSemantic(params, embedder, store, cfg, keyword
160
176
  minScore: cfg.retrieval.minScore,
161
177
  keywordIndex,
162
178
  keywordWeight: cfg.retrieval.hybridSearch?.keywordWeight,
179
+ hybridEnabled: cfg.retrieval.hybridSearch?.enabled,
163
180
  queryPrefix: cfg.embedding.queryPrefix,
164
181
  };
165
182
  const rawResults = await retrieveFn_(query, embedder, store, retrieveOpts);
@@ -95,6 +95,8 @@ export async function createMcpServer(options) {
95
95
  server,
96
96
  close: async () => {
97
97
  await server.close();
98
+ await ctx.store.close();
99
+ ctx.keywordIndex.close();
98
100
  },
99
101
  };
100
102
  }
@@ -24,6 +24,8 @@ export interface RagReadToolOptions {
24
24
  }>;
25
25
  /** Optional keyword index for hybrid search. */
26
26
  keywordIndex?: KeywordIndex;
27
+ /** Maximum number of sessions to retain in the retrieval cache. */
28
+ maxSessionCacheSize?: number;
27
29
  }
28
30
  /**
29
31
  * Create the RAG-backed read tool for OpenCode plugin registration.
@@ -63,7 +63,13 @@ export function createRagReadTool(options) {
63
63
  }
64
64
  else {
65
65
  const retrievalQuery = buildSessionQuery(messageText, resolvedPath, normalized);
66
- rawResults = await retrieve(retrievalQuery, embedder, store, { topK: retrievalTopK, keywordIndex, queryPrefix: config.embedding.queryPrefix });
66
+ rawResults = await retrieve(retrievalQuery, embedder, store, { topK: retrievalTopK, keywordIndex, hybridEnabled: config.retrieval.hybridSearch?.enabled, queryPrefix: config.embedding.queryPrefix });
67
+ const maxSize = options.maxSessionCacheSize ?? 50;
68
+ if (!sessionRetrievalCache.has(sessionID) && sessionRetrievalCache.size >= maxSize) {
69
+ const oldest = sessionRetrievalCache.keys().next().value;
70
+ if (oldest !== undefined)
71
+ sessionRetrievalCache.delete(oldest);
72
+ }
67
73
  sessionRetrievalCache.set(sessionID, { messageText, rawResults });
68
74
  }
69
75
  }
@@ -74,7 +80,7 @@ export function createRagReadTool(options) {
74
80
  startLine: normalized.startLine,
75
81
  endLine: normalized.endLine,
76
82
  });
77
- rawResults = await retrieve(retrievalQuery, embedder, store, { topK: retrievalTopK, keywordIndex, queryPrefix: config.embedding.queryPrefix });
83
+ rawResults = await retrieve(retrievalQuery, embedder, store, { topK: retrievalTopK, keywordIndex, hybridEnabled: config.retrieval.hybridSearch?.enabled, queryPrefix: config.embedding.queryPrefix });
78
84
  }
79
85
  // Collect related files from raw results (before filtering)
80
86
  relatedFiles = collectRelatedFiles(rawResults, resolvedPath, readRelatedFilesMax);
@@ -1,12 +1,8 @@
1
1
  /**
2
2
  * @fileoverview Provides fallback error messages and no-results behavior dispatch for the read tool.
3
3
  */
4
- import type { ReadNoResultsBehavior } from "../core/config.js";
5
4
  /**
6
5
  * Error message wrapper for retrieval failures.
7
6
  */
8
7
  export declare function retrievalErrorMessage(shortError: string): string;
9
- /**
10
- * Dispatch to the correct fallback message based on behavior config.
11
- */
12
- export declare function getNoResultsMessage(behavior: ReadNoResultsBehavior, filePath?: string): string;
8
+ /** No results fallback messages are handled inline in create-read-tool.ts. */
@@ -12,22 +12,5 @@ export function retrievalErrorMessage(shortError) {
12
12
  shortError,
13
13
  ].join("\n");
14
14
  }
15
- /**
16
- * Dispatch to the correct fallback message based on behavior config.
17
- */
18
- export function getNoResultsMessage(behavior, filePath) {
19
- switch (behavior) {
20
- case "error":
21
- throw new Error("OpenCodeRAG read: no indexed chunks found." +
22
- (filePath ? ` File: ${filePath}` : ""));
23
- case "empty":
24
- return "No indexed chunks found.";
25
- case "hint":
26
- default:
27
- if (filePath) {
28
- return `No indexed chunks found for ${filePath}.`;
29
- }
30
- return "No relevant chunks found.";
31
- }
32
- }
15
+ /** No results fallback messages are handled inline in create-read-tool.ts. */
33
16
  //# sourceMappingURL=read-fallback.js.map
@@ -124,9 +124,11 @@ function formatChunk(index, result) {
124
124
  lines.push(`Score: ${score.toFixed(4)}`);
125
125
  lines.push("");
126
126
  lines.push("```" + language);
127
- lines.push(chunk.content);
128
- if (!chunk.content.endsWith("\n")) {
129
- // Ensure code block closes on its own line
127
+ if (chunk.content.endsWith("\n")) {
128
+ lines.push(chunk.content.slice(0, -1));
129
+ }
130
+ else {
131
+ lines.push(chunk.content);
130
132
  }
131
133
  lines.push("```");
132
134
  return lines.join("\n");
@@ -19,7 +19,7 @@ import { retrieve } from "../retriever/retriever.js";
19
19
  import { Parser } from "web-tree-sitter";
20
20
  import { initParser, loadLanguage, walkTree } from "../chunker/grammar.js";
21
21
  import { readFileSync } from "node:fs";
22
- import { resolve, isAbsolute } from "node:path";
22
+ import { resolveWorkspacePath } from "./tool-args.js";
23
23
  const SKELETON_CONFIGS = {
24
24
  ".ts": { grammarName: "typescript", nodeTypes: ["function_declaration", "method_definition", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration"] },
25
25
  ".tsx": { grammarName: "typescript", nodeTypes: ["function_declaration", "method_definition", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration", "arrow_function"] },
@@ -68,9 +68,7 @@ function getExtension(filePath) {
68
68
  * Resolve a file path relative to the workspace root.
69
69
  */
70
70
  function resolveFilePath(filePath, worktree) {
71
- if (isAbsolute(filePath))
72
- return filePath;
73
- return resolve(worktree, filePath);
71
+ return resolveWorkspacePath(worktree, filePath);
74
72
  }
75
73
  /**
76
74
  * Extract structural outline from source code using tree-sitter.
@@ -116,6 +114,8 @@ async function extractSkeleton(content, ext) {
116
114
  return [];
117
115
  const nodeTypes = new Set(config.nodeTypes);
118
116
  const astNodes = walkTree(tree.rootNode, nodeTypes, content, 15);
117
+ tree.delete();
118
+ parser.delete();
119
119
  return astNodes.map((node) => ({
120
120
  type: node.type,
121
121
  name: extractNodeName(node.text, node.type),
@@ -262,9 +262,7 @@ export function createDescribeImageTool(options) {
262
262
  try {
263
263
  const { existsSync, readFileSync } = await import("node:fs");
264
264
  const path = await import("node:path");
265
- const resolvedPath = isAbsolute(args.filePath)
266
- ? args.filePath
267
- : resolve(worktree, args.filePath);
265
+ const resolvedPath = resolveWorkspacePath(worktree, args.filePath);
268
266
  if (!existsSync(resolvedPath)) {
269
267
  return {
270
268
  title: "Describe image",
package/dist/plugin.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * automatic context injection and documentation mode.
5
5
  */
6
6
  import { tool } from "@opencode-ai/plugin/tool";
7
- import { loadConfig, findConfigFile, DEFAULT_CONFIG, resolveLogConfig } from "./core/config.js";
7
+ import { loadConfig, findConfigFile, DEFAULT_CONFIG, resolveLogConfig, persistProbedDimension } from "./core/config.js";
8
8
  import { createEmbedder } from "./embedder/factory.js";
9
9
  import { createDescriptionProvider } from "./describer/factory.js";
10
10
  import { createVectorStore } from "./vectorstore/factory.js";
@@ -18,17 +18,19 @@ import { createRagReadTool } from "./opencode/create-read-tool.js";
18
18
  import { createFileSkeletonTool, createFindUsagesTool, createDescribeImageTool, } from "./opencode/tools.js";
19
19
  import { resolveApiKey } from "./core/resolve-api-key.js";
20
20
  import { consumePendingRagInjection } from "./core/rag-injection-flag.js";
21
- import { uuid } from "./chunker/uuid.js";
22
21
  import { loadDocProgress, markSubdirectoryDocumented } from "./core/doc-progress.js";
23
22
  import { loadManifest } from "./core/manifest.js";
24
23
  import { createSessionLogger } from "./eval/session-logger.js";
25
24
  import { countTokens } from "./eval/token-counter.js";
26
- import { checkForUpdate } from "./updater.js";
25
+ import { checkForUpdate } from "./core/version-check.js";
26
+ import { destroyAllPooledConnections } from "./embedder/http.js";
27
27
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
28
28
  import path from "node:path";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import { spawn, execSync } from "node:child_process";
31
31
  import { tmpdir } from "node:os";
32
+ /** Maximum entries per session map before oldest entries are evicted. */
33
+ const MAX_SESSION_MAP_SIZE = 50;
32
34
  /** Cache of loaded RAG configurations keyed by workspace directory. */
33
35
  const configCache = new Map();
34
36
  /** Active background indexer instances keyed by workspace directory. */
@@ -37,6 +39,52 @@ const backgroundIndexers = new Map();
37
39
  const mcpServers = new Map();
38
40
  /** Pending update notifications keyed by workspace directory. */
39
41
  const pendingUpdateInfo = new Map();
42
+ /** Guard flag to prevent re-entrant shutdown. */
43
+ let shutdownRegistered = false;
44
+ /** Close all active background indexers and MCP servers, then destroy idle sockets. */
45
+ async function shutdownPluginResources() {
46
+ for (const [dir, indexer] of backgroundIndexers) {
47
+ try {
48
+ await indexer.close();
49
+ }
50
+ catch { /* best-effort */ }
51
+ backgroundIndexers.delete(dir);
52
+ }
53
+ for (const [dir, server] of mcpServers) {
54
+ try {
55
+ await server.close();
56
+ }
57
+ catch { /* best-effort */ }
58
+ mcpServers.delete(dir);
59
+ }
60
+ configCache.clear();
61
+ pendingUpdateInfo.clear();
62
+ destroyAllPooledConnections();
63
+ }
64
+ /** Register a process.beforeExit handler so that resources are cleaned up
65
+ * when the OpenCode process exits (rather than only on plugin reload). */
66
+ function registerShutdownHandler() {
67
+ if (shutdownRegistered)
68
+ return;
69
+ shutdownRegistered = true;
70
+ process.once("beforeExit", () => {
71
+ void shutdownPluginResources();
72
+ });
73
+ }
74
+ registerShutdownHandler();
75
+ /** Set a bounded Map entry, evicting the oldest key if the map exceeds maximum size. */
76
+ function boundedSet(map, key, value, maxSize) {
77
+ if (map.has(key)) {
78
+ map.set(key, value);
79
+ return;
80
+ }
81
+ if (map.size >= maxSize) {
82
+ const oldest = map.keys().next().value;
83
+ if (oldest !== undefined)
84
+ map.delete(oldest);
85
+ }
86
+ map.set(key, value);
87
+ }
40
88
  /** Name of the semantic search tool as exposed to the LLM. */
41
89
  const CONTEXT_TOOL_NAME = "search_semantic";
42
90
  /** Marker string injected into context output to identify RAG-sourced content. */
@@ -231,10 +279,10 @@ function buildRetrievalQuery(hints) {
231
279
  * Perform a retrieval query against the vector store with the given parameters.
232
280
  * Returns an empty array for blank queries.
233
281
  */
234
- async function retrieveContext(query, embedder, store, topK, retrieveFn = retrieve, minScore = 0, keywordIndex, keywordWeight, queryPrefix, explain = false) {
282
+ async function retrieveContext(query, embedder, store, topK, retrieveFn = retrieve, minScore = 0, keywordIndex, keywordWeight, queryPrefix, explain = false, hybridEnabled) {
235
283
  if (query.trim().length === 0)
236
284
  return [];
237
- return retrieveFn(query, embedder, store, { topK, minScore, keywordIndex, keywordWeight, queryPrefix, explain });
285
+ return retrieveFn(query, embedder, store, { topK, minScore, keywordIndex, keywordWeight, hybridEnabled, queryPrefix, explain });
238
286
  }
239
287
  /**
240
288
  * Load results from one or two queries (primary + optional extra), optimize
@@ -244,9 +292,10 @@ async function retrieveContext(query, embedder, store, topK, retrieveFn = retrie
244
292
  async function loadRetrievedResults(query, embedder, store, cfg, retrieveFn = retrieve, topK = cfg.retrieval.topK, extraQuery, keywordIndex, queryPrefix, explain = false) {
245
293
  const minScore = cfg.retrieval.minScore;
246
294
  const kw = cfg.retrieval.hybridSearch?.keywordWeight;
247
- const primaryResults = await retrieveContext(query, embedder, store, topK, retrieveFn, minScore, keywordIndex, kw, queryPrefix, explain);
295
+ const hybridEnabled = cfg.retrieval.hybridSearch?.enabled;
296
+ const primaryResults = await retrieveContext(query, embedder, store, topK, retrieveFn, minScore, keywordIndex, kw, queryPrefix, explain, hybridEnabled);
248
297
  const extraResults = extraQuery
249
- ? await retrieveContext(extraQuery, embedder, store, topK, retrieveFn, minScore, keywordIndex, kw, queryPrefix, explain)
298
+ ? await retrieveContext(extraQuery, embedder, store, topK, retrieveFn, minScore, keywordIndex, kw, queryPrefix, explain, hybridEnabled)
250
299
  : [];
251
300
  const optCfg = cfg.retrieval.contextOptimization ?? DEFAULT_CONTEXT_OPTIMIZATION;
252
301
  return optimizeContext([...primaryResults, ...extraResults], { topK, config: optCfg })
@@ -356,16 +405,27 @@ export function createRagHooks(options) {
356
405
  let cachedOverrides = loadRuntimeOverrides(options.storePath);
357
406
  let overridesLastCheck = 0;
358
407
  const OVERRIDES_TTL_MS = Number(process.env.OPENCODE_RAG_OVERRIDES_TTL_MS) || 3600000;
408
+ let lastEffectiveCfg = null;
409
+ let lastEffectiveCfgTime = 0;
359
410
  function getEffectiveCfg() {
360
411
  if (Date.now() - overridesLastCheck > OVERRIDES_TTL_MS) {
361
412
  cachedOverrides = loadRuntimeOverrides(options.storePath);
362
413
  overridesLastCheck = Date.now();
414
+ lastEffectiveCfg = null;
415
+ }
416
+ if (lastEffectiveCfg && Date.now() - lastEffectiveCfgTime < 1000) {
417
+ return lastEffectiveCfg;
363
418
  }
364
- return applyRuntimeOverrides(options.cfg, cachedOverrides);
419
+ lastEffectiveCfg = applyRuntimeOverrides(options.cfg, cachedOverrides);
420
+ lastEffectiveCfgTime = Date.now();
421
+ return lastEffectiveCfg;
365
422
  }
366
423
  // Session-level caches for lazy retrieval
367
424
  const sessionLastMessage = new Map();
368
425
  const sessionRetrievalCache = new Map();
426
+ // Track the current assistant message text per session for 2-message search queries.
427
+ const sessionAssistantMessageId = new Map();
428
+ const sessionAssistantText = new Map();
369
429
  // Evaluation session logger — captures OpenCode events for analysis
370
430
  const sessionLogger = createSessionLogger(options.storePath);
371
431
  appendDebugLog(options.logFilePath, {
@@ -536,6 +596,7 @@ export function createRagHooks(options) {
536
596
  sessionLastMessage,
537
597
  sessionRetrievalCache,
538
598
  keywordIndex,
599
+ maxSessionCacheSize: MAX_SESSION_MAP_SIZE,
539
600
  });
540
601
  tools["read"] = readTool;
541
602
  appendDebugLog(options.logFilePath, {
@@ -551,6 +612,38 @@ export function createRagHooks(options) {
551
612
  return {
552
613
  async event({ event }) {
553
614
  sessionLogger.onEvent(event);
615
+ // Accumulate the most recent assistant message's text via streaming deltas.
616
+ // Used to build the 2-message retrieval query in chat.message.
617
+ try {
618
+ if (event.type === "message.updated") {
619
+ const info = event.properties;
620
+ const msgInfo = info?.info;
621
+ if (msgInfo?.role === "assistant" &&
622
+ typeof msgInfo.sessionID === "string" &&
623
+ typeof msgInfo.id === "string") {
624
+ boundedSet(sessionAssistantMessageId, msgInfo.sessionID, msgInfo.id, MAX_SESSION_MAP_SIZE);
625
+ boundedSet(sessionAssistantText, msgInfo.sessionID, "", MAX_SESSION_MAP_SIZE);
626
+ }
627
+ }
628
+ else if (event.type === "message.part.updated") {
629
+ const props = event.properties;
630
+ const part = props?.part;
631
+ const delta = props?.delta;
632
+ if (part?.type === "text" &&
633
+ typeof part.sessionID === "string" &&
634
+ typeof part.messageID === "string" &&
635
+ typeof delta === "string") {
636
+ const assistantMsgId = sessionAssistantMessageId.get(part.sessionID);
637
+ if (assistantMsgId && part.messageID === assistantMsgId) {
638
+ const existing = sessionAssistantText.get(part.sessionID) ?? "";
639
+ boundedSet(sessionAssistantText, part.sessionID, existing + delta, MAX_SESSION_MAP_SIZE);
640
+ }
641
+ }
642
+ }
643
+ }
644
+ catch {
645
+ // Non-critical — must never throw
646
+ }
554
647
  },
555
648
  tool: tools,
556
649
  async "experimental.chat.system.transform"(_input, output) {
@@ -558,42 +651,45 @@ export function createRagHooks(options) {
558
651
  scope: "experimental.chat.system.transform",
559
652
  message: "system guidance injected",
560
653
  });
561
- const guidance = [
562
- "MANDATORY: OpenCodeRAG tools MUST be used before any code task:",
563
- "- `search_semantic(query)`: retrieve relevant code chunks. Call BEFORE planning, editing, or answering. Accepts `pathHints` and `languageHints`.",
564
- "- `get_file_skeleton(filePath)`: structural overview of a file. Call BEFORE reading any file.",
565
- "- `find_usages(symbolName)`: find all references. Call BEFORE editing any function, class, or variable.",
566
- "- `describe_image(filePath)`: describe an image file using a vision model. Call when user refers to a screenshot, diagram, or image.",
567
- "",
568
- "Decision tree ALWAYS follow this order:",
569
- "1. User mentions code behavior/architecture → `search_semantic(query)`",
570
- "2. User mentions a file path → `get_file_skeleton(filePath)` THEN `read` on specific lines",
571
- "3. User mentions a function/class/variable to edit → `find_usages(symbolName)` THEN `search_semantic` THEN `edit`",
572
- "4. User asks a code question → `search_semantic` to gather context before answering",
573
- "5. User asks about an image or visual asset → `describe_image(filePath)` to retrieve its generated description, then optionally `search_semantic` for related code",
574
- "",
575
- "Proactive triggers you MUST call these tools when:",
576
- "- User asks about code behavior, architecture, or implementation details",
577
- "- User asks to edit, refactor, or fix code — call `find_usages` first",
578
- "- User references files or functions you haven't read yet",
579
- "- User says \"find\", \"search\", \"look up\", \"where is\", \"how does\"",
580
- "- User refers to an image, screenshot, diagram, or visual asset",
581
- "- Before answering ANY code-related question, retrieve context first",
582
- "- Before reading ANY file, call `get_file_skeleton` to orient first",
583
- "",
584
- "Anti-patterns NEVER do these:",
585
- "- Reading full files without calling `get_file_skeleton` first (wastes tokens)",
586
- "- Editing a function without calling `find_usages` first (breaks call sites)",
587
- "- Answering code questions without calling `search_semantic` first (you guess at behavior)",
588
- "- Using `grep`/`glob` when `search_semantic` would find the answer faster",
589
- "- Treating image files as text — use `describe_image` instead of reading raw bytes",
590
- ];
591
- output.system.unshift(guidance.join("\n"));
654
+ const cfg = getEffectiveCfg();
655
+ if (cfg.openCode.injectSystemPrompt !== false) {
656
+ const guidance = [
657
+ "MANDATORY: OpenCodeRAG tools MUST be used before any code task:",
658
+ "- `search_semantic(query)`: retrieve relevant code chunks. Call BEFORE planning, editing, or answering. Accepts `pathHints` and `languageHints`.",
659
+ "- `get_file_skeleton(filePath)`: structural overview of a file. Call BEFORE reading any file.",
660
+ "- `find_usages(symbolName)`: find all references. Call BEFORE editing any function, class, or variable.",
661
+ "- `describe_image(filePath)`: describe an image file using a vision model. Call when user refers to a screenshot, diagram, or image.",
662
+ "",
663
+ "Decision tree ALWAYS follow this order:",
664
+ "1. User mentions code behavior/architecture → `search_semantic(query)`",
665
+ "2. User mentions a file path → `get_file_skeleton(filePath)` THEN `read` on specific lines",
666
+ "3. User mentions a function/class/variable to edit → `find_usages(symbolName)` THEN `search_semantic` THEN `edit`",
667
+ "4. User asks a code question → `search_semantic` to gather context before answering",
668
+ "5. User asks about an image or visual asset → `describe_image(filePath)` to retrieve its generated description, then optionally `search_semantic` for related code",
669
+ "",
670
+ "Proactive triggers you MUST call these tools when:",
671
+ "- User asks about code behavior, architecture, or implementation details",
672
+ "- User asks to edit, refactor, or fix code call `find_usages` first",
673
+ "- User references files or functions you haven't read yet",
674
+ "- User says \"find\", \"search\", \"look up\", \"where is\", \"how does\"",
675
+ "- User refers to an image, screenshot, diagram, or visual asset",
676
+ "- Before answering ANY code-related question, retrieve context first",
677
+ "- Before reading ANY file, call `get_file_skeleton` to orient first",
678
+ "",
679
+ "Anti-patterns NEVER do these:",
680
+ "- Reading full files without calling `get_file_skeleton` first (wastes tokens)",
681
+ "- Editing a function without calling `find_usages` first (breaks call sites)",
682
+ "- Answering code questions without calling `search_semantic` first (you guess at behavior)",
683
+ "- Using `grep`/`glob` when `search_semantic` would find the answer faster",
684
+ "- Treating image files as text — use `describe_image` instead of reading raw bytes",
685
+ ];
686
+ output.system.unshift(guidance.join("\n"));
687
+ }
592
688
  // Inject update notification if available
593
689
  const updateInfo = pendingUpdateInfo.get(options.worktree);
594
690
  if (updateInfo) {
595
691
  output.system.unshift(`OpenCodeRAG update available: ${updateInfo.currentVersion} → ${updateInfo.latestVersion}. ` +
596
- `Run \`opencode-rag update\` to install.`);
692
+ `Run \`npm update -g opencode-rag-plugin && opencode-rag setup\` to install.`);
597
693
  }
598
694
  // Inject documentation mode system prompt if enabled
599
695
  const docMode = getEffectiveCfg().documentationMode;
@@ -616,7 +712,7 @@ export function createRagHooks(options) {
616
712
  });
617
713
  if (text.length === 0)
618
714
  return;
619
- sessionLastMessage.set(input.sessionID, text);
715
+ boundedSet(sessionLastMessage, input.sessionID, text, MAX_SESSION_MAP_SIZE);
620
716
  // Handle /doc slash command
621
717
  if (text.startsWith("/doc")) {
622
718
  const docMode = getEffectiveCfg().documentationMode;
@@ -714,7 +810,11 @@ export function createRagHooks(options) {
714
810
  const effectiveCfg = getEffectiveCfg();
715
811
  const hybridCfg = effectiveCfg.retrieval.hybridSearch;
716
812
  const retrievalStart = Date.now();
717
- const results = await dependencies.retrieve(text, embedder, store, {
813
+ // Build 2-message query: previous assistant response + current user prompt.
814
+ // Falls back to user-only text on the first turn (no prior assistant message).
815
+ const assistantText = sessionAssistantText.get(input.sessionID);
816
+ const searchQuery = assistantText ? assistantText + "\n" + text : text;
817
+ const results = await dependencies.retrieve(searchQuery, embedder, store, {
718
818
  topK: effectiveCfg.retrieval.topK,
719
819
  minScore: 0,
720
820
  keywordIndex,
@@ -734,29 +834,22 @@ export function createRagHooks(options) {
734
834
  const parts = output?.parts ?? output?.message?.parts;
735
835
  if (Array.isArray(parts) && parts.length > 0) {
736
836
  const first = parts[0];
737
- const messageID = first?.messageID
738
- ?? output?.message?.id
739
- ?? input.messageID
740
- ?? `msg-${Date.now()}`;
741
- const sessionID = first?.sessionID ?? input.sessionID;
742
- const ragPart = {
743
- id: `prt_${uuid()}`,
744
- sessionID,
745
- messageID,
746
- type: "text",
747
- text: ragContext,
748
- metadata: {
749
- source: "opencode-rag",
750
- injectionType: pendingInjection,
751
- retrievalTimeMs,
752
- resultCount: results.length,
753
- timestamp: Date.now(),
754
- },
755
- };
756
- parts.push(ragPart);
837
+ if (typeof first.text === "string") {
838
+ // Append RAG context directly to the user's text part instead of
839
+ // pushing a new part. Matches the /doc handler pattern — avoids the
840
+ // duplicate-output bug caused by parts.push() in some render paths.
841
+ parts[0] = { ...first, text: first.text + "\n\n" + ragContext };
842
+ }
843
+ const msgParts = output?.message?.parts;
844
+ if (Array.isArray(msgParts) && msgParts !== parts) {
845
+ const mfirst = msgParts[0];
846
+ if (typeof mfirst.text === "string") {
847
+ msgParts[0] = { ...mfirst, text: mfirst.text + "\n\n" + ragContext };
848
+ }
849
+ }
757
850
  appendDebugLog(options.logFilePath, {
758
851
  scope: "chat.message",
759
- message: `pushed RAG part (id=${ragPart.id}, parts.length=${parts.length})`,
852
+ message: `injected ${pendingInjection} context into parts[0].text (text.length=${ragContext.length})`,
760
853
  });
761
854
  }
762
855
  }
@@ -765,10 +858,10 @@ export function createRagHooks(options) {
765
858
  scope: "chat.message",
766
859
  message: `injected ${pendingInjection} context (results=${results.length}, retrieval=${retrievalTimeMs}ms)`,
767
860
  });
768
- const postPartsArr = output?.parts;
769
- const postMsgPartsArr = output?.message?.parts;
770
- const postPartsText = Array.isArray(postPartsArr) ? postPartsArr[0]?.text : undefined;
771
- const postMsgPartsText = Array.isArray(postMsgPartsArr) ? postMsgPartsArr[0]?.text : undefined;
861
+ const postParts = output?.parts;
862
+ const postMsgParts = output?.message?.parts;
863
+ const postPartsText = Array.isArray(postParts) ? postParts[0]?.text : undefined;
864
+ const postMsgPartsText = Array.isArray(postMsgParts) ? postMsgParts[0]?.text : undefined;
772
865
  appendDebugLog(options.logFilePath, {
773
866
  scope: "chat.message",
774
867
  message: `post-injection parts[0].text="${typeof postPartsText === 'string' ? postPartsText.substring(0, 80) : 'n/a'}" msgParts[0].text="${typeof postMsgPartsText === 'string' ? postMsgPartsText.substring(0, 80) : 'n/a'}"`,
@@ -994,29 +1087,51 @@ export const ragPlugin = async (input, _options) => {
994
1087
  }
995
1088
  mcpServers.delete(input.directory);
996
1089
  }
1090
+ // Clean up stale config cache and pending update info for this directory
1091
+ configCache.delete(input.directory);
1092
+ pendingUpdateInfo.delete(input.directory);
1093
+ // Clean up idle HTTP sockets from previous provider connections
1094
+ destroyAllPooledConnections();
997
1095
  appendDebugLog(logFilePath, {
998
1096
  scope: "plugin",
999
1097
  message: `OpenCode plugin enabled for ${input.directory}`,
1000
1098
  }, logLevel);
1001
- // Probe vector dimension and create store with correct dimension
1099
+ // Use cached dimension from config if available (avoids blocking startup with an API call)
1100
+ // If not set, probe the embedding provider once and persist the result.
1002
1101
  const embedder = createEmbedder(effectiveCfg);
1003
- let vectorDimension = 384;
1004
- try {
1005
- const probe = await embedder.embed(["dimension-probe"], "query");
1006
- if (probe && probe[0] && probe[0].length > 0 && typeof probe[0][0] === "number") {
1007
- vectorDimension = probe[0].length;
1008
- }
1102
+ let vectorDimension = effectiveCfg.embedding.vectorDimension;
1103
+ if (vectorDimension && vectorDimension > 0) {
1009
1104
  appendDebugLog(logFilePath, {
1010
1105
  scope: "plugin",
1011
- message: `Vector dimension: ${vectorDimension}`,
1106
+ message: `Vector dimension: ${vectorDimension} (cached in config)`,
1012
1107
  }, logLevel);
1013
1108
  }
1014
- catch (err) {
1015
- appendDebugLog(logFilePath, {
1016
- scope: "plugin",
1017
- message: `Dimension probe failed, falling back to ${vectorDimension}`,
1018
- error: err,
1019
- }, logLevel);
1109
+ else {
1110
+ vectorDimension = 384;
1111
+ try {
1112
+ const probe = await embedder.embed(["dimension-probe"], "query");
1113
+ if (probe && probe[0] && probe[0].length > 0 && typeof probe[0][0] === "number") {
1114
+ vectorDimension = probe[0].length;
1115
+ const configPath = findConfigFile(input.directory);
1116
+ if (configPath) {
1117
+ try {
1118
+ persistProbedDimension(configPath, vectorDimension);
1119
+ }
1120
+ catch { /* best-effort */ }
1121
+ }
1122
+ }
1123
+ appendDebugLog(logFilePath, {
1124
+ scope: "plugin",
1125
+ message: `Vector dimension: ${vectorDimension}`,
1126
+ }, logLevel);
1127
+ }
1128
+ catch (err) {
1129
+ appendDebugLog(logFilePath, {
1130
+ scope: "plugin",
1131
+ message: `Dimension probe failed, falling back to ${vectorDimension}`,
1132
+ error: err,
1133
+ }, logLevel);
1134
+ }
1020
1135
  }
1021
1136
  const store = createVectorStore(effectiveCfg, storePath, vectorDimension);
1022
1137
  // Load or create keyword index for hybrid search
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @fileoverview In-memory inverted keyword index with stemming, tokenization, and optional serialization.
3
3
  */
4
- import type { Chunk, SearchResult } from "../core/interfaces.js";
4
+ import type { Chunk, SearchResult, MetadataFilter } from "../core/interfaces.js";
5
5
  /**
6
6
  * Tokenize text into normalized tokens including stems, camelCase parts, and snake_case parts.
7
7
  *
@@ -22,7 +22,8 @@ export declare class KeywordIndex {
22
22
  addChunks(chunks: Chunk[]): void;
23
23
  getMatchedTerms(query: string, chunkId: string): string[];
24
24
  removeByFilePath(filePath: string): void;
25
- search(query: string, topK: number): SearchResult[];
25
+ search(query: string, topK: number, filter?: MetadataFilter): SearchResult[];
26
+ close(): void;
26
27
  clear(): void;
27
28
  count(): number;
28
29
  save(storePath?: string): Promise<void>;