opencode-rag-plugin 1.15.1 → 1.17.2

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 (240) hide show
  1. package/ReadMe.md +52 -9
  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 -0
  14. package/dist/cli/commands/index.js +1 -0
  15. package/dist/cli/commands/init-helpers.d.ts +12 -0
  16. package/dist/cli/commands/init-helpers.js +87 -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/commands/update.d.ts +4 -6
  23. package/dist/cli/commands/update.js +44 -58
  24. package/dist/cli/index.js +2 -1
  25. package/dist/content/image.js +24 -3
  26. package/dist/content/reader.d.ts +6 -1
  27. package/dist/content/reader.js +49 -4
  28. package/dist/core/config.d.ts +29 -1
  29. package/dist/core/config.js +105 -8
  30. package/dist/core/desc-cache.d.ts +31 -0
  31. package/dist/core/desc-cache.js +124 -0
  32. package/dist/core/interfaces.d.ts +58 -3
  33. package/dist/core/manifest.d.ts +24 -1
  34. package/dist/core/manifest.js +34 -3
  35. package/dist/core/resolve-api-key.js +4 -2
  36. package/dist/core/runtime-overrides.js +12 -8
  37. package/dist/core/setup-runtime.d.ts +23 -0
  38. package/dist/core/setup-runtime.js +231 -0
  39. package/dist/core/version-check.d.ts +85 -0
  40. package/dist/core/version-check.js +160 -0
  41. package/dist/describer/anthropic.d.ts +2 -2
  42. package/dist/describer/anthropic.js +5 -7
  43. package/dist/describer/describer.d.ts +2 -2
  44. package/dist/describer/describer.js +6 -8
  45. package/dist/describer/gemini.d.ts +2 -2
  46. package/dist/describer/gemini.js +5 -7
  47. package/dist/embedder/cohere.d.ts +1 -1
  48. package/dist/embedder/cohere.js +2 -2
  49. package/dist/embedder/factory.d.ts +3 -1
  50. package/dist/embedder/factory.js +8 -2
  51. package/dist/embedder/health.js +5 -1
  52. package/dist/embedder/http.d.ts +1 -1
  53. package/dist/embedder/http.js +74 -43
  54. package/dist/embedder/ollama.d.ts +1 -1
  55. package/dist/embedder/ollama.js +2 -2
  56. package/dist/embedder/openai.d.ts +1 -1
  57. package/dist/embedder/openai.js +2 -2
  58. package/dist/eval/compare-merge.d.ts +10 -0
  59. package/dist/eval/compare-merge.js +537 -0
  60. package/dist/eval/compare-rankings.d.ts +10 -0
  61. package/dist/eval/compare-rankings.js +245 -0
  62. package/dist/eval/dump-descriptions.d.ts +7 -0
  63. package/dist/eval/dump-descriptions.js +58 -0
  64. package/dist/eval/fast-index.d.ts +8 -0
  65. package/dist/eval/fast-index.js +283 -0
  66. package/dist/eval/run-branch-compare.d.ts +7 -0
  67. package/dist/eval/run-branch-compare.js +220 -0
  68. package/dist/eval/run-token-test.js +1 -0
  69. package/dist/eval/test-kw.d.ts +1 -0
  70. package/dist/eval/test-kw.js +22 -0
  71. package/dist/eval/update-descriptions.d.ts +7 -0
  72. package/dist/eval/update-descriptions.js +84 -0
  73. package/dist/index.d.ts +4 -0
  74. package/dist/index.js +3 -0
  75. package/dist/indexer/embed-stage.js +2 -1
  76. package/dist/indexer/git-diff.js +21 -9
  77. package/dist/indexer/pipeline.d.ts +6 -0
  78. package/dist/indexer/pipeline.js +304 -40
  79. package/dist/indexer/watch.js +1 -3
  80. package/dist/indexer/worker.d.ts +15 -2
  81. package/dist/indexer/worker.js +25 -12
  82. package/dist/mcp/handlers.d.ts +9 -0
  83. package/dist/mcp/handlers.js +23 -6
  84. package/dist/mcp/server.js +2 -0
  85. package/dist/opencode/create-read-tool.d.ts +2 -0
  86. package/dist/opencode/create-read-tool.js +8 -2
  87. package/dist/opencode/read-fallback.d.ts +1 -5
  88. package/dist/opencode/read-fallback.js +1 -18
  89. package/dist/opencode/read-format.js +5 -3
  90. package/dist/opencode/tools.js +5 -7
  91. package/dist/plugin.js +223 -71
  92. package/dist/retriever/keyword-index.d.ts +3 -2
  93. package/dist/retriever/keyword-index.js +25 -1
  94. package/dist/retriever/retriever.d.ts +4 -1
  95. package/dist/retriever/retriever.js +34 -56
  96. package/dist/tui.js +18 -1
  97. package/dist/vectorstore/lancedb.d.ts +35 -4
  98. package/dist/vectorstore/lancedb.js +146 -23
  99. package/dist/vectorstore/memory.d.ts +6 -1
  100. package/dist/vectorstore/memory.js +58 -0
  101. package/dist/watcher.d.ts +2 -0
  102. package/dist/watcher.js +5 -1
  103. package/dist/web/api.js +10 -2
  104. package/dist/web/server.js +18 -3
  105. package/package.json +8 -9
  106. package/scripts/postinstall-setup.js +82 -0
  107. package/dist/api.js.map +0 -1
  108. package/dist/chunker/base.js.map +0 -1
  109. package/dist/chunker/bash.js.map +0 -1
  110. package/dist/chunker/c.js.map +0 -1
  111. package/dist/chunker/cpp.js.map +0 -1
  112. package/dist/chunker/csharp.js.map +0 -1
  113. package/dist/chunker/css.js.map +0 -1
  114. package/dist/chunker/doc.js.map +0 -1
  115. package/dist/chunker/dockerfile.js.map +0 -1
  116. package/dist/chunker/docx.js.map +0 -1
  117. package/dist/chunker/excel.js.map +0 -1
  118. package/dist/chunker/factory.js.map +0 -1
  119. package/dist/chunker/fallback.js.map +0 -1
  120. package/dist/chunker/go.js.map +0 -1
  121. package/dist/chunker/grammar.js.map +0 -1
  122. package/dist/chunker/html.js.map +0 -1
  123. package/dist/chunker/image.js.map +0 -1
  124. package/dist/chunker/ini.js.map +0 -1
  125. package/dist/chunker/java.js.map +0 -1
  126. package/dist/chunker/javascript.js.map +0 -1
  127. package/dist/chunker/json.js.map +0 -1
  128. package/dist/chunker/kotlin.js.map +0 -1
  129. package/dist/chunker/loader.js.map +0 -1
  130. package/dist/chunker/markdown.js.map +0 -1
  131. package/dist/chunker/pdf.js.map +0 -1
  132. package/dist/chunker/php.js.map +0 -1
  133. package/dist/chunker/powershell.js.map +0 -1
  134. package/dist/chunker/python.js.map +0 -1
  135. package/dist/chunker/razor.js.map +0 -1
  136. package/dist/chunker/ruby.js.map +0 -1
  137. package/dist/chunker/rust.js.map +0 -1
  138. package/dist/chunker/sln.js.map +0 -1
  139. package/dist/chunker/sql.js.map +0 -1
  140. package/dist/chunker/ssl.js.map +0 -1
  141. package/dist/chunker/swift.js.map +0 -1
  142. package/dist/chunker/tex.js.map +0 -1
  143. package/dist/chunker/toml.js.map +0 -1
  144. package/dist/chunker/typescript.js.map +0 -1
  145. package/dist/chunker/uuid.js.map +0 -1
  146. package/dist/chunker/xml.js.map +0 -1
  147. package/dist/chunker/yaml.js.map +0 -1
  148. package/dist/cli/commands/clear.js.map +0 -1
  149. package/dist/cli/commands/describe-image.js.map +0 -1
  150. package/dist/cli/commands/dump.js.map +0 -1
  151. package/dist/cli/commands/eval.js.map +0 -1
  152. package/dist/cli/commands/index-command.js.map +0 -1
  153. package/dist/cli/commands/index.js.map +0 -1
  154. package/dist/cli/commands/init-helpers.js.map +0 -1
  155. package/dist/cli/commands/init.js.map +0 -1
  156. package/dist/cli/commands/list.js.map +0 -1
  157. package/dist/cli/commands/mcp.js.map +0 -1
  158. package/dist/cli/commands/query.js.map +0 -1
  159. package/dist/cli/commands/show.js.map +0 -1
  160. package/dist/cli/commands/status.js.map +0 -1
  161. package/dist/cli/commands/ui.js.map +0 -1
  162. package/dist/cli/commands/update.js.map +0 -1
  163. package/dist/cli/format.js.map +0 -1
  164. package/dist/cli/helpers.js.map +0 -1
  165. package/dist/cli/index.js.map +0 -1
  166. package/dist/cli/progress.d.ts +0 -42
  167. package/dist/cli/progress.js +0 -137
  168. package/dist/cli/progress.js.map +0 -1
  169. package/dist/cli/types.js.map +0 -1
  170. package/dist/cli.js.map +0 -1
  171. package/dist/content/doc.js.map +0 -1
  172. package/dist/content/docx.js.map +0 -1
  173. package/dist/content/excel.js.map +0 -1
  174. package/dist/content/image.js.map +0 -1
  175. package/dist/content/pdf.js.map +0 -1
  176. package/dist/content/reader.js.map +0 -1
  177. package/dist/content/types.js.map +0 -1
  178. package/dist/core/bootstrap.js.map +0 -1
  179. package/dist/core/config.js.map +0 -1
  180. package/dist/core/doc-progress.js.map +0 -1
  181. package/dist/core/fileLogger.js.map +0 -1
  182. package/dist/core/interfaces.js.map +0 -1
  183. package/dist/core/manifest.js.map +0 -1
  184. package/dist/core/provider-defaults.js.map +0 -1
  185. package/dist/core/rag-injection-flag.js.map +0 -1
  186. package/dist/core/resolve-api-key.js.map +0 -1
  187. package/dist/core/runtime-overrides.js.map +0 -1
  188. package/dist/describer/anthropic.js.map +0 -1
  189. package/dist/describer/describer.js.map +0 -1
  190. package/dist/describer/factory.js.map +0 -1
  191. package/dist/describer/gemini.js.map +0 -1
  192. package/dist/describer/shared.js.map +0 -1
  193. package/dist/embedder/cohere.js.map +0 -1
  194. package/dist/embedder/factory.js.map +0 -1
  195. package/dist/embedder/health.js.map +0 -1
  196. package/dist/embedder/http.js.map +0 -1
  197. package/dist/embedder/ollama.js.map +0 -1
  198. package/dist/embedder/openai.js.map +0 -1
  199. package/dist/eval/index.js.map +0 -1
  200. package/dist/eval/run-token-test.js.map +0 -1
  201. package/dist/eval/session-logger.js.map +0 -1
  202. package/dist/eval/storage.js.map +0 -1
  203. package/dist/eval/token-analysis.js.map +0 -1
  204. package/dist/eval/token-counter.js.map +0 -1
  205. package/dist/eval/types.js.map +0 -1
  206. package/dist/index.js.map +0 -1
  207. package/dist/indexer/description-stage.js.map +0 -1
  208. package/dist/indexer/embed-stage.js.map +0 -1
  209. package/dist/indexer/git-diff.js.map +0 -1
  210. package/dist/indexer/metadata.js.map +0 -1
  211. package/dist/indexer/pipeline.js.map +0 -1
  212. package/dist/indexer/stats.js.map +0 -1
  213. package/dist/indexer/watch.js.map +0 -1
  214. package/dist/indexer/worker.js.map +0 -1
  215. package/dist/indexer.js.map +0 -1
  216. package/dist/mcp/cli.js.map +0 -1
  217. package/dist/mcp/handlers.js.map +0 -1
  218. package/dist/mcp/server.js.map +0 -1
  219. package/dist/opencode/create-read-tool.js.map +0 -1
  220. package/dist/opencode/read-fallback.js.map +0 -1
  221. package/dist/opencode/read-format.js.map +0 -1
  222. package/dist/opencode/read-query.js.map +0 -1
  223. package/dist/opencode/tool-args.js.map +0 -1
  224. package/dist/opencode/tools.js.map +0 -1
  225. package/dist/plugin-entry.js.map +0 -1
  226. package/dist/plugin.js.map +0 -1
  227. package/dist/retriever/context-optimizer.js.map +0 -1
  228. package/dist/retriever/keyword-index.js.map +0 -1
  229. package/dist/retriever/retriever.js.map +0 -1
  230. package/dist/tui.js.map +0 -1
  231. package/dist/updater.d.ts +0 -45
  232. package/dist/updater.js +0 -175
  233. package/dist/updater.js.map +0 -1
  234. package/dist/vectorstore/factory.js.map +0 -1
  235. package/dist/vectorstore/lancedb.js.map +0 -1
  236. package/dist/vectorstore/memory.js.map +0 -1
  237. package/dist/watcher.js.map +0 -1
  238. package/dist/web/api.js.map +0 -1
  239. package/dist/web/server.js.map +0 -1
  240. package/dist/web/static.js.map +0 -1
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";
@@ -22,12 +22,15 @@ import { loadDocProgress, markSubdirectoryDocumented } from "./core/doc-progress
22
22
  import { loadManifest } from "./core/manifest.js";
23
23
  import { createSessionLogger } from "./eval/session-logger.js";
24
24
  import { countTokens } from "./eval/token-counter.js";
25
- import { checkForUpdate } from "./updater.js";
26
- import { existsSync, readFileSync, unlinkSync } from "node:fs";
25
+ import { checkForUpdate, getCurrentVersion } from "./core/version-check.js";
26
+ import { destroyAllPooledConnections } from "./embedder/http.js";
27
+ import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
27
28
  import path from "node:path";
28
29
  import { fileURLToPath } from "node:url";
29
30
  import { spawn, execSync } from "node:child_process";
30
31
  import { tmpdir } from "node:os";
32
+ /** Maximum entries per session map before oldest entries are evicted. */
33
+ const MAX_SESSION_MAP_SIZE = 50;
31
34
  /** Cache of loaded RAG configurations keyed by workspace directory. */
32
35
  const configCache = new Map();
33
36
  /** Active background indexer instances keyed by workspace directory. */
@@ -36,6 +39,55 @@ const backgroundIndexers = new Map();
36
39
  const mcpServers = new Map();
37
40
  /** Pending update notifications keyed by workspace directory. */
38
41
  const pendingUpdateInfo = new Map();
42
+ /** Workspace directories that have already been prompted about an update this session. */
43
+ const notifiedUpdateDirs = new Set();
44
+ /** Guard flag to prevent re-entrant shutdown. */
45
+ let shutdownRegistered = false;
46
+ /** Close all active background indexers and MCP servers, then destroy idle sockets. */
47
+ async function shutdownPluginResources() {
48
+ for (const [dir, indexer] of backgroundIndexers) {
49
+ try {
50
+ await indexer.close();
51
+ }
52
+ catch { /* best-effort */ }
53
+ backgroundIndexers.delete(dir);
54
+ }
55
+ for (const [dir, server] of mcpServers) {
56
+ try {
57
+ await server.close();
58
+ }
59
+ catch { /* best-effort */ }
60
+ mcpServers.delete(dir);
61
+ }
62
+ configCache.clear();
63
+ pendingUpdateInfo.clear();
64
+ notifiedUpdateDirs.clear();
65
+ destroyAllPooledConnections();
66
+ }
67
+ /** Register a process.beforeExit handler so that resources are cleaned up
68
+ * when the OpenCode process exits (rather than only on plugin reload). */
69
+ function registerShutdownHandler() {
70
+ if (shutdownRegistered)
71
+ return;
72
+ shutdownRegistered = true;
73
+ process.once("beforeExit", () => {
74
+ void shutdownPluginResources();
75
+ });
76
+ }
77
+ registerShutdownHandler();
78
+ /** Set a bounded Map entry, evicting the oldest key if the map exceeds maximum size. */
79
+ function boundedSet(map, key, value, maxSize) {
80
+ if (map.has(key)) {
81
+ map.set(key, value);
82
+ return;
83
+ }
84
+ if (map.size >= maxSize) {
85
+ const oldest = map.keys().next().value;
86
+ if (oldest !== undefined)
87
+ map.delete(oldest);
88
+ }
89
+ map.set(key, value);
90
+ }
39
91
  /** Name of the semantic search tool as exposed to the LLM. */
40
92
  const CONTEXT_TOOL_NAME = "search_semantic";
41
93
  /** Marker string injected into context output to identify RAG-sourced content. */
@@ -230,10 +282,10 @@ function buildRetrievalQuery(hints) {
230
282
  * Perform a retrieval query against the vector store with the given parameters.
231
283
  * Returns an empty array for blank queries.
232
284
  */
233
- async function retrieveContext(query, embedder, store, topK, retrieveFn = retrieve, minScore = 0, keywordIndex, keywordWeight, queryPrefix, explain = false) {
285
+ async function retrieveContext(query, embedder, store, topK, retrieveFn = retrieve, minScore = 0, keywordIndex, keywordWeight, queryPrefix, explain = false, hybridEnabled) {
234
286
  if (query.trim().length === 0)
235
287
  return [];
236
- return retrieveFn(query, embedder, store, { topK, minScore, keywordIndex, keywordWeight, queryPrefix, explain });
288
+ return retrieveFn(query, embedder, store, { topK, minScore, keywordIndex, keywordWeight, hybridEnabled, queryPrefix, explain });
237
289
  }
238
290
  /**
239
291
  * Load results from one or two queries (primary + optional extra), optimize
@@ -243,9 +295,10 @@ async function retrieveContext(query, embedder, store, topK, retrieveFn = retrie
243
295
  async function loadRetrievedResults(query, embedder, store, cfg, retrieveFn = retrieve, topK = cfg.retrieval.topK, extraQuery, keywordIndex, queryPrefix, explain = false) {
244
296
  const minScore = cfg.retrieval.minScore;
245
297
  const kw = cfg.retrieval.hybridSearch?.keywordWeight;
246
- const primaryResults = await retrieveContext(query, embedder, store, topK, retrieveFn, minScore, keywordIndex, kw, queryPrefix, explain);
298
+ const hybridEnabled = cfg.retrieval.hybridSearch?.enabled;
299
+ const primaryResults = await retrieveContext(query, embedder, store, topK, retrieveFn, minScore, keywordIndex, kw, queryPrefix, explain, hybridEnabled);
247
300
  const extraResults = extraQuery
248
- ? await retrieveContext(extraQuery, embedder, store, topK, retrieveFn, minScore, keywordIndex, kw, queryPrefix, explain)
301
+ ? await retrieveContext(extraQuery, embedder, store, topK, retrieveFn, minScore, keywordIndex, kw, queryPrefix, explain, hybridEnabled)
249
302
  : [];
250
303
  const optCfg = cfg.retrieval.contextOptimization ?? DEFAULT_CONTEXT_OPTIMIZATION;
251
304
  return optimizeContext([...primaryResults, ...extraResults], { topK, config: optCfg })
@@ -355,12 +408,20 @@ export function createRagHooks(options) {
355
408
  let cachedOverrides = loadRuntimeOverrides(options.storePath);
356
409
  let overridesLastCheck = 0;
357
410
  const OVERRIDES_TTL_MS = Number(process.env.OPENCODE_RAG_OVERRIDES_TTL_MS) || 3600000;
411
+ let lastEffectiveCfg = null;
412
+ let lastEffectiveCfgTime = 0;
358
413
  function getEffectiveCfg() {
359
414
  if (Date.now() - overridesLastCheck > OVERRIDES_TTL_MS) {
360
415
  cachedOverrides = loadRuntimeOverrides(options.storePath);
361
416
  overridesLastCheck = Date.now();
417
+ lastEffectiveCfg = null;
362
418
  }
363
- return applyRuntimeOverrides(options.cfg, cachedOverrides);
419
+ if (lastEffectiveCfg && Date.now() - lastEffectiveCfgTime < 1000) {
420
+ return lastEffectiveCfg;
421
+ }
422
+ lastEffectiveCfg = applyRuntimeOverrides(options.cfg, cachedOverrides);
423
+ lastEffectiveCfgTime = Date.now();
424
+ return lastEffectiveCfg;
364
425
  }
365
426
  // Session-level caches for lazy retrieval
366
427
  const sessionLastMessage = new Map();
@@ -538,6 +599,7 @@ export function createRagHooks(options) {
538
599
  sessionLastMessage,
539
600
  sessionRetrievalCache,
540
601
  keywordIndex,
602
+ maxSessionCacheSize: MAX_SESSION_MAP_SIZE,
541
603
  });
542
604
  tools["read"] = readTool;
543
605
  appendDebugLog(options.logFilePath, {
@@ -562,8 +624,8 @@ export function createRagHooks(options) {
562
624
  if (msgInfo?.role === "assistant" &&
563
625
  typeof msgInfo.sessionID === "string" &&
564
626
  typeof msgInfo.id === "string") {
565
- sessionAssistantMessageId.set(msgInfo.sessionID, msgInfo.id);
566
- sessionAssistantText.set(msgInfo.sessionID, "");
627
+ boundedSet(sessionAssistantMessageId, msgInfo.sessionID, msgInfo.id, MAX_SESSION_MAP_SIZE);
628
+ boundedSet(sessionAssistantText, msgInfo.sessionID, "", MAX_SESSION_MAP_SIZE);
567
629
  }
568
630
  }
569
631
  else if (event.type === "message.part.updated") {
@@ -577,7 +639,7 @@ export function createRagHooks(options) {
577
639
  const assistantMsgId = sessionAssistantMessageId.get(part.sessionID);
578
640
  if (assistantMsgId && part.messageID === assistantMsgId) {
579
641
  const existing = sessionAssistantText.get(part.sessionID) ?? "";
580
- sessionAssistantText.set(part.sessionID, existing + delta);
642
+ boundedSet(sessionAssistantText, part.sessionID, existing + delta, MAX_SESSION_MAP_SIZE);
581
643
  }
582
644
  }
583
645
  }
@@ -592,48 +654,61 @@ export function createRagHooks(options) {
592
654
  scope: "experimental.chat.system.transform",
593
655
  message: "system guidance injected",
594
656
  });
595
- const guidance = [
596
- "MANDATORY: OpenCodeRAG tools MUST be used before any code task:",
597
- "- `search_semantic(query)`: retrieve relevant code chunks. Call BEFORE planning, editing, or answering. Accepts `pathHints` and `languageHints`.",
598
- "- `get_file_skeleton(filePath)`: structural overview of a file. Call BEFORE reading any file.",
599
- "- `find_usages(symbolName)`: find all references. Call BEFORE editing any function, class, or variable.",
600
- "- `describe_image(filePath)`: describe an image file using a vision model. Call when user refers to a screenshot, diagram, or image.",
601
- "",
602
- "Decision tree ALWAYS follow this order:",
603
- "1. User mentions code behavior/architecture → `search_semantic(query)`",
604
- "2. User mentions a file path → `get_file_skeleton(filePath)` THEN `read` on specific lines",
605
- "3. User mentions a function/class/variable to edit → `find_usages(symbolName)` THEN `search_semantic` THEN `edit`",
606
- "4. User asks a code question → `search_semantic` to gather context before answering",
607
- "5. User asks about an image or visual asset → `describe_image(filePath)` to retrieve its generated description, then optionally `search_semantic` for related code",
608
- "",
609
- "Proactive triggers you MUST call these tools when:",
610
- "- User asks about code behavior, architecture, or implementation details",
611
- "- User asks to edit, refactor, or fix code — call `find_usages` first",
612
- "- User references files or functions you haven't read yet",
613
- "- User says \"find\", \"search\", \"look up\", \"where is\", \"how does\"",
614
- "- User refers to an image, screenshot, diagram, or visual asset",
615
- "- Before answering ANY code-related question, retrieve context first",
616
- "- Before reading ANY file, call `get_file_skeleton` to orient first",
617
- "",
618
- "Anti-patterns NEVER do these:",
619
- "- Reading full files without calling `get_file_skeleton` first (wastes tokens)",
620
- "- Editing a function without calling `find_usages` first (breaks call sites)",
621
- "- Answering code questions without calling `search_semantic` first (you guess at behavior)",
622
- "- Using `grep`/`glob` when `search_semantic` would find the answer faster",
623
- "- Treating image files as text — use `describe_image` instead of reading raw bytes",
624
- ];
625
- output.system.unshift(guidance.join("\n"));
626
- // Inject update notification if available
657
+ const cfg = getEffectiveCfg();
658
+ if (cfg.openCode.injectSystemPrompt !== false) {
659
+ const guidance = [
660
+ "MANDATORY: OpenCodeRAG tools MUST be used before any code task:",
661
+ "- `search_semantic(query)`: retrieve relevant code chunks. Call BEFORE planning, editing, or answering. Accepts `pathHints` and `languageHints`.",
662
+ "- `get_file_skeleton(filePath)`: structural overview of a file. Call BEFORE reading any file.",
663
+ "- `find_usages(symbolName)`: find all references. Call BEFORE editing any function, class, or variable.",
664
+ "- `describe_image(filePath)`: describe an image file using a vision model. Call when user refers to a screenshot, diagram, or image.",
665
+ "",
666
+ "Decision tree ALWAYS follow this order:",
667
+ "1. User mentions code behavior/architecture → `search_semantic(query)`",
668
+ "2. User mentions a file path → `get_file_skeleton(filePath)` THEN `read` on specific lines",
669
+ "3. User mentions a function/class/variable to edit → `find_usages(symbolName)` THEN `search_semantic` THEN `edit`",
670
+ "4. User asks a code question → `search_semantic` to gather context before answering",
671
+ "5. User asks about an image or visual asset → `describe_image(filePath)` to retrieve its generated description, then optionally `search_semantic` for related code",
672
+ "",
673
+ "Proactive triggers you MUST call these tools when:",
674
+ "- User asks about code behavior, architecture, or implementation details",
675
+ "- User asks to edit, refactor, or fix code call `find_usages` first",
676
+ "- User references files or functions you haven't read yet",
677
+ "- User says \"find\", \"search\", \"look up\", \"where is\", \"how does\"",
678
+ "- User refers to an image, screenshot, diagram, or visual asset",
679
+ "- Before answering ANY code-related question, retrieve context first",
680
+ "- Before reading ANY file, call `get_file_skeleton` to orient first",
681
+ "",
682
+ "Anti-patterns NEVER do these:",
683
+ "- Reading full files without calling `get_file_skeleton` first (wastes tokens)",
684
+ "- Editing a function without calling `find_usages` first (breaks call sites)",
685
+ "- Answering code questions without calling `search_semantic` first (you guess at behavior)",
686
+ "- Using `grep`/`glob` when `search_semantic` would find the answer faster",
687
+ "- Treating image files as text — use `describe_image` instead of reading raw bytes",
688
+ ];
689
+ output.system.unshift(guidance.join("\n"));
690
+ }
691
+ // Inject a one-time update prompt so the agent asks the user to install.
692
+ // Fires only once per workspace per session to avoid nagging on every turn.
627
693
  const updateInfo = pendingUpdateInfo.get(options.worktree);
628
- if (updateInfo) {
629
- output.system.unshift(`OpenCodeRAG update available: ${updateInfo.currentVersion} → ${updateInfo.latestVersion}. ` +
630
- `Run \`opencode-rag update\` to install.`);
694
+ if (updateInfo && !notifiedUpdateDirs.has(options.worktree)) {
695
+ notifiedUpdateDirs.add(options.worktree);
696
+ pendingUpdateInfo.delete(options.worktree);
697
+ output.system.unshift(`A newer OpenCodeRAG version is available: ${updateInfo.currentVersion} → ${updateInfo.latestVersion}. ` +
698
+ `At the very start of your next response, briefly tell the user a new version is available and ask whether ` +
699
+ `they would like you to install it. If they agree, run \`opencode-rag update\` via the bash tool, then tell ` +
700
+ `them to restart OpenCode to load the new version. Do not mention this notice again.`);
631
701
  }
632
702
  // Inject documentation mode system prompt if enabled
633
703
  const docMode = getEffectiveCfg().documentationMode;
634
704
  if (docMode?.enabled && docMode.systemPrompt) {
635
705
  output.system.unshift(docMode.systemPrompt);
636
706
  }
707
+ // Inject wiki mode system prompt if enabled
708
+ const wikiMode = getEffectiveCfg().wikiMode;
709
+ if (wikiMode?.enabled && wikiMode.systemPrompt) {
710
+ output.system.unshift(wikiMode.systemPrompt);
711
+ }
637
712
  },
638
713
  async "chat.message"(input, output) {
639
714
  try {
@@ -650,7 +725,7 @@ export function createRagHooks(options) {
650
725
  });
651
726
  if (text.length === 0)
652
727
  return;
653
- sessionLastMessage.set(input.sessionID, text);
728
+ boundedSet(sessionLastMessage, input.sessionID, text, MAX_SESSION_MAP_SIZE);
654
729
  // Handle /doc slash command
655
730
  if (text.startsWith("/doc")) {
656
731
  const docMode = getEffectiveCfg().documentationMode;
@@ -735,6 +810,64 @@ export function createRagHooks(options) {
735
810
  }
736
811
  return;
737
812
  }
813
+ // Handle /wiki slash command
814
+ if (text.startsWith("/wiki")) {
815
+ const wikiMode = getEffectiveCfg().wikiMode;
816
+ if (!wikiMode?.enabled) {
817
+ const parts = output?.parts ?? output?.message?.parts;
818
+ if (Array.isArray(parts) && parts.length > 0) {
819
+ const first = parts[0];
820
+ if (typeof first.text === "string") {
821
+ parts[0] = { ...first, text: "Wiki mode is not enabled. Set `wikiMode.enabled` to `true` in opencode-rag.json." };
822
+ }
823
+ }
824
+ return;
825
+ }
826
+ const arg = text.slice(5).trim().toLowerCase();
827
+ const wikiDir = path.join(options.worktree, ".opencode", "wiki");
828
+ const lines = [];
829
+ if (arg === "lint") {
830
+ lines.push("## Wiki Lint", "", "Run a health check on the wiki. Check for:", "- Orphan pages with no inbound [[links]]", "- Stale pages whose sourceRefs files changed since lastReviewed", "- Contradictions between pages", "- Important concepts mentioned but lacking their own page", "", "Append findings to .opencode/wiki/log.md and fix any issues found.");
831
+ }
832
+ else if (arg === "seed") {
833
+ lines.push("## Wiki Seed", "", "Generate the initial wiki from existing sources:", "1. Read README and top-level docs for project overview → create overview page", "2. Scan file structure with get_file_skeleton for major modules → create entity pages", "3. Identify common conventions from code patterns → create concept pages", "4. Create .opencode/wiki/index.md listing all pages", "5. Create .opencode/wiki/log.md with initial entry", "6. Keep pages focused — one concept per page, short prose, cross-referenced with [[links]]");
834
+ }
835
+ else {
836
+ if (existsSync(wikiDir)) {
837
+ const entries = readdirSync(wikiDir);
838
+ const pages = entries.filter((e) => e.endsWith(".md") && e !== "log.md").length;
839
+ const hasIndex = entries.includes("index.md");
840
+ const hasLog = entries.includes("log.md");
841
+ const dirs = entries.filter((e) => !e.includes(".")).length;
842
+ let lastLogEntry = "";
843
+ const logPath = path.join(wikiDir, "log.md");
844
+ if (hasLog && existsSync(logPath)) {
845
+ const logContent = readFileSync(logPath, "utf-8");
846
+ const logLines = logContent.trim().split("\n").filter((l) => l.startsWith("## ["));
847
+ if (logLines.length > 0) {
848
+ lastLogEntry = logLines[logLines.length - 1] ?? "";
849
+ }
850
+ }
851
+ lines.push("## Wiki Status", "", `Wiki: \`.opencode/wiki/\``, `Pages: ~${pages} (.md files)`, `Subdirectories: \`${dirs}\``, `Index: ${hasIndex ? "yes" : "no"} | Log: ${hasLog ? "yes" : "no"}`);
852
+ if (lastLogEntry) {
853
+ lines.push(`Latest log: \`${lastLogEntry}\``);
854
+ }
855
+ lines.push("", "Commands:", "- `/wiki` — show this status", "- `/wiki lint` — health-check the wiki", "- `/wiki seed` — generate initial wiki pages from the codebase");
856
+ }
857
+ else {
858
+ lines.push("## Wiki Status", "", "No wiki yet at `.opencode/wiki/`.", "Use `/wiki seed` to generate the initial wiki from existing sources.");
859
+ }
860
+ }
861
+ const wikiMsg = lines.join("\n");
862
+ const parts = output?.parts ?? output?.message?.parts;
863
+ if (Array.isArray(parts) && parts.length > 0) {
864
+ const first = parts[0];
865
+ if (typeof first.text === "string") {
866
+ parts[0] = { ...first, text: wikiMsg };
867
+ }
868
+ }
869
+ return;
870
+ }
738
871
  // Handle hotkey-triggered RAG injection (Ctrl+Enter / Ctrl+Alt+Enter)
739
872
  const pendingInjection = consumePendingRagInjection(options.storePath);
740
873
  if (pendingInjection) {
@@ -1025,29 +1158,52 @@ export const ragPlugin = async (input, _options) => {
1025
1158
  }
1026
1159
  mcpServers.delete(input.directory);
1027
1160
  }
1161
+ // Clean up stale config cache and pending update info for this directory
1162
+ configCache.delete(input.directory);
1163
+ pendingUpdateInfo.delete(input.directory);
1164
+ notifiedUpdateDirs.delete(input.directory);
1165
+ // Clean up idle HTTP sockets from previous provider connections
1166
+ destroyAllPooledConnections();
1028
1167
  appendDebugLog(logFilePath, {
1029
1168
  scope: "plugin",
1030
1169
  message: `OpenCode plugin enabled for ${input.directory}`,
1031
1170
  }, logLevel);
1032
- // Probe vector dimension and create store with correct dimension
1171
+ // Use cached dimension from config if available (avoids blocking startup with an API call)
1172
+ // If not set, probe the embedding provider once and persist the result.
1033
1173
  const embedder = createEmbedder(effectiveCfg);
1034
- let vectorDimension = 384;
1035
- try {
1036
- const probe = await embedder.embed(["dimension-probe"], "query");
1037
- if (probe && probe[0] && probe[0].length > 0 && typeof probe[0][0] === "number") {
1038
- vectorDimension = probe[0].length;
1039
- }
1174
+ let vectorDimension = effectiveCfg.embedding.vectorDimension;
1175
+ if (vectorDimension && vectorDimension > 0) {
1040
1176
  appendDebugLog(logFilePath, {
1041
1177
  scope: "plugin",
1042
- message: `Vector dimension: ${vectorDimension}`,
1178
+ message: `Vector dimension: ${vectorDimension} (cached in config)`,
1043
1179
  }, logLevel);
1044
1180
  }
1045
- catch (err) {
1046
- appendDebugLog(logFilePath, {
1047
- scope: "plugin",
1048
- message: `Dimension probe failed, falling back to ${vectorDimension}`,
1049
- error: err,
1050
- }, logLevel);
1181
+ else {
1182
+ vectorDimension = 384;
1183
+ try {
1184
+ const probe = await embedder.embed(["dimension-probe"], "query");
1185
+ if (probe && probe[0] && probe[0].length > 0 && typeof probe[0][0] === "number") {
1186
+ vectorDimension = probe[0].length;
1187
+ const configPath = findConfigFile(input.directory);
1188
+ if (configPath) {
1189
+ try {
1190
+ persistProbedDimension(configPath, vectorDimension);
1191
+ }
1192
+ catch { /* best-effort */ }
1193
+ }
1194
+ }
1195
+ appendDebugLog(logFilePath, {
1196
+ scope: "plugin",
1197
+ message: `Vector dimension: ${vectorDimension}`,
1198
+ }, logLevel);
1199
+ }
1200
+ catch (err) {
1201
+ appendDebugLog(logFilePath, {
1202
+ scope: "plugin",
1203
+ message: `Dimension probe failed, falling back to ${vectorDimension}`,
1204
+ error: err,
1205
+ }, logLevel);
1206
+ }
1051
1207
  }
1052
1208
  const store = createVectorStore(effectiveCfg, storePath, vectorDimension);
1053
1209
  // Load or create keyword index for hybrid search
@@ -1081,6 +1237,7 @@ export const ragPlugin = async (input, _options) => {
1081
1237
  logLevel,
1082
1238
  keywordIndex,
1083
1239
  descriptionProvider,
1240
+ dimension: vectorDimension,
1084
1241
  });
1085
1242
  backgroundIndexers.set(input.directory, indexer);
1086
1243
  }
@@ -1103,18 +1260,13 @@ export const ragPlugin = async (input, _options) => {
1103
1260
  const mcpInstance = startMcpServerProcess(input.directory, logFilePath, logLevel);
1104
1261
  mcpServers.set(input.directory, mcpInstance);
1105
1262
  }
1106
- // Auto-update check (non-blocking, best-effort)
1263
+ // Auto-update check (non-blocking, best-effort). On by default; can be
1264
+ // disabled via `autoUpdate.enabled: false` in opencode-rag.json. When a newer
1265
+ // release is found it is stored and surfaced as a one-time install prompt via
1266
+ // the system transform hook (see createRagHooks).
1107
1267
  const autoUpdateCfg = effectiveCfg.autoUpdate;
1108
1268
  if (autoUpdateCfg?.enabled) {
1109
- const currentVersion = (() => {
1110
- try {
1111
- const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1112
- return JSON.parse(readFileSync(pkgPath, "utf-8")).version;
1113
- }
1114
- catch {
1115
- return "0.0.0";
1116
- }
1117
- })();
1269
+ const currentVersion = getCurrentVersion();
1118
1270
  checkForUpdate(currentVersion)
1119
1271
  .then((info) => {
1120
1272
  if (info.updateAvailable) {
@@ -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>;
@@ -124,7 +124,7 @@ export class KeywordIndex {
124
124
  }
125
125
  }
126
126
  }
127
- search(query, topK) {
127
+ search(query, topK, filter) {
128
128
  if (this.chunkMap.size === 0)
129
129
  return [];
130
130
  const queryTokens = tokenize(query);
@@ -144,6 +144,7 @@ export class KeywordIndex {
144
144
  }
145
145
  }
146
146
  const sorted = [...scores.entries()]
147
+ .filter(([id]) => matchesFilter(this.chunkMap.get(id), filter))
147
148
  .sort((a, b) => b[1] - a[1])
148
149
  .slice(0, topK);
149
150
  return sorted.map(([chunkId, score]) => {
@@ -151,6 +152,10 @@ export class KeywordIndex {
151
152
  return { chunk, score };
152
153
  });
153
154
  }
155
+ close() {
156
+ this.invertedIndex.clear();
157
+ this.chunkMap.clear();
158
+ }
154
159
  clear() {
155
160
  this.invertedIndex.clear();
156
161
  this.chunkMap.clear();
@@ -232,4 +237,23 @@ export class KeywordIndex {
232
237
  await writeFile(targetPath, JSON.stringify({ version: INDEX_VERSION, tokens: [], chunkMap: {} }), "utf-8");
233
238
  }
234
239
  }
240
+ function globMatch(pattern, filePath) {
241
+ const GLOBSTAR = "\x00GS\x00";
242
+ let re = pattern.replace(/\*\*/g, GLOBSTAR);
243
+ re = re.replace(/([.+^${}()|[\]\\])/g, "\\$1");
244
+ re = re.replace(new RegExp(GLOBSTAR, "g"), ".*");
245
+ re = re.replace(/\*/g, "[^/]*");
246
+ re = re.replace(/\?/g, ".");
247
+ return new RegExp("^" + re + "$").test(filePath);
248
+ }
249
+ function matchesFilter(chunk, filter) {
250
+ if (!filter)
251
+ return true;
252
+ if (filter.languages?.length && !filter.languages.includes(chunk.metadata.language))
253
+ return false;
254
+ if (filter.pathPatterns?.length) {
255
+ return filter.pathPatterns.some((p) => globMatch(p, chunk.metadata.filePath));
256
+ }
257
+ return true;
258
+ }
235
259
  //# sourceMappingURL=keyword-index.js.map
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * @fileoverview Performs hybrid vector-keyword retrieval with configurable scoring and explanation.
3
3
  */
4
- import type { EmbeddingProvider, KeywordIndex, VectorStore, SearchResult } from "../core/interfaces.js";
4
+ import type { EmbeddingProvider, KeywordIndex, VectorStore, SearchResult, MetadataFilter } from "../core/interfaces.js";
5
5
  /** Options controlling the retrieval behavior. */
6
6
  export interface RetrieveOptions {
7
7
  topK?: number;
8
8
  minScore?: number;
9
9
  keywordIndex?: KeywordIndex;
10
10
  keywordWeight?: number;
11
+ /** Whether hybrid search is enabled. When false, keyword index is ignored. */
12
+ hybridEnabled?: boolean;
11
13
  queryPrefix?: string;
12
14
  explain?: boolean;
15
+ filter?: MetadataFilter;
13
16
  }
14
17
  /**
15
18
  * Perform hybrid vector-keyword retrieval with configurable scoring.
@@ -2,6 +2,9 @@
2
2
  * We request extra results up-front, then after hybrid fusion + minScore filtering,
3
3
  * we slice back to the requested topK. */
4
4
  const FETCH_OVERFETCH_FACTOR = 3;
5
+ const RRF_K = 60;
6
+ /** Multiply raw RRF scores by (K+1) to normalize to ~[0,1]. */
7
+ const RRF_NORMALIZE = RRF_K + 1;
5
8
  /**
6
9
  * Perform hybrid vector-keyword retrieval with configurable scoring.
7
10
  *
@@ -28,13 +31,13 @@ export async function retrieve(query, embedder, store, options = {}) {
28
31
  if (typeof embedding[0] !== "number") {
29
32
  return [];
30
33
  }
31
- const vectorResults = await store.search(embedding, topK * FETCH_OVERFETCH_FACTOR);
34
+ const vectorResults = await store.searchWithFilter(embedding, topK * FETCH_OVERFETCH_FACTOR, options.filter);
32
35
  let keywordResults = [];
33
- if (options.keywordIndex) {
34
- keywordResults = options.keywordIndex.search(query, topK * FETCH_OVERFETCH_FACTOR);
36
+ if (options.keywordIndex && options.hybridEnabled !== false) {
37
+ keywordResults = options.keywordIndex.search(query, topK * FETCH_OVERFETCH_FACTOR, options.filter);
35
38
  }
36
39
  if (keywordResults.length === 0) {
37
- const filtered = vectorResults.filter((r) => r.score >= minScore);
40
+ const filtered = vectorResults.filter((r) => r.score >= minScore).slice(0, topK);
38
41
  if (options.explain) {
39
42
  const kw = options.keywordWeight ?? 0.4;
40
43
  for (const r of filtered) {
@@ -51,65 +54,40 @@ export async function retrieve(query, embedder, store, options = {}) {
51
54
  }
52
55
  return filtered;
53
56
  }
54
- const kwTopScore = keywordResults.length > 0 ? keywordResults[0].score : 1;
55
- const combined = new Map();
56
- for (const r of vectorResults) {
57
- combined.set(r.chunk.id, {
58
- chunk: r.chunk,
59
- vScore: r.score,
60
- kScore: 0,
61
- rawVScore: r.score,
62
- rawKScore: 0,
63
- });
64
- }
65
- for (const r of keywordResults) {
66
- const existing = combined.get(r.chunk.id);
67
- const normalizedK = kwTopScore > 0 ? r.score / kwTopScore : 0;
68
- if (existing) {
69
- existing.kScore = normalizedK;
70
- existing.rawKScore = r.score;
71
- }
72
- else {
73
- combined.set(r.chunk.id, {
74
- chunk: r.chunk,
75
- vScore: 0,
76
- kScore: normalizedK,
77
- rawVScore: 0,
78
- rawKScore: r.score,
79
- });
80
- }
81
- }
57
+ const vRank = new Map(vectorResults.map((r, i) => [r.chunk.id, i]));
58
+ const kRank = new Map(keywordResults.map((r, i) => [r.chunk.id, i]));
59
+ const chunkById = new Map();
60
+ for (const r of vectorResults)
61
+ chunkById.set(r.chunk.id, r);
62
+ for (const r of keywordResults)
63
+ if (!chunkById.has(r.chunk.id))
64
+ chunkById.set(r.chunk.id, r);
82
65
  const kw = options.keywordWeight ?? 0.4;
83
- const combinedResults = [...combined.values()]
84
- .map((entry) => {
85
- const hasVector = entry.vScore > 0;
86
- const hasKeyword = entry.kScore > 0;
87
- const score = hasVector && hasKeyword
88
- ? (1 - kw) * entry.vScore + kw * entry.kScore
89
- : hasVector
90
- ? entry.vScore
91
- : entry.kScore * 0.9;
92
- const result = {
93
- chunk: entry.chunk,
94
- score,
95
- };
66
+ const allIds = new Set([...vRank.keys(), ...kRank.keys()]);
67
+ const combinedResults = [...allIds].map((id) => {
68
+ const vR = vRank.get(id);
69
+ const kR = kRank.get(id);
70
+ const vContrib = vR !== undefined ? ((1 - kw) * RRF_NORMALIZE) / (RRF_K + vR + 1) : 0;
71
+ const kContrib = kR !== undefined ? (kw * RRF_NORMALIZE) / (RRF_K + kR + 1) : 0;
72
+ const score = vContrib + kContrib;
73
+ const result = { chunk: chunkById.get(id).chunk, score };
96
74
  if (options.explain) {
97
- const explanation = {
75
+ result.explanation = {
98
76
  scoreBreakdown: {
99
- vectorScore: entry.vScore,
100
- keywordScore: entry.kScore,
101
- rawVectorScore: entry.rawVScore,
102
- rawKeywordScore: entry.rawKScore,
77
+ vectorScore: vContrib,
78
+ keywordScore: kContrib,
79
+ rawVectorScore: vR !== undefined ? vectorResults[vR].score : 0,
80
+ rawKeywordScore: kR !== undefined ? keywordResults[kR].score : 0,
103
81
  keywordWeight: kw,
82
+ vectorRank: vR,
83
+ keywordRank: kR,
104
84
  },
105
85
  };
106
- if (options.keywordIndex && entry.rawKScore > 0) {
107
- const terms = options.keywordIndex.getMatchedTerms(query, entry.chunk.id);
108
- if (terms.length > 0) {
109
- explanation.matchedTerms = terms;
110
- }
86
+ if (options.keywordIndex && kR !== undefined) {
87
+ const terms = options.keywordIndex.getMatchedTerms(query, id);
88
+ if (terms.length > 0)
89
+ result.explanation.matchedTerms = terms;
111
90
  }
112
- result.explanation = explanation;
113
91
  }
114
92
  return result;
115
93
  })