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