opencode-rag-plugin 1.19.3 → 1.19.5

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 (82) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/api.js +2 -0
  3. package/dist/chunker/base.js +19 -5
  4. package/dist/chunker/factory.js +27 -9
  5. package/dist/chunker/grammar.d.ts +18 -1
  6. package/dist/chunker/grammar.js +48 -10
  7. package/dist/chunker/pdf.js +30 -14
  8. package/dist/cli/commands/init-helpers.js +15 -2
  9. package/dist/cli/commands/init.js +31 -21
  10. package/dist/cli/commands/query.js +2 -0
  11. package/dist/cli/commands/quirk.js +9 -3
  12. package/dist/cli/commands/setup.js +5 -2
  13. package/dist/cli/commands/status.js +14 -5
  14. package/dist/cli/commands/ui.js +23 -9
  15. package/dist/cli/commands/update.js +4 -5
  16. package/dist/cli/format.d.ts +5 -2
  17. package/dist/cli/format.js +14 -5
  18. package/dist/content/image.js +33 -11
  19. package/dist/content/reader.js +79 -17
  20. package/dist/core/bootstrap.js +10 -3
  21. package/dist/core/config.js +31 -0
  22. package/dist/core/desc-cache.d.ts +8 -2
  23. package/dist/core/desc-cache.js +10 -3
  24. package/dist/core/doc-progress.js +5 -2
  25. package/dist/core/interfaces.d.ts +8 -0
  26. package/dist/core/interfaces.js +8 -1
  27. package/dist/core/provider-defaults.d.ts +2 -0
  28. package/dist/core/provider-defaults.js +19 -4
  29. package/dist/core/runtime-overrides.d.ts +0 -6
  30. package/dist/core/version-check.d.ts +5 -0
  31. package/dist/core/version-check.js +8 -2
  32. package/dist/describer/anthropic.d.ts +2 -2
  33. package/dist/describer/anthropic.js +19 -5
  34. package/dist/describer/describer.js +15 -2
  35. package/dist/describer/gemini.js +25 -10
  36. package/dist/embedder/factory.d.ts +5 -3
  37. package/dist/embedder/factory.js +41 -8
  38. package/dist/embedder/health.js +19 -19
  39. package/dist/embedder/http.d.ts +14 -1
  40. package/dist/embedder/http.js +60 -6
  41. package/dist/eval/session-logger.js +7 -0
  42. package/dist/eval/storage.js +8 -0
  43. package/dist/indexer/git-diff.d.ts +1 -1
  44. package/dist/indexer/git-diff.js +5 -1
  45. package/dist/indexer/pipeline.js +421 -344
  46. package/dist/indexer/stats.d.ts +2 -0
  47. package/dist/indexer/stats.js +1 -0
  48. package/dist/indexer/watch.js +8 -1
  49. package/dist/indexer/worker.js +21 -0
  50. package/dist/mcp/cli.js +4 -0
  51. package/dist/mcp/handlers.d.ts +1 -1
  52. package/dist/mcp/handlers.js +23 -6
  53. package/dist/mcp/server.js +3 -0
  54. package/dist/opencode/create-read-tool.d.ts +1 -1
  55. package/dist/opencode/create-read-tool.js +17 -5
  56. package/dist/opencode/tool-args.js +23 -1
  57. package/dist/opencode/tools.d.ts +1 -1
  58. package/dist/opencode/tools.js +3 -1
  59. package/dist/plugin.d.ts +1 -1
  60. package/dist/plugin.js +69 -152
  61. package/dist/quirks/auto-capture.js +5 -0
  62. package/dist/quirks/quirk-store.d.ts +1 -1
  63. package/dist/quirks/quirk-store.js +56 -17
  64. package/dist/retriever/context-optimizer.js +18 -4
  65. package/dist/retriever/keyword-index.d.ts +2 -0
  66. package/dist/retriever/keyword-index.js +38 -4
  67. package/dist/retriever/retriever.js +6 -1
  68. package/dist/tui.js +41 -4
  69. package/dist/vectorstore/lancedb.d.ts +25 -1
  70. package/dist/vectorstore/lancedb.js +157 -11
  71. package/dist/vectorstore/memory.js +5 -1
  72. package/dist/watcher.js +30 -4
  73. package/dist/web/api.d.ts +6 -2
  74. package/dist/web/api.js +198 -70
  75. package/dist/web/server.d.ts +2 -0
  76. package/dist/web/server.js +66 -28
  77. package/dist/web/static.d.ts +5 -2
  78. package/dist/web/static.js +9 -5
  79. package/dist/web/ui/assets/index-BDPYdtA1.js +3 -0
  80. package/dist/web/ui/index.html +1 -1
  81. package/package.json +1 -1
  82. package/dist/web/ui/assets/index-CJBvt6e0.js +0 -3
package/dist/api.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { type IndexRunStats } from "./indexer.js";
6
6
  import { type WorkspaceFile } from "./content/reader.js";
7
- import type { SearchResult } from "./core/interfaces.js";
7
+ import { type SearchResult } from "./core/interfaces.js";
8
8
  /** Options controlling a semantic search query. */
9
9
  export interface SearchOptions {
10
10
  /** Working directory to resolve relative paths against. */
package/dist/api.js CHANGED
@@ -7,6 +7,7 @@ import { retrieve } from "./retriever/retriever.js";
7
7
  import { optimizeContext, DEFAULT_CONTEXT_OPTIMIZATION } from "./retriever/context-optimizer.js";
8
8
  import { runIndexPass } from "./indexer.js";
9
9
  import { scanWorkspaceFiles } from "./content/reader.js";
10
+ import { CODE_SEARCH_FILTER } from "./core/interfaces.js";
10
11
  import { destroyAllPooledConnections } from "./embedder/http.js";
11
12
  /**
12
13
  * Format a list of search results into a human-readable markdown block.
@@ -55,6 +56,7 @@ export async function search(query, options = {}) {
55
56
  filter: {
56
57
  pathPatterns: options.pathHints,
57
58
  languages: options.languageHints,
59
+ kinds: CODE_SEARCH_FILTER.kinds,
58
60
  },
59
61
  });
60
62
  const optCfg = ctx.config.retrieval.contextOptimization ?? DEFAULT_CONTEXT_OPTIMIZATION;
@@ -2,7 +2,7 @@
2
2
  * @fileoverview Abstract base class for tree-sitter based language chunkers.
3
3
  */
4
4
  import { Parser } from "web-tree-sitter";
5
- import { loadLanguage, loadLanguageFromPath, walkTree } from "./grammar.js";
5
+ import { loadLanguage, loadLanguageFromPath, walkTree, buildByteOffsetMap } from "./grammar.js";
6
6
  import { uuid } from "./uuid.js";
7
7
  /** Module-level parser pool shared across all TreeSitterChunker instances. */
8
8
  const parserPool = new Map();
@@ -45,8 +45,15 @@ export class TreeSitterChunker {
45
45
  if (!tree) {
46
46
  return [];
47
47
  }
48
- const nodes = walkTree(tree.rootNode, types, content);
49
- tree.delete();
48
+ // tree-sitter ranges are UTF-8 byte offsets; translate for slicing
49
+ const offsetMap = buildByteOffsetMap(content);
50
+ let nodes;
51
+ try {
52
+ nodes = walkTree(tree.rootNode, types, content, 10, 0, offsetMap);
53
+ }
54
+ finally {
55
+ tree.delete();
56
+ }
50
57
  return nodes.map((node) => ({
51
58
  id: uuid(),
52
59
  content: node.text,
@@ -108,8 +115,15 @@ export class TreeSitterChunker {
108
115
  if (!tree) {
109
116
  return [];
110
117
  }
111
- const nodes = walkTree(tree.rootNode, this.nodeTypes, content);
112
- tree.delete();
118
+ // tree-sitter ranges are UTF-8 byte offsets; translate for slicing
119
+ const offsetMap = buildByteOffsetMap(content);
120
+ let nodes;
121
+ try {
122
+ nodes = walkTree(tree.rootNode, this.nodeTypes, content, 10, 0, offsetMap);
123
+ }
124
+ finally {
125
+ tree.delete();
126
+ }
113
127
  return nodes.map((node) => ({
114
128
  id: uuid(),
115
129
  content: node.text,
@@ -174,14 +174,26 @@ function splitOversized(chunks, filePath) {
174
174
  }
175
175
  return result;
176
176
  }
177
- /** Apply config overrides to a chunker before it's used. */
178
- function applyChunkerConfig(chunker, _filePath, options) {
179
- if (options?.maxSvgSizeBytes && chunker instanceof TreeSitterChunker) {
180
- const ext = _filePath.toLowerCase();
181
- if (ext.endsWith(".svg") || ext.endsWith(".xml") || ext.endsWith(".csproj")) {
182
- chunker.maxContentBytes = options.maxSvgSizeBytes;
183
- }
184
- }
177
+ /**
178
+ * Wrap a chunker with a per-file byte limit.
179
+ *
180
+ * Previously the limit was assigned onto the shared chunker singleton
181
+ * (`chunker.maxContentBytes = ...`) under pLimit concurrency one file's
182
+ * limit could bleed into unrelated concurrent files, and it was never
183
+ * reset, so a later large XML/SVG file was silently skipped.
184
+ */
185
+ function withByteLimit(chunker, limit) {
186
+ const language = chunker.language;
187
+ return {
188
+ language,
189
+ fileExtensions: chunker.fileExtensions,
190
+ async chunk(filePath, content) {
191
+ if (limit > 0 && Buffer.byteLength(content, "utf-8") > limit) {
192
+ throw new Error(`File exceeds ${limit} byte limit for ${language} chunker`);
193
+ }
194
+ return chunker.chunk(filePath, content);
195
+ },
196
+ };
185
197
  }
186
198
  /**
187
199
  * Chunk a file by looking up its registered chunker, applying node-type
@@ -203,7 +215,13 @@ export async function chunkFile(filePath, content, nodeTypesOverrides, options)
203
215
  chunker = chunker.withNodeTypes(new Set(overrideTypes));
204
216
  }
205
217
  }
206
- applyChunkerConfig(chunker, filePath, options);
218
+ // Per-file byte limit (SVG/XML/csproj) without mutating the shared singleton
219
+ if (options?.maxSvgSizeBytes && chunker instanceof TreeSitterChunker) {
220
+ const ext = filePath.toLowerCase();
221
+ if (ext.endsWith(".svg") || ext.endsWith(".xml") || ext.endsWith(".csproj")) {
222
+ chunker = withByteLimit(chunker, options.maxSvgSizeBytes);
223
+ }
224
+ }
207
225
  const chunks = await chunker.chunk(filePath, content);
208
226
  if (chunks.length === 0) {
209
227
  return fallbackChunker.chunk(filePath, content);
@@ -57,6 +57,23 @@ export interface AstNode {
57
57
  */
58
58
  leadingDoc?: string;
59
59
  }
60
+ /**
61
+ * Build a byte-offset → UTF-16-index map for a source string.
62
+ *
63
+ * tree-sitter reports node ranges as UTF-8 BYTE offsets, but JavaScript
64
+ * `String.prototype.slice` counts UTF-16 code units. For any file containing
65
+ * multi-byte characters the two disagree, so slicing with raw node indices
66
+ * truncates/garbles chunk content and doc comments. Tree-sitter node
67
+ * boundaries always align to code-point boundaries, so an exact map from
68
+ * byte offset to UTF-16 index is sufficient.
69
+ *
70
+ * Returns `null` for pure-ASCII sources (byte offsets == UTF-16 offsets),
71
+ * which is the common case and avoids the allocation.
72
+ *
73
+ * @param source - The full source text.
74
+ * @returns A Map from UTF-8 byte offset to UTF-16 index, or `null` for ASCII.
75
+ */
76
+ export declare function buildByteOffsetMap(source: string): Map<number, number> | null;
60
77
  /**
61
78
  * Recursively walk a tree-sitter AST and collect nodes matching the given
62
79
  * type set.
@@ -75,4 +92,4 @@ export interface AstNode {
75
92
  * @param depth - Current recursion depth (internal, start at 0).
76
93
  * @returns An array of {@link AstNode} objects for matching declarations.
77
94
  */
78
- export declare function walkTree(node: Node, nodeTypes: Set<string>, source: string, maxDepth?: number, depth?: number): AstNode[];
95
+ export declare function walkTree(node: Node, nodeTypes: Set<string>, source: string, maxDepth?: number, depth?: number, offsetMap?: Map<number, number> | null): AstNode[];
@@ -120,6 +120,44 @@ const COMMENT_NODE_TYPES = new Set([
120
120
  "marginalia",
121
121
  "Comment",
122
122
  ]);
123
+ /**
124
+ * Build a byte-offset → UTF-16-index map for a source string.
125
+ *
126
+ * tree-sitter reports node ranges as UTF-8 BYTE offsets, but JavaScript
127
+ * `String.prototype.slice` counts UTF-16 code units. For any file containing
128
+ * multi-byte characters the two disagree, so slicing with raw node indices
129
+ * truncates/garbles chunk content and doc comments. Tree-sitter node
130
+ * boundaries always align to code-point boundaries, so an exact map from
131
+ * byte offset to UTF-16 index is sufficient.
132
+ *
133
+ * Returns `null` for pure-ASCII sources (byte offsets == UTF-16 offsets),
134
+ * which is the common case and avoids the allocation.
135
+ *
136
+ * @param source - The full source text.
137
+ * @returns A Map from UTF-8 byte offset to UTF-16 index, or `null` for ASCII.
138
+ */
139
+ export function buildByteOffsetMap(source) {
140
+ if (!/[\u0080-\uffff]/u.test(source))
141
+ return null;
142
+ const map = new Map();
143
+ let byteOffset = 0;
144
+ let utf16Index = 0;
145
+ for (const ch of source) {
146
+ map.set(byteOffset, utf16Index);
147
+ byteOffset += Buffer.byteLength(ch, "utf-8");
148
+ utf16Index += ch.length;
149
+ }
150
+ map.set(byteOffset, utf16Index);
151
+ return map;
152
+ }
153
+ /** Slice `source` by tree-sitter byte offsets, translating through the map when present. */
154
+ function sliceByBytes(source, startByte, endByte, offsetMap) {
155
+ if (!offsetMap)
156
+ return source.slice(startByte, endByte);
157
+ const start = offsetMap.get(startByte) ?? startByte;
158
+ const end = offsetMap.get(endByte) ?? endByte;
159
+ return source.slice(start, end);
160
+ }
123
161
  function cleanCommentText(text) {
124
162
  const lines = text.split("\n");
125
163
  if (text.startsWith("/*")) {
@@ -152,19 +190,19 @@ function cleanCommentText(text) {
152
190
  let cleaned = lines.map((line) => line.replace(prefix, ""));
153
191
  return cleaned.filter((l) => l.trim().length > 0).join("\n");
154
192
  }
155
- function extractLeadingComments(node, source) {
193
+ function extractLeadingComments(node, source, offsetMap) {
156
194
  const comments = [];
157
195
  let sibling = node.previousSibling;
158
196
  while (sibling) {
159
197
  if (COMMENT_NODE_TYPES.has(sibling.type)) {
160
- const raw = source.slice(sibling.startIndex, sibling.endIndex);
198
+ const raw = sliceByBytes(source, sibling.startIndex, sibling.endIndex, offsetMap);
161
199
  comments.unshift(cleanCommentText(raw));
162
200
  sibling = sibling.previousSibling;
163
201
  }
164
202
  else if (sibling.type === "expression_statement") {
165
203
  const firstChild = sibling.namedChildren[0];
166
204
  if (firstChild?.type === "string") {
167
- const raw = source.slice(firstChild.startIndex, firstChild.endIndex);
205
+ const raw = sliceByBytes(source, firstChild.startIndex, firstChild.endIndex, offsetMap);
168
206
  comments.unshift(cleanCommentText(raw));
169
207
  sibling = sibling.previousSibling;
170
208
  }
@@ -180,7 +218,7 @@ function extractLeadingComments(node, source) {
180
218
  return undefined;
181
219
  return comments.join("\n\n");
182
220
  }
183
- function extractDocstringFromBody(node, source) {
221
+ function extractDocstringFromBody(node, source, offsetMap) {
184
222
  if (node.type !== "function_definition" && node.type !== "class_definition") {
185
223
  return undefined;
186
224
  }
@@ -193,7 +231,7 @@ function extractDocstringFromBody(node, source) {
193
231
  const stringNode = firstStmt.namedChildren[0];
194
232
  if (!stringNode || stringNode.type !== "string")
195
233
  return undefined;
196
- return cleanCommentText(source.slice(stringNode.startIndex, stringNode.endIndex));
234
+ return cleanCommentText(sliceByBytes(source, stringNode.startIndex, stringNode.endIndex, offsetMap));
197
235
  }
198
236
  /**
199
237
  * Recursively walk a tree-sitter AST and collect nodes matching the given
@@ -213,16 +251,16 @@ function extractDocstringFromBody(node, source) {
213
251
  * @param depth - Current recursion depth (internal, start at 0).
214
252
  * @returns An array of {@link AstNode} objects for matching declarations.
215
253
  */
216
- export function walkTree(node, nodeTypes, source, maxDepth = 10, depth = 0) {
254
+ export function walkTree(node, nodeTypes, source, maxDepth = 10, depth = 0, offsetMap) {
217
255
  const results = [];
218
256
  if (nodeTypes.has(node.type) && depth > 0) {
219
- const leadingComments = extractLeadingComments(node, source);
220
- const bodyDocstring = extractDocstringFromBody(node, source);
257
+ const leadingComments = extractLeadingComments(node, source, offsetMap);
258
+ const bodyDocstring = extractDocstringFromBody(node, source, offsetMap);
221
259
  const leadingDoc = [leadingComments, bodyDocstring]
222
260
  .filter((d) => d !== undefined && d.length > 0)
223
261
  .join("\n\n");
224
262
  results.push({
225
- text: source.slice(node.startIndex, node.endIndex),
263
+ text: sliceByBytes(source, node.startIndex, node.endIndex, offsetMap),
226
264
  startLine: node.startPosition.row + 1,
227
265
  endLine: node.endPosition.row + 1,
228
266
  startIndex: node.startIndex,
@@ -234,7 +272,7 @@ export function walkTree(node, nodeTypes, source, maxDepth = 10, depth = 0) {
234
272
  }
235
273
  if (depth < maxDepth) {
236
274
  for (const child of node.children) {
237
- results.push(...walkTree(child, nodeTypes, source, maxDepth, depth + 1));
275
+ results.push(...walkTree(child, nodeTypes, source, maxDepth, depth + 1, offsetMap));
238
276
  }
239
277
  }
240
278
  return results;
@@ -22,19 +22,18 @@ function getStandardFontsUrl() {
22
22
  * Create a pdfjs-dist PDF document from a buffer.
23
23
  * Uses `@thednp/dommatrix` for DOMMatrix polyfill (lighter alternative to native canvas).
24
24
  * @param buffer - Raw buffer of the PDF file.
25
- * @returns A promise resolving to a pdfjs-dist PDFDocumentProxy.
25
+ * @returns The pdfjs LoadingTask for the document.
26
26
  */
27
- async function createPdfDocument(buffer) {
27
+ async function getPdfLoadingTask(buffer) {
28
28
  const { default: CSSMatrix } = await import("@thednp/dommatrix");
29
29
  globalThis.DOMMatrix ??= CSSMatrix;
30
30
  globalThis.DOMMatrixReadOnly ??= CSSMatrix;
31
31
  const { getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
32
- const loadingTask = getDocument({
32
+ return getDocument({
33
33
  data: new Uint8Array(buffer),
34
34
  standardFontDataUrl: getStandardFontsUrl(),
35
35
  verbosity: 0,
36
36
  });
37
- return loadingTask.promise;
38
37
  }
39
38
  /**
40
39
  * Extract the full text content from a PDF file buffer.
@@ -47,17 +46,34 @@ export async function extractPdfText(buffer) {
47
46
  if (buffer.length > MAX_PDF_SIZE_BYTES) {
48
47
  throw new Error(`PDF too large: ${(buffer.length / 1024 / 1024).toFixed(1)} MB (max 100 MB)`);
49
48
  }
50
- const pdf = await createPdfDocument(buffer);
51
- const texts = [];
52
- const pageCount = Math.min(pdf.numPages, MAX_PDF_PAGES);
53
- for (let i = 1; i <= pageCount; i++) {
54
- const page = await pdf.getPage(i);
55
- const content = await page.getTextContent();
56
- const textItems = content.items.filter((item) => typeof item === "object" && item !== null && "str" in item);
57
- const strings = textItems.map((item) => item.str);
58
- texts.push(strings.join(" "));
49
+ // loadingTask.destroy() releases the pdfjs worker/document resources —
50
+ // without it long-running watch/plugin processes leak them per PDF.
51
+ const loadingTask = await getPdfLoadingTask(buffer);
52
+ try {
53
+ const pdf = await loadingTask.promise;
54
+ const texts = [];
55
+ const pageCount = Math.min(pdf.numPages, MAX_PDF_PAGES);
56
+ for (let i = 1; i <= pageCount; i++) {
57
+ const page = await pdf.getPage(i);
58
+ try {
59
+ const content = await page.getTextContent();
60
+ const strings = [];
61
+ for (const item of content.items) {
62
+ if (typeof item === "object" && item !== null && "str" in item) {
63
+ strings.push(item.str);
64
+ }
65
+ }
66
+ texts.push(strings.join(" "));
67
+ }
68
+ finally {
69
+ page.cleanup();
70
+ }
71
+ }
72
+ return texts.join("\n\n");
73
+ }
74
+ finally {
75
+ await loadingTask.destroy().catch(() => { });
59
76
  }
60
- return texts.join("\n\n");
61
77
  }
62
78
  /**
63
79
  * Chunker for PDF documents (.pdf).
@@ -379,10 +379,23 @@ export async function installPluginFromGlobal(opencodeDir, packageName, skipInst
379
379
  if (existsSync(workspaceTarget)) {
380
380
  rmSync(workspaceTarget, { recursive: true, force: true });
381
381
  }
382
- // Create directory junction from workspace → global runtime
382
+ // Create directory junction from workspace → global runtime.
383
+ // Junction creation can throw (EPERM, cross-drive on Windows) — fall back
384
+ // to a copy instead of aborting `init`.
383
385
  console.log(` ${c.created("Linking:")} ${packageName} from global cache...`);
384
386
  mkdirSync(path.dirname(workspaceTarget), { recursive: true });
385
- createJunction(globalPluginDir, workspaceTarget);
387
+ try {
388
+ createJunction(globalPluginDir, workspaceTarget);
389
+ }
390
+ catch (err) {
391
+ console.log(` ${c.warn("Junction failed, falling back to copy...")}`);
392
+ rmSync(workspaceTarget, { recursive: true, force: true });
393
+ const { cpSync } = await import("node:fs");
394
+ mkdirSync(path.dirname(workspaceTarget), { recursive: true });
395
+ cpSync(globalPluginDir, workspaceTarget, { recursive: true });
396
+ console.log(` ${c.success("Copied:")} ${packageName} from global cache`);
397
+ return;
398
+ }
386
399
  const cliEntry = path.join(workspaceTarget, "dist", "cli.js");
387
400
  if (!existsSync(cliEntry)) {
388
401
  // Junction may not work (e.g. cross-drive on Windows) — fall back to copy
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import path from "node:path";
13
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from "node:fs";
14
- import { loadConfig } from "../../core/config.js";
14
+ import { loadConfig, findConfigFile } from "../../core/config.js";
15
15
  import { checkProviderHealth, pullOllamaModels } from "../../embedder/health.js";
16
16
  import { destroyAllPooledConnections } from "../../embedder/http.js";
17
17
  import { c } from "../format.js";
@@ -37,7 +37,10 @@ export function registerInitCommand(program) {
37
37
  try {
38
38
  const cwd = process.cwd();
39
39
  const packageMetadata = getPackageMetadata();
40
- const configPath = path.join(cwd, "opencode-rag.json");
40
+ // Use findConfigFile so an existing config in .opencode/rag.json (or
41
+ // .opencode/opencode-rag.json) is respected instead of being shadowed
42
+ // by a new root-level opencode-rag.json.
43
+ const configPath = findConfigFile(cwd) ?? path.join(cwd, "opencode-rag.json");
41
44
  const opencodeDir = path.join(cwd, ".opencode");
42
45
  const gitignorePath = path.join(opencodeDir, ".gitignore");
43
46
  const opencodeConfigPath = path.join(opencodeDir, "opencode.json");
@@ -253,27 +256,34 @@ export function registerInitCommand(program) {
253
256
  return { model: r.model, baseUrl: ragConfig.embedding.baseUrl, proxy: ragConfig.embedding.proxy };
254
257
  });
255
258
  console.log(`\n ${c.warn("Models not found:")} ${pullEntries.map((e) => e.model).join(", ")}`);
256
- const readline = await import("node:readline");
257
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
258
- const answer = await new Promise((resolve) => {
259
- rl.question(` Pull ${pullEntries.length === 1 ? "this model" : "these models"} now? (y/n) `, resolve);
260
- });
261
- rl.close();
262
- if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
263
- console.log();
264
- try {
265
- await pullOllamaModels(pullEntries, (model, line) => {
266
- console.log(` ${c.value(model)}: ${line}`);
267
- });
268
- console.log(`\n ${c.success("Models pulled successfully.")}`);
269
- }
270
- catch (err) {
271
- console.error(`\n ${c.error("Pull failed:")} ${err.message}`);
272
- console.log(` ${c.dim("Pull manually with: ollama pull <model>")}`);
273
- }
259
+ // Non-TTY guard: rl.question would block forever with a still-open
260
+ // stdin pipe (e.g. `echo y | opencode-rag init`, CI runners).
261
+ if (!process.stdin.isTTY) {
262
+ console.log(` ${c.dim("Non-interactive shell skipping. Pull manually with: ollama pull <model>")}`);
274
263
  }
275
264
  else {
276
- console.log(` ${c.dim("Skipped. Pull manually with: ollama pull <model>")}`);
265
+ const readline = await import("node:readline");
266
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
267
+ const answer = await new Promise((resolve) => {
268
+ rl.question(` Pull ${pullEntries.length === 1 ? "this model" : "these models"} now? (y/n) `, resolve);
269
+ });
270
+ rl.close();
271
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
272
+ console.log();
273
+ try {
274
+ await pullOllamaModels(pullEntries, (model, line) => {
275
+ console.log(` ${c.value(model)}: ${line}`);
276
+ });
277
+ console.log(`\n ${c.success("Models pulled successfully.")}`);
278
+ }
279
+ catch (err) {
280
+ console.error(`\n ${c.error("Pull failed:")} ${err.message}`);
281
+ console.log(` ${c.dim("Pull manually with: ollama pull <model>")}`);
282
+ }
283
+ }
284
+ else {
285
+ console.log(` ${c.dim("Skipped. Pull manually with: ollama pull <model>")}`);
286
+ }
277
287
  }
278
288
  }
279
289
  const hasErrors = results.some((r) => r.status === "error");
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import path from "node:path";
8
8
  import pc from "picocolors";
9
+ import { CODE_SEARCH_FILTER } from "../../core/interfaces.js";
9
10
  import { retrieve } from "../../retriever/retriever.js";
10
11
  import { c, resolveCliContext, cleanupContext, logCliError, logCliInfo, formatDuration } from "../format.js";
11
12
  import { optimizeContext, DEFAULT_CONTEXT_OPTIMIZATION } from "../../retriever/context-optimizer.js";
@@ -54,6 +55,7 @@ export function registerQueryCommand(program) {
54
55
  hybridEnabled: hybridCfg?.enabled,
55
56
  queryPrefix: config.embedding.queryPrefix,
56
57
  explain: options.explain ?? false,
58
+ filter: CODE_SEARCH_FILTER,
57
59
  });
58
60
  const optCfg = config.retrieval.contextOptimization ?? DEFAULT_CONTEXT_OPTIMIZATION;
59
61
  const results = optimizeContext(rawResults, { topK, config: optCfg });
@@ -16,6 +16,7 @@ export function registerQuirkCommand(program) {
16
16
  .option("-t, --type <type>", "quirk type: gotcha, preference, decision, environment-constraint")
17
17
  .option("--tag <tags...>", "tags for filtering")
18
18
  .option("--source-ref <path>", "source file path reference")
19
+ .option("-c, --config <path>", "path to config file")
19
20
  .action(async (content, options) => {
20
21
  try {
21
22
  const ctx = await resolveCliContext(options, resolveLogPath());
@@ -49,6 +50,11 @@ export function registerQuirkCommand(program) {
49
50
  .option("-c, --config <path>", "path to config file")
50
51
  .action(async (id, options) => {
51
52
  try {
53
+ const confidence = options.confidence;
54
+ if (confidence !== undefined && (Number.isNaN(confidence) || confidence < 0 || confidence > 1)) {
55
+ logCliError(resolveLogPath(), "quirk update", "Failed to update quirk: --confidence must be a number between 0 and 1");
56
+ process.exit(1);
57
+ }
52
58
  const ctx = await resolveCliContext(options, resolveLogPath());
53
59
  const { config, embedder, store, keywordIndex } = ctx;
54
60
  const tags = options.tag;
@@ -56,7 +62,7 @@ export function registerQuirkCommand(program) {
56
62
  content: options.content,
57
63
  quirkType: options.type,
58
64
  tags: tags ? (Array.isArray(tags) ? tags : [tags]) : undefined,
59
- confidence: options.confidence,
65
+ confidence,
60
66
  sourceRef: options.sourceRef,
61
67
  });
62
68
  logCliInfo(ctx.logFilePath, "quirk update", `\n${c.success("Quirk updated:")}`);
@@ -148,7 +154,7 @@ export function registerQuirkCommand(program) {
148
154
  const { config, embedder, store, keywordIndex } = ctx;
149
155
  const results = await recallQuirks({ embedder, store, keywordIndex: keywordIndex, cfg: config, storePath: ctx.storePath }, content, { topK: 5 });
150
156
  if (results.length > 0) {
151
- logCliInfo(ctx.logFilePath, "quirk test", c.success("\n Quirk has been appended:\n"));
157
+ logCliInfo(ctx.logFilePath, "quirk test", c.warn("\n Similar quirk(s) already exist — quirk has NOT been appended:\n"));
152
158
  for (const r of results) {
153
159
  const badge = r.chunk.metadata.quirkType ? `[${r.chunk.metadata.quirkType}] ` : "";
154
160
  const tags = r.chunk.metadata.tags?.length ? ` (${r.chunk.metadata.tags.join(", ")})` : "";
@@ -159,7 +165,7 @@ export function registerQuirkCommand(program) {
159
165
  }
160
166
  }
161
167
  else {
162
- logCliInfo(ctx.logFilePath, "quirk test", c.warn("\n No matching quirk found — quirk has not been appended\n"));
168
+ logCliInfo(ctx.logFilePath, "quirk test", c.success("\n No matching quirk found — safe to append\n"));
163
169
  }
164
170
  await cleanupContext(ctx);
165
171
  }
@@ -81,9 +81,12 @@ export function registerSetupCommand(program) {
81
81
  if (options.uninstall) {
82
82
  console.log(`\n${c.heading("Removing OpenCodeRAG runtime...")}\n`);
83
83
  removeIfExists(runtimePluginDir);
84
- removeIfExists(runtimeSdkDir);
84
+ // Only remove the @opencode-ai/plugin SDK package — the scope dir may
85
+ // be shared with other OpenCode plugins/tools.
86
+ removeIfExists(runtimeSdkPluginDir);
85
87
  removeIfExists(versionFile);
86
- console.log(` ${c.updated("Removed:")} ${c.file(runtimeDir)}`);
88
+ console.log(` ${c.updated("Removed:")} ${c.file(runtimePluginDir)}`);
89
+ console.log(` ${c.updated("Removed:")} ${c.file(runtimeSdkPluginDir)}`);
87
90
  console.log(`\n ${c.success("Done.")} Run ${c.file("npm uninstall -g opencode-rag-plugin")} to remove the global package.\n`);
88
91
  return;
89
92
  }
@@ -147,14 +147,23 @@ export function registerStatusCommand(program) {
147
147
  logCliInfo(logFilePath, "status", `${c.label("Runtime:")} ${c.warn("version unknown — run `opencode-rag setup`")}`);
148
148
  }
149
149
  }
150
- // Async GitHub update check (fire-and-forget, 5s timeout).
151
- // Runs unless autoUpdate is explicitly disabled.
150
+ // GitHub update check. Runs unless autoUpdate is explicitly disabled.
151
+ // Awaited (raced against 3s) so the result can actually print —
152
+ // process.exit(0) below would previously kill the promise before it
153
+ // resolved, making the Update: line dead code.
152
154
  if (config.autoUpdate?.enabled) {
153
- checkForUpdate(pkg.version).then((info) => {
154
- if (info.updateAvailable) {
155
+ try {
156
+ const info = await Promise.race([
157
+ checkForUpdate(pkg.version),
158
+ new Promise((resolve) => setTimeout(() => resolve(null), 3000)),
159
+ ]);
160
+ if (info && info.updateAvailable) {
155
161
  process.stdout.write(` ${c.label("Update:")} ${c.warn(`v${info.latestVersion} available — run \`opencode-rag update\` to install`)}\n`);
156
162
  }
157
- }).catch(() => { });
163
+ }
164
+ catch {
165
+ /* ignore network errors */
166
+ }
158
167
  }
159
168
  // Force exit — avoid LanceDB close() hanging on Windows native bindings.
160
169
  // Status is read-only so there's no state to lose.
@@ -5,7 +5,7 @@
5
5
  * `ui` command — starts a local web UI for browsing the vector database.
6
6
  */
7
7
  import path from "node:path";
8
- import { c, resolveCliContext, logCliError, logCliInfo } from "../format.js";
8
+ import { c, resolveCliContext, cleanupContext, logCliError, logCliInfo } from "../format.js";
9
9
  /**
10
10
  * Register the `ui` command on the given Commander program.
11
11
  *
@@ -33,9 +33,10 @@ export function registerUiCommand(program) {
33
33
  const openBrowser = options.open !== false && (config.ui?.openBrowser ?? true);
34
34
  const { startWebUi } = await import("../../web/server.js");
35
35
  const server = await startWebUi(storePath, port, cwd, config.embedding.vectorDimension ?? 384, config);
36
- const url = `http://127.0.0.1:${server.port}`;
36
+ const url = `http://127.0.0.1:${server.port}/?token=${server.token}`;
37
37
  logCliInfo(logFilePath, "ui", `\n${c.heading("OpenCodeRAG Web UI")}`);
38
38
  logCliInfo(logFilePath, "ui", ` ${c.label("URL:")} ${c.value(url)}`);
39
+ logCliInfo(logFilePath, "ui", ` ${c.dim("The token in the URL authenticates this session — keep it private.")}`);
39
40
  logCliInfo(logFilePath, "ui", ` ${c.dim("Press Ctrl+C to stop")}\n`);
40
41
  if (openBrowser) {
41
42
  const { spawn } = await import("node:child_process");
@@ -52,14 +53,27 @@ export function registerUiCommand(program) {
52
53
  console.error(c.dim(`Could not open browser automatically. Open ${url} manually.`));
53
54
  }
54
55
  }
55
- process.on("SIGINT", async () => {
56
- await server.close();
57
- process.exit(0);
58
- });
59
- process.on("SIGTERM", async () => {
60
- await server.close();
56
+ let shuttingDown = false;
57
+ const shutdown = async () => {
58
+ if (shuttingDown)
59
+ return;
60
+ shuttingDown = true;
61
+ try {
62
+ await server.close();
63
+ }
64
+ catch {
65
+ // best-effort — the process is exiting anyway
66
+ }
67
+ try {
68
+ await cleanupContext(ctx);
69
+ }
70
+ catch {
71
+ // best-effort cleanup
72
+ }
61
73
  process.exit(0);
62
- });
74
+ };
75
+ process.on("SIGINT", shutdown);
76
+ process.on("SIGTERM", shutdown);
63
77
  }
64
78
  catch (err) {
65
79
  const message = err.message || String(err);
@@ -24,11 +24,10 @@ export function registerUpdateCommand(program) {
24
24
  console.log(`\n${c.heading("OpenCodeRAG Update")}\n`);
25
25
  console.log(` ${c.label("Current version:")} ${c.value(currentVersion)}`);
26
26
  console.log(` ${c.label("Checking...")} `);
27
- let info;
28
- try {
29
- info = await checkForUpdate(currentVersion);
30
- }
31
- catch {
27
+ // checkForUpdate never throws (all failures collapse to "no update"),
28
+ // so the try/catch below is defensive only.
29
+ const info = await checkForUpdate(currentVersion).catch(() => null);
30
+ if (!info) {
32
31
  console.log(`\n ${c.warn("Could not reach the update server. Check your network and try again.")}\n`);
33
32
  process.exit(1);
34
33
  return;