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/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 MCP server instances keyed by workspace directory. */
43
- const mcpServers = new Map();
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 MCP servers, then destroy idle sockets. */
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, server] of mcpServers) {
61
+ for (const [dir, store] of ragStores) {
62
62
  try {
63
- await server.close();
63
+ await store.close();
64
64
  }
65
65
  catch { /* best-effort */ }
66
- mcpServers.delete(dir);
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
- boundedSet(sessionToolResults, input.sessionID, results, MAX_SESSION_MAP_SIZE);
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
- new Promise((resolve) => setTimeout(() => resolve([]), budgetMs)),
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.set(input.sessionID, text);
897
- sessionToolResults.set(input.sessionID, []);
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
- new Promise((resolve) => setTimeout(() => resolve([]), budgetMs)),
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
- const pendingInjection = consumePendingRagInjection(options.storePath);
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
- let _cachedNodeExecutable;
1275
- /**
1276
- * Resolve the path to the Node.js executable, caching the result.
1277
- * Checks `process.execPath` first, then falls back to `where`/`which`.
1278
- */
1279
- function resolveNodeExecutable() {
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 existing MCP server for this directory if one exists (e.g. on plugin reload)
1445
- const existingMcp = mcpServers.get(input.directory);
1446
- if (existingMcp) {
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 existingMcp.close();
1365
+ await existingStore.close();
1449
1366
  }
1450
1367
  catch (err) {
1451
1368
  appendDebugLog(logFilePath, {
1452
1369
  scope: "plugin",
1453
- message: "Failed to close existing MCP server",
1370
+ message: "Failed to close existing vector store",
1454
1371
  error: err,
1455
1372
  }, logLevel);
1456
1373
  }
1457
- mcpServers.delete(input.directory);
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
- // Auto-start standalone MCP server if enabled (default: off). Agent tools
1556
- // (search_semantic, get_file_skeleton, find_usages, describe_image) and the
1557
- // chat.message hook run in-process regardless. Enable only when an external
1558
- // MCP client connects to `opencode-rag mcp`.
1559
- const mcpCfg = effectiveCfg.mcp ?? { enabled: false };
1560
- const isTempDir = path.resolve(input.directory).startsWith(tmpdir());
1561
- if (mcpCfg.enabled && !isTempDir) {
1562
- const mcpInstance = startMcpServerProcess(input.directory, logFilePath, logLevel);
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 `minTokenLen` (default 3 skips short filler like "the").
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 memQuirks = new Map();
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
- return raw.split("\n").map((l) => JSON.parse(l));
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
- writeFileSync(filePath, quirks.map((q) => JSON.stringify(q)).join("\n") + "\n", "utf-8");
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 memQuirks.get(id);
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
- await deps.store.deleteByFilePath(filePath);
104
- deps.keywordIndex.removeByFilePath(filePath);
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
- memQuirks.set(id, updated);
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
- memQuirks.set(id, quirk);
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
- memQuirks.delete(id);
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 = [...memQuirks.values()];
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}" "${quirks[j].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 `minTokenLen` (default 3 skips short filler like "the").
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
- const tokensA = new Set(tokenize(a));
32
- const tokensB = new Set(tokenize(b));
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[];