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.
- package/dist/api.d.ts +1 -1
- package/dist/api.js +2 -0
- 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/pdf.js +30 -14
- package/dist/cli/commands/init-helpers.js +15 -2
- package/dist/cli/commands/init.js +31 -21
- package/dist/cli/commands/query.js +2 -0
- package/dist/cli/commands/quirk.js +9 -3
- package/dist/cli/commands/setup.js +5 -2
- 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 +79 -17
- package/dist/core/bootstrap.js +10 -3
- package/dist/core/config.js +31 -0
- 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 +8 -0
- package/dist/core/interfaces.js +8 -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.js +15 -2
- package/dist/describer/gemini.js +25 -10
- package/dist/embedder/factory.d.ts +5 -3
- package/dist/embedder/factory.js +41 -8
- package/dist/embedder/health.js +19 -19
- package/dist/embedder/http.d.ts +14 -1
- package/dist/embedder/http.js +60 -6
- 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 +421 -344
- 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.d.ts +1 -1
- package/dist/mcp/handlers.js +23 -6
- package/dist/mcp/server.js +3 -0
- package/dist/opencode/create-read-tool.d.ts +1 -1
- package/dist/opencode/create-read-tool.js +17 -5
- package/dist/opencode/tool-args.js +23 -1
- package/dist/opencode/tools.d.ts +1 -1
- package/dist/opencode/tools.js +3 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.js +69 -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 +25 -1
- package/dist/vectorstore/lancedb.js +157 -11
- package/dist/vectorstore/memory.js +5 -1
- package/dist/watcher.js +30 -4
- package/dist/web/api.d.ts +6 -2
- package/dist/web/api.js +198 -70
- 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/plugin.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* automatic context injection and documentation mode.
|
|
5
5
|
*/
|
|
6
6
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
7
|
+
import { CODE_SEARCH_FILTER } from "./core/interfaces.js";
|
|
7
8
|
import { loadConfig, findConfigFile, DEFAULT_CONFIG, resolveLogConfig, persistProbedDimension } from "./core/config.js";
|
|
8
9
|
import { createEmbedder } from "./embedder/factory.js";
|
|
9
10
|
import { createDescriptionProvider } from "./describer/factory.js";
|
|
@@ -17,7 +18,7 @@ import { createBackgroundIndexer } from "./watcher.js";
|
|
|
17
18
|
import { createRagReadTool } from "./opencode/create-read-tool.js";
|
|
18
19
|
import { createFileSkeletonTool, createFindUsagesTool, createDescribeImageTool, createRecallQuirksTool, createAddQuirkTool, createUpdateQuirkTool, createDeleteQuirkTool, } from "./opencode/tools.js";
|
|
19
20
|
import { resolveApiKey } from "./core/resolve-api-key.js";
|
|
20
|
-
import { consumePendingRagInjection } from "./core/rag-injection-flag.js";
|
|
21
|
+
import { consumePendingRagInjection, peekPendingRagInjection } from "./core/rag-injection-flag.js";
|
|
21
22
|
import { loadDocProgress, markSubdirectoryDocumented } from "./core/doc-progress.js";
|
|
22
23
|
import { loadManifest } from "./core/manifest.js";
|
|
23
24
|
import { createSessionLogger } from "./eval/session-logger.js";
|
|
@@ -30,17 +31,16 @@ import { autoCaptureQuirks } from "./quirks/auto-capture.js";
|
|
|
30
31
|
import { buildSystemGuidanceLines } from "./opencode/system-guidance.js";
|
|
31
32
|
import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
32
33
|
import path from "node:path";
|
|
33
|
-
import { fileURLToPath } from "node:url";
|
|
34
|
-
import { spawn, execSync } from "node:child_process";
|
|
35
|
-
import { tmpdir } from "node:os";
|
|
36
34
|
/** Maximum entries per session map before oldest entries are evicted. */
|
|
37
35
|
const MAX_SESSION_MAP_SIZE = 50;
|
|
36
|
+
/** Maximum tool results kept per session (for quirk extraction). */
|
|
37
|
+
const MAX_SESSION_TOOL_RESULTS = 20;
|
|
38
38
|
/** Cache of loaded RAG configurations keyed by workspace directory. */
|
|
39
39
|
const configCache = new Map();
|
|
40
40
|
/** Active background indexer instances keyed by workspace directory. */
|
|
41
41
|
const backgroundIndexers = new Map();
|
|
42
|
-
/** Active
|
|
43
|
-
const
|
|
42
|
+
/** Active vector stores keyed by workspace directory — closed on reload/shutdown. */
|
|
43
|
+
const ragStores = new Map();
|
|
44
44
|
/** Pending update notifications keyed by workspace directory. */
|
|
45
45
|
const pendingUpdateInfo = new Map();
|
|
46
46
|
/** Workspace directories that have already been prompted about an update this session. */
|
|
@@ -49,7 +49,7 @@ const notifiedUpdateDirs = new Set();
|
|
|
49
49
|
const pendingRestartNotice = new Map();
|
|
50
50
|
/** Guard flag to prevent re-entrant shutdown. */
|
|
51
51
|
let shutdownRegistered = false;
|
|
52
|
-
/** Close all active background indexers and
|
|
52
|
+
/** Close all active background indexers and vector stores, then destroy idle sockets. */
|
|
53
53
|
async function shutdownPluginResources() {
|
|
54
54
|
for (const [dir, indexer] of backgroundIndexers) {
|
|
55
55
|
try {
|
|
@@ -58,12 +58,12 @@ async function shutdownPluginResources() {
|
|
|
58
58
|
catch { /* best-effort */ }
|
|
59
59
|
backgroundIndexers.delete(dir);
|
|
60
60
|
}
|
|
61
|
-
for (const [dir,
|
|
61
|
+
for (const [dir, store] of ragStores) {
|
|
62
62
|
try {
|
|
63
|
-
await
|
|
63
|
+
await store.close();
|
|
64
64
|
}
|
|
65
65
|
catch { /* best-effort */ }
|
|
66
|
-
|
|
66
|
+
ragStores.delete(dir);
|
|
67
67
|
}
|
|
68
68
|
configCache.clear();
|
|
69
69
|
pendingUpdateInfo.clear();
|
|
@@ -765,7 +765,10 @@ export function createRagHooks(options) {
|
|
|
765
765
|
try {
|
|
766
766
|
const results = sessionToolResults.get(input.sessionID) ?? [];
|
|
767
767
|
results.push({ tool: input.tool, output: output.output ?? "" });
|
|
768
|
-
|
|
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);
|
|
769
772
|
}
|
|
770
773
|
catch {
|
|
771
774
|
// Non-critical — must never throw
|
|
@@ -824,9 +827,17 @@ export function createRagHooks(options) {
|
|
|
824
827
|
if (query.length > 0) {
|
|
825
828
|
const quirkDeps = { embedder, store, keywordIndex: keywordIndex, cfg: getEffectiveCfg(), storePath: options.storePath };
|
|
826
829
|
const budgetMs = memCfg.autoInjectLatencyBudgetMs ?? 2000;
|
|
830
|
+
let timer;
|
|
827
831
|
let quirkResults = await Promise.race([
|
|
828
|
-
recallQuirks(quirkDeps, query, { topK: memCfg.autoInjectTopK ?? 2, minScore: memCfg.autoInjectMinScore })
|
|
829
|
-
|
|
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
|
+
}),
|
|
830
841
|
]);
|
|
831
842
|
// Dedup: skip quirks already injected into this session
|
|
832
843
|
const injectedSet = getOrCreateSessionSet(sessionInjectedQuirks, sessionId, MAX_SESSION_MAP_SIZE);
|
|
@@ -893,8 +904,8 @@ export function createRagHooks(options) {
|
|
|
893
904
|
}
|
|
894
905
|
}
|
|
895
906
|
}
|
|
896
|
-
sessionPrevUserReq
|
|
897
|
-
sessionToolResults
|
|
907
|
+
boundedSet(sessionPrevUserReq, input.sessionID, text, MAX_SESSION_MAP_SIZE);
|
|
908
|
+
boundedSet(sessionToolResults, input.sessionID, [], MAX_SESSION_MAP_SIZE);
|
|
898
909
|
boundedSet(sessionLastMessage, input.sessionID, text, MAX_SESSION_MAP_SIZE);
|
|
899
910
|
// Handle /doc slash command
|
|
900
911
|
if (text.startsWith("/doc")) {
|
|
@@ -1122,9 +1133,17 @@ export function createRagHooks(options) {
|
|
|
1122
1133
|
// Use the stricter recallMinScore for user-prompt injection
|
|
1123
1134
|
// (system prompt injection uses the lower autoInjectMinScore)
|
|
1124
1135
|
const minScore = quirkMemoryCfg.recallMinScore ?? 0.72;
|
|
1136
|
+
let timer;
|
|
1125
1137
|
let quirkResults = await Promise.race([
|
|
1126
|
-
recallQuirks(quirkDeps, quirkQuery, { topK: quirkMemoryCfg.autoInjectTopK ?? 2, minScore })
|
|
1127
|
-
|
|
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
|
+
}),
|
|
1128
1147
|
]);
|
|
1129
1148
|
// Dedup: skip quirks already injected into this session
|
|
1130
1149
|
const injectedSet = getOrCreateSessionSet(sessionInjectedQuirks, input.sessionID, MAX_SESSION_MAP_SIZE);
|
|
@@ -1167,15 +1186,19 @@ export function createRagHooks(options) {
|
|
|
1167
1186
|
}
|
|
1168
1187
|
}
|
|
1169
1188
|
// Handle hotkey-triggered RAG injection (Ctrl+Enter / Ctrl+Alt+Enter)
|
|
1170
|
-
|
|
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);
|
|
1171
1193
|
if (pendingInjection) {
|
|
1194
|
+
const count = await store.count();
|
|
1195
|
+
if (count === 0)
|
|
1196
|
+
return;
|
|
1197
|
+
consumePendingRagInjection(options.storePath);
|
|
1172
1198
|
appendDebugLog(options.logFilePath, {
|
|
1173
1199
|
scope: "chat.message",
|
|
1174
1200
|
message: `pending injection: ${pendingInjection}`,
|
|
1175
1201
|
});
|
|
1176
|
-
const count = await store.count();
|
|
1177
|
-
if (count === 0)
|
|
1178
|
-
return;
|
|
1179
1202
|
const effectiveCfg = getEffectiveCfg();
|
|
1180
1203
|
const hybridCfg = effectiveCfg.retrieval.hybridSearch;
|
|
1181
1204
|
const retrievalStart = Date.now();
|
|
@@ -1189,6 +1212,8 @@ export function createRagHooks(options) {
|
|
|
1189
1212
|
keywordIndex,
|
|
1190
1213
|
keywordWeight: hybridCfg?.keywordWeight,
|
|
1191
1214
|
queryPrefix: effectiveCfg.embedding.queryPrefix,
|
|
1215
|
+
// Never surface quirk chunks in hotkey file lists / chunk injections.
|
|
1216
|
+
filter: CODE_SEARCH_FILTER,
|
|
1192
1217
|
});
|
|
1193
1218
|
const retrievalTimeMs = Date.now() - retrievalStart;
|
|
1194
1219
|
if (results.length > 0) {
|
|
@@ -1271,122 +1296,12 @@ async function loadKeywordIndex(storePath, logFilePath, logLevel) {
|
|
|
1271
1296
|
return new KeywordIndex(storePath);
|
|
1272
1297
|
}
|
|
1273
1298
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
if (_cachedNodeExecutable !== undefined)
|
|
1281
|
-
return _cachedNodeExecutable;
|
|
1282
|
-
const execPath = process.execPath;
|
|
1283
|
-
const basename = path.basename(execPath).toLowerCase();
|
|
1284
|
-
if (basename === "node" || basename === "node.exe") {
|
|
1285
|
-
_cachedNodeExecutable = execPath;
|
|
1286
|
-
return _cachedNodeExecutable;
|
|
1287
|
-
}
|
|
1288
|
-
const tryCmd = (cmd) => {
|
|
1289
|
-
try {
|
|
1290
|
-
const result = execSync(cmd, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
1291
|
-
return result.split("\n")[0]?.trim() || null;
|
|
1292
|
-
}
|
|
1293
|
-
catch {
|
|
1294
|
-
return null;
|
|
1295
|
-
}
|
|
1296
|
-
};
|
|
1297
|
-
const found = process.platform === "win32"
|
|
1298
|
-
? tryCmd("where node")
|
|
1299
|
-
: tryCmd("which node");
|
|
1300
|
-
_cachedNodeExecutable = found;
|
|
1301
|
-
return _cachedNodeExecutable;
|
|
1302
|
-
}
|
|
1303
|
-
/**
|
|
1304
|
-
* Resolve the MCP CLI entry point script path.
|
|
1305
|
-
* Checks `dist/cli.js` first, then falls back to `src/cli.ts`.
|
|
1306
|
-
*/
|
|
1307
|
-
function resolveMcpCliEntry() {
|
|
1308
|
-
const selfDir = path.dirname(fileURLToPath(import.meta.url));
|
|
1309
|
-
const packageRoot = path.resolve(selfDir, "..");
|
|
1310
|
-
const distCli = path.join(packageRoot, "dist", "cli.js");
|
|
1311
|
-
if (existsSync(distCli))
|
|
1312
|
-
return distCli;
|
|
1313
|
-
const srcCli = path.join(packageRoot, "src", "cli.ts");
|
|
1314
|
-
if (existsSync(srcCli))
|
|
1315
|
-
return srcCli;
|
|
1316
|
-
return null;
|
|
1317
|
-
}
|
|
1318
|
-
/**
|
|
1319
|
-
* Start an MCP server process for the given workspace directory.
|
|
1320
|
-
* Spawns the CLI in MCP mode as a child process and wires stdout/stderr
|
|
1321
|
-
* to the debug log. Returns a handle for graceful shutdown.
|
|
1322
|
-
*/
|
|
1323
|
-
function startMcpServerProcess(cwd, logFilePath, logLevel) {
|
|
1324
|
-
const cliEntry = resolveMcpCliEntry();
|
|
1325
|
-
if (!cliEntry) {
|
|
1326
|
-
appendDebugLog(logFilePath, {
|
|
1327
|
-
scope: "mcp",
|
|
1328
|
-
message: "Could not resolve MCP CLI entry point; skipping autostart",
|
|
1329
|
-
}, logLevel);
|
|
1330
|
-
return { close: async () => { } };
|
|
1331
|
-
}
|
|
1332
|
-
const nodeExec = resolveNodeExecutable();
|
|
1333
|
-
if (!nodeExec) {
|
|
1334
|
-
appendDebugLog(logFilePath, {
|
|
1335
|
-
scope: "mcp",
|
|
1336
|
-
message: "Could not resolve Node.js executable; skipping MCP autostart",
|
|
1337
|
-
}, logLevel);
|
|
1338
|
-
return { close: async () => { } };
|
|
1339
|
-
}
|
|
1340
|
-
const args = cliEntry.endsWith(".ts")
|
|
1341
|
-
? ["--import", "tsx", cliEntry, "mcp"]
|
|
1342
|
-
: [cliEntry, "mcp"];
|
|
1343
|
-
appendDebugLog(logFilePath, {
|
|
1344
|
-
scope: "mcp",
|
|
1345
|
-
message: `Starting MCP server: ${nodeExec} ${args.join(" ")}`,
|
|
1346
|
-
}, logLevel);
|
|
1347
|
-
const child = spawn(nodeExec, args, {
|
|
1348
|
-
cwd,
|
|
1349
|
-
stdio: "pipe",
|
|
1350
|
-
detached: false,
|
|
1351
|
-
});
|
|
1352
|
-
child.stdout?.on("data", (chunk) => {
|
|
1353
|
-
appendDebugLog(logFilePath, {
|
|
1354
|
-
scope: "mcp",
|
|
1355
|
-
message: `stdout: ${chunk.toString().trim()}`,
|
|
1356
|
-
}, logLevel);
|
|
1357
|
-
});
|
|
1358
|
-
child.stderr?.on("data", (chunk) => {
|
|
1359
|
-
appendDebugLog(logFilePath, {
|
|
1360
|
-
scope: "mcp",
|
|
1361
|
-
message: `stderr: ${chunk.toString().trim()}`,
|
|
1362
|
-
}, logLevel);
|
|
1363
|
-
});
|
|
1364
|
-
child.on("error", (err) => {
|
|
1365
|
-
appendDebugLog(logFilePath, {
|
|
1366
|
-
scope: "mcp",
|
|
1367
|
-
message: "MCP server process error",
|
|
1368
|
-
error: err,
|
|
1369
|
-
}, logLevel);
|
|
1370
|
-
});
|
|
1371
|
-
child.on("exit", (code, signal) => {
|
|
1372
|
-
appendDebugLog(logFilePath, {
|
|
1373
|
-
scope: "mcp",
|
|
1374
|
-
message: `MCP server exited (code=${code}, signal=${signal})`,
|
|
1375
|
-
});
|
|
1376
|
-
mcpServers.delete(cwd);
|
|
1377
|
-
});
|
|
1378
|
-
return {
|
|
1379
|
-
close: async () => {
|
|
1380
|
-
if (child.exitCode !== null || child.killed)
|
|
1381
|
-
return;
|
|
1382
|
-
appendDebugLog(logFilePath, {
|
|
1383
|
-
scope: "mcp",
|
|
1384
|
-
message: "Shutting down MCP server",
|
|
1385
|
-
});
|
|
1386
|
-
child.kill("SIGTERM");
|
|
1387
|
-
},
|
|
1388
|
-
};
|
|
1389
|
-
}
|
|
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.
|
|
1390
1305
|
/**
|
|
1391
1306
|
* OpenCodeRAG plugin factory — invoked by OpenCode's plugin system for each workspace.
|
|
1392
1307
|
*
|
|
@@ -1441,20 +1356,22 @@ export const ragPlugin = async (input, _options) => {
|
|
|
1441
1356
|
}
|
|
1442
1357
|
backgroundIndexers.delete(input.directory);
|
|
1443
1358
|
}
|
|
1444
|
-
// Close
|
|
1445
|
-
|
|
1446
|
-
|
|
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) {
|
|
1447
1364
|
try {
|
|
1448
|
-
await
|
|
1365
|
+
await existingStore.close();
|
|
1449
1366
|
}
|
|
1450
1367
|
catch (err) {
|
|
1451
1368
|
appendDebugLog(logFilePath, {
|
|
1452
1369
|
scope: "plugin",
|
|
1453
|
-
message: "Failed to close existing
|
|
1370
|
+
message: "Failed to close existing vector store",
|
|
1454
1371
|
error: err,
|
|
1455
1372
|
}, logLevel);
|
|
1456
1373
|
}
|
|
1457
|
-
|
|
1374
|
+
ragStores.delete(input.directory);
|
|
1458
1375
|
}
|
|
1459
1376
|
// Clean up stale config cache and pending update info for this directory
|
|
1460
1377
|
configCache.delete(input.directory);
|
|
@@ -1505,6 +1422,7 @@ export const ragPlugin = async (input, _options) => {
|
|
|
1505
1422
|
}
|
|
1506
1423
|
}
|
|
1507
1424
|
const store = createVectorStore(effectiveCfg, storePath, vectorDimension);
|
|
1425
|
+
ragStores.set(input.directory, store);
|
|
1508
1426
|
// Load or create keyword index for hybrid search
|
|
1509
1427
|
const keywordIndex = await loadKeywordIndex(storePath, logFilePath, logLevel);
|
|
1510
1428
|
// Create description provider (enabled by default)
|
|
@@ -1552,15 +1470,14 @@ export const ragPlugin = async (input, _options) => {
|
|
|
1552
1470
|
catch { /* ignore */ }
|
|
1553
1471
|
}
|
|
1554
1472
|
}
|
|
1555
|
-
//
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
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);
|
|
1564
1481
|
}
|
|
1565
1482
|
// Auto-update check (non-blocking, best-effort). On by default; can be
|
|
1566
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
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { retrieve } from "../retriever/retriever.js";
|
|
@@ -11,15 +11,33 @@ function jsonlPath(storePath) {
|
|
|
11
11
|
function isMemoryStore(storePath) {
|
|
12
12
|
return storePath.startsWith("memory:");
|
|
13
13
|
}
|
|
14
|
-
/** In-memory backup for memory:// stores. */
|
|
15
|
-
const
|
|
14
|
+
/** In-memory backup for memory:// stores, keyed by store path. */
|
|
15
|
+
const memQuirksByStore = new Map();
|
|
16
|
+
function memQuirksFor(storePath) {
|
|
17
|
+
let store = memQuirksByStore.get(storePath);
|
|
18
|
+
if (!store) {
|
|
19
|
+
store = new Map();
|
|
20
|
+
memQuirksByStore.set(storePath, store);
|
|
21
|
+
}
|
|
22
|
+
return store;
|
|
23
|
+
}
|
|
16
24
|
function readJsonl(filePath) {
|
|
17
25
|
if (!existsSync(filePath))
|
|
18
26
|
return [];
|
|
19
27
|
const raw = readFileSync(filePath, "utf-8").trim();
|
|
20
28
|
if (!raw)
|
|
21
29
|
return [];
|
|
22
|
-
|
|
30
|
+
const quirks = [];
|
|
31
|
+
for (const line of raw.split("\n")) {
|
|
32
|
+
try {
|
|
33
|
+
quirks.push(JSON.parse(line));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Skip corrupt lines — a single bad line must never break
|
|
37
|
+
// every quirk operation (readJsonl feeds get/update/remove/list).
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return quirks;
|
|
23
41
|
}
|
|
24
42
|
function appendJsonl(filePath, q) {
|
|
25
43
|
const dir = path.dirname(filePath);
|
|
@@ -33,7 +51,24 @@ function rewriteJsonl(filePath, quirks) {
|
|
|
33
51
|
if (!existsSync(dir)) {
|
|
34
52
|
mkdirSync(dir, { recursive: true });
|
|
35
53
|
}
|
|
36
|
-
|
|
54
|
+
// Atomic write: tmp file + rename, so a crash or concurrent process
|
|
55
|
+
// can never leave a truncated/empty quirks.jsonl behind.
|
|
56
|
+
const tmpPath = `${filePath}.tmp`;
|
|
57
|
+
writeFileSync(tmpPath, quirks.map((q) => JSON.stringify(q)).join("\n") + "\n", "utf-8");
|
|
58
|
+
try {
|
|
59
|
+
renameSync(tmpPath, filePath);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Windows: rename can fail with EPERM when another process holds the file;
|
|
63
|
+
// fall back to a direct write (best-effort) rather than losing data.
|
|
64
|
+
writeFileSync(filePath, quirks.map((q) => JSON.stringify(q)).join("\n") + "\n", "utf-8");
|
|
65
|
+
try {
|
|
66
|
+
renameSync(tmpPath, `${filePath}.bak`);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// ignore
|
|
70
|
+
}
|
|
71
|
+
}
|
|
37
72
|
}
|
|
38
73
|
function nowISO() {
|
|
39
74
|
return new Date().toISOString();
|
|
@@ -62,7 +97,7 @@ export async function getQuirk(deps, id) {
|
|
|
62
97
|
}
|
|
63
98
|
return undefined;
|
|
64
99
|
}
|
|
65
|
-
return
|
|
100
|
+
return memQuirksFor(deps.storePath).get(id);
|
|
66
101
|
}
|
|
67
102
|
/**
|
|
68
103
|
* Update an existing quirk by ID. Fields in `patch` override the stored values.
|
|
@@ -100,8 +135,9 @@ export async function updateQuirk(deps, id, patch) {
|
|
|
100
135
|
sourceRef,
|
|
101
136
|
};
|
|
102
137
|
const filePath = QUIRK_FILE_PREFIX + id;
|
|
103
|
-
|
|
104
|
-
|
|
138
|
+
// Embed FIRST — if embedding fails, the old index entries are untouched
|
|
139
|
+
// (previously the delete happened before the embed, permanently removing
|
|
140
|
+
// the quirk from search while the JSONL still listed it).
|
|
105
141
|
const prefix = deps.cfg.embedding.documentPrefix ?? "";
|
|
106
142
|
const chunkContent = prefix + content;
|
|
107
143
|
const embeddings = await deps.embedder.embed([chunkContent], "document");
|
|
@@ -109,6 +145,8 @@ export async function updateQuirk(deps, id, patch) {
|
|
|
109
145
|
if (!embedding || embedding.length === 0) {
|
|
110
146
|
throw new Error("Embedding returned empty vector for quirk content");
|
|
111
147
|
}
|
|
148
|
+
await deps.store.deleteByFilePath(filePath);
|
|
149
|
+
deps.keywordIndex.removeByFilePath(filePath);
|
|
112
150
|
const chunk = {
|
|
113
151
|
id,
|
|
114
152
|
content,
|
|
@@ -134,7 +172,7 @@ export async function updateQuirk(deps, id, patch) {
|
|
|
134
172
|
rewriteJsonl(jp, all);
|
|
135
173
|
}
|
|
136
174
|
else {
|
|
137
|
-
|
|
175
|
+
memQuirksFor(deps.storePath).set(id, updated);
|
|
138
176
|
}
|
|
139
177
|
return updated;
|
|
140
178
|
}
|
|
@@ -186,7 +224,7 @@ export async function addQuirk(deps, input) {
|
|
|
186
224
|
appendJsonl(jsonlPath(deps.storePath), quirk);
|
|
187
225
|
}
|
|
188
226
|
else {
|
|
189
|
-
|
|
227
|
+
memQuirksFor(deps.storePath).set(id, quirk);
|
|
190
228
|
}
|
|
191
229
|
return quirk;
|
|
192
230
|
}
|
|
@@ -205,7 +243,7 @@ export async function removeQuirk(deps, id) {
|
|
|
205
243
|
rewriteJsonl(jp, all);
|
|
206
244
|
}
|
|
207
245
|
else {
|
|
208
|
-
|
|
246
|
+
memQuirksFor(deps.storePath).delete(id);
|
|
209
247
|
}
|
|
210
248
|
}
|
|
211
249
|
/** List all quirks sorted by lastObserved descending. */
|
|
@@ -238,7 +276,7 @@ export async function listQuirks(deps) {
|
|
|
238
276
|
result.sort((a, b) => b.lastObserved.localeCompare(a.lastObserved));
|
|
239
277
|
return result;
|
|
240
278
|
}
|
|
241
|
-
const all = [...
|
|
279
|
+
const all = [...memQuirksFor(deps.storePath).values()];
|
|
242
280
|
all.sort((a, b) => b.lastObserved.localeCompare(a.lastObserved));
|
|
243
281
|
return all;
|
|
244
282
|
}
|
|
@@ -246,10 +284,11 @@ export async function listQuirks(deps) {
|
|
|
246
284
|
export async function recallQuirks(deps, query, options) {
|
|
247
285
|
const topK = options?.topK ?? 10;
|
|
248
286
|
const minConfidence = deps.cfg.memory?.minConfidence ?? 0.5;
|
|
287
|
+
// NOTE: do NOT put quirkType into `filter.languages` — quirk chunks store
|
|
288
|
+
// the type in metadata.quirkType, not metadata.language ("quirk").
|
|
289
|
+
// A languages filter would exclude every quirk chunk and return zero hits.
|
|
290
|
+
// Type filtering happens in the post-filter below instead.
|
|
249
291
|
const filter = { kinds: ["quirk"] };
|
|
250
|
-
if (options?.quirkType) {
|
|
251
|
-
filter.languages = [options.quirkType];
|
|
252
|
-
}
|
|
253
292
|
const recallMinScore = options?.minScore ?? deps.cfg.memory?.recallMinScore ?? 0.72;
|
|
254
293
|
const raw = await retrieve(query, deps.embedder, deps.store, {
|
|
255
294
|
topK: topK * 3,
|
|
@@ -304,7 +343,7 @@ export async function lintQuirks(deps) {
|
|
|
304
343
|
for (let j = i + 1; j < quirks.length; j++) {
|
|
305
344
|
const sim = lexicalSimilarity(quirks[i].content, quirks[j].content);
|
|
306
345
|
if (sim > 0.85) {
|
|
307
|
-
issues.push(`Near-duplicate (${(sim * 100).toFixed(0)}% similar): "${quirks[i].content}"
|
|
346
|
+
issues.push(`Near-duplicate (${(sim * 100).toFixed(0)}% similar): "${quirks[i].content}" ↔ "${quirks[j].content}"`);
|
|
308
347
|
}
|
|
309
348
|
}
|
|
310
349
|
}
|
|
@@ -322,7 +361,7 @@ export function lexicalSimilarity(a, b) {
|
|
|
322
361
|
* Count of meaningful word tokens shared between two texts (Jaccard numerator).
|
|
323
362
|
*
|
|
324
363
|
* Tokens are whitespace/punctuation-split, lowercased, and filtered to those
|
|
325
|
-
* with length
|
|
364
|
+
* with length ≥ `minTokenLen` (default 3 — skips short filler like "the").
|
|
326
365
|
*
|
|
327
366
|
* Used by the quirk auto-inject gate: candidate quirks that share no tokens
|
|
328
367
|
* with the user's *current* message (i.e. they matched only against the prior
|
|
@@ -26,10 +26,20 @@ function toOptimized(r) {
|
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
28
|
* Compute Jaccard similarity between two strings based on their token sets.
|
|
29
|
+
* Accepts a per-call token cache so repeated comparisons (the dedup loop is
|
|
30
|
+
* O(n²) over same-file pairs) don't re-tokenize the same content every time.
|
|
29
31
|
*/
|
|
30
|
-
function jaccardSimilarity(a, b) {
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
function jaccardSimilarity(a, b, tokenCache) {
|
|
33
|
+
let tokensA = tokenCache.get(a);
|
|
34
|
+
if (!tokensA) {
|
|
35
|
+
tokensA = new Set(tokenize(a));
|
|
36
|
+
tokenCache.set(a, tokensA);
|
|
37
|
+
}
|
|
38
|
+
let tokensB = tokenCache.get(b);
|
|
39
|
+
if (!tokensB) {
|
|
40
|
+
tokensB = new Set(tokenize(b));
|
|
41
|
+
tokenCache.set(b, tokensB);
|
|
42
|
+
}
|
|
33
43
|
if (tokensA.size === 0 && tokensB.size === 0)
|
|
34
44
|
return 1;
|
|
35
45
|
let intersection = 0;
|
|
@@ -99,12 +109,16 @@ function dedupeSimilar(results, threshold) {
|
|
|
99
109
|
if (results.length <= 1)
|
|
100
110
|
return results;
|
|
101
111
|
const kept = [...results];
|
|
112
|
+
// Token cache shared across all pair comparisons of this group — the
|
|
113
|
+
// dedup loop is O(n²) over pairs and was re-tokenizing full chunk
|
|
114
|
+
// contents on every comparison.
|
|
115
|
+
const tokenCache = new Map();
|
|
102
116
|
let changed = true;
|
|
103
117
|
while (changed) {
|
|
104
118
|
changed = false;
|
|
105
119
|
for (let i = 0; i < kept.length; i++) {
|
|
106
120
|
for (let j = i + 1; j < kept.length; j++) {
|
|
107
|
-
const sim = jaccardSimilarity(kept[i].chunk.content, kept[j].chunk.content);
|
|
121
|
+
const sim = jaccardSimilarity(kept[i].chunk.content, kept[j].chunk.content, tokenCache);
|
|
108
122
|
if (sim > threshold) {
|
|
109
123
|
const [keepIdx, removeIdx] = kept[i].score >= kept[j].score ? [i, j] : [j, i];
|
|
110
124
|
const removedId = kept[removeIdx].chunk.id;
|
|
@@ -18,6 +18,8 @@ export declare class KeywordIndex {
|
|
|
18
18
|
private invertedIndex;
|
|
19
19
|
private chunkMap;
|
|
20
20
|
private readonly storePath?;
|
|
21
|
+
/** filePath (normalized) → chunk IDs, for O(chunks-in-file) removal. */
|
|
22
|
+
private fileToIds;
|
|
21
23
|
constructor(storePath?: string);
|
|
22
24
|
addChunks(chunks: Chunk[]): void;
|
|
23
25
|
getMatchedTerms(query: string, chunkId: string): string[];
|