opencode-rag-plugin 1.17.2 → 1.18.0
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/ReadMe.md +4 -2
- package/dist/cli/commands/index.d.ts +1 -0
- package/dist/cli/commands/index.js +1 -0
- package/dist/cli/commands/init-helpers.js +4 -0
- package/dist/cli/commands/quirk.d.ts +10 -0
- package/dist/cli/commands/quirk.js +141 -0
- package/dist/cli/index.js +2 -1
- package/dist/core/auto-update-state.d.ts +33 -0
- package/dist/core/auto-update-state.js +69 -0
- package/dist/core/config.d.ts +28 -2
- package/dist/core/config.js +24 -2
- package/dist/core/interfaces.d.ts +16 -1
- package/dist/opencode/tools.d.ts +38 -0
- package/dist/opencode/tools.js +127 -0
- package/dist/plugin.js +220 -7
- package/dist/quirks/monitor.d.ts +5 -0
- package/dist/quirks/monitor.js +23 -0
- package/dist/quirks/quirk-store.d.ts +25 -0
- package/dist/quirks/quirk-store.js +217 -0
- package/dist/quirks/types.d.ts +20 -0
- package/dist/quirks/types.js +2 -0
- package/dist/retriever/keyword-index.js +16 -0
- package/dist/vectorstore/lancedb.d.ts +12 -10
- package/dist/vectorstore/lancedb.js +154 -18
- package/dist/vectorstore/memory.js +3 -0
- package/package.json +1 -1
package/dist/plugin.js
CHANGED
|
@@ -15,15 +15,17 @@ import { appendDebugLog } from "./core/fileLogger.js";
|
|
|
15
15
|
import { loadRuntimeOverrides, applyRuntimeOverrides } from "./core/runtime-overrides.js";
|
|
16
16
|
import { createBackgroundIndexer } from "./watcher.js";
|
|
17
17
|
import { createRagReadTool } from "./opencode/create-read-tool.js";
|
|
18
|
-
import { createFileSkeletonTool, createFindUsagesTool, createDescribeImageTool, } from "./opencode/tools.js";
|
|
18
|
+
import { createFileSkeletonTool, createFindUsagesTool, createDescribeImageTool, createRecallQuirksTool, createAddQuirkTool, } from "./opencode/tools.js";
|
|
19
19
|
import { resolveApiKey } from "./core/resolve-api-key.js";
|
|
20
20
|
import { consumePendingRagInjection } from "./core/rag-injection-flag.js";
|
|
21
21
|
import { loadDocProgress, markSubdirectoryDocumented } from "./core/doc-progress.js";
|
|
22
22
|
import { loadManifest } from "./core/manifest.js";
|
|
23
23
|
import { createSessionLogger } from "./eval/session-logger.js";
|
|
24
24
|
import { countTokens } from "./eval/token-counter.js";
|
|
25
|
-
import { checkForUpdate, getCurrentVersion } from "./core/version-check.js";
|
|
25
|
+
import { checkForUpdate, getCurrentVersion, installLatestUpdate } from "./core/version-check.js";
|
|
26
|
+
import { loadAutoUpdateState, saveAutoUpdateState, shouldAttemptInstall } from "./core/auto-update-state.js";
|
|
26
27
|
import { destroyAllPooledConnections } from "./embedder/http.js";
|
|
28
|
+
import { listQuirks, lintQuirks, recallQuirks } from "./quirks/quirk-store.js";
|
|
27
29
|
import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
28
30
|
import path from "node:path";
|
|
29
31
|
import { fileURLToPath } from "node:url";
|
|
@@ -41,6 +43,8 @@ const mcpServers = new Map();
|
|
|
41
43
|
const pendingUpdateInfo = new Map();
|
|
42
44
|
/** Workspace directories that have already been prompted about an update this session. */
|
|
43
45
|
const notifiedUpdateDirs = new Set();
|
|
46
|
+
/** Pending "restart needed" notice after a background auto-install. */
|
|
47
|
+
const pendingRestartNotice = new Map();
|
|
44
48
|
/** Guard flag to prevent re-entrant shutdown. */
|
|
45
49
|
let shutdownRegistered = false;
|
|
46
50
|
/** Close all active background indexers and MCP servers, then destroy idle sockets. */
|
|
@@ -61,6 +65,7 @@ async function shutdownPluginResources() {
|
|
|
61
65
|
}
|
|
62
66
|
configCache.clear();
|
|
63
67
|
pendingUpdateInfo.clear();
|
|
68
|
+
pendingRestartNotice.clear();
|
|
64
69
|
notifiedUpdateDirs.clear();
|
|
65
70
|
destroyAllPooledConnections();
|
|
66
71
|
}
|
|
@@ -589,6 +594,40 @@ export function createRagHooks(options) {
|
|
|
589
594
|
error: err,
|
|
590
595
|
});
|
|
591
596
|
}
|
|
597
|
+
try {
|
|
598
|
+
const recallQuirksTool = createRecallQuirksTool({
|
|
599
|
+
store,
|
|
600
|
+
embedder,
|
|
601
|
+
cfg: effectiveCfg,
|
|
602
|
+
keywordIndex: keywordIndex,
|
|
603
|
+
storePath: options.storePath,
|
|
604
|
+
});
|
|
605
|
+
tools["recall_quirks"] = recallQuirksTool;
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
appendDebugLog(options.logFilePath, {
|
|
609
|
+
scope: "plugin",
|
|
610
|
+
message: "Failed to register recall_quirks tool",
|
|
611
|
+
error: err,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
try {
|
|
615
|
+
const addQuirkTool = createAddQuirkTool({
|
|
616
|
+
store,
|
|
617
|
+
embedder,
|
|
618
|
+
cfg: effectiveCfg,
|
|
619
|
+
keywordIndex: keywordIndex,
|
|
620
|
+
storePath: options.storePath,
|
|
621
|
+
});
|
|
622
|
+
tools["add_quirk"] = addQuirkTool;
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
appendDebugLog(options.logFilePath, {
|
|
626
|
+
scope: "plugin",
|
|
627
|
+
message: "Failed to register add_quirk tool",
|
|
628
|
+
error: err,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
592
631
|
if (readOverride) {
|
|
593
632
|
const readTool = createRagReadTool({
|
|
594
633
|
worktree: options.worktree,
|
|
@@ -662,6 +701,8 @@ export function createRagHooks(options) {
|
|
|
662
701
|
"- `get_file_skeleton(filePath)`: structural overview of a file. Call BEFORE reading any file.",
|
|
663
702
|
"- `find_usages(symbolName)`: find all references. Call BEFORE editing any function, class, or variable.",
|
|
664
703
|
"- `describe_image(filePath)`: describe an image file using a vision model. Call when user refers to a screenshot, diagram, or image.",
|
|
704
|
+
"- `recall_quirks(query)`: query experiential quirk memory (gotchas, preferences, decisions). Call when you hit an error or need to recall known pitfalls.",
|
|
705
|
+
"- `add_quirk(content)`: store a new experiential memory. Call when you discover a non-obvious fact, gotcha, or coding convention.",
|
|
665
706
|
"",
|
|
666
707
|
"Decision tree — ALWAYS follow this order:",
|
|
667
708
|
"1. User mentions code behavior/architecture → `search_semantic(query)`",
|
|
@@ -669,6 +710,8 @@ export function createRagHooks(options) {
|
|
|
669
710
|
"3. User mentions a function/class/variable to edit → `find_usages(symbolName)` THEN `search_semantic` THEN `edit`",
|
|
670
711
|
"4. User asks a code question → `search_semantic` to gather context before answering",
|
|
671
712
|
"5. User asks about an image or visual asset → `describe_image(filePath)` to retrieve its generated description, then optionally `search_semantic` for related code",
|
|
713
|
+
"6. You encounter an error or need to recall a known pitfall → `recall_quirks(query)`",
|
|
714
|
+
"7. You discover a non-obvious fact or workaround → `add_quirk(content)` to persist it for future sessions",
|
|
672
715
|
"",
|
|
673
716
|
"Proactive triggers — you MUST call these tools when:",
|
|
674
717
|
"- User asks about code behavior, architecture, or implementation details",
|
|
@@ -699,6 +742,16 @@ export function createRagHooks(options) {
|
|
|
699
742
|
`they would like you to install it. If they agree, run \`opencode-rag update\` via the bash tool, then tell ` +
|
|
700
743
|
`them to restart OpenCode to load the new version. Do not mention this notice again.`);
|
|
701
744
|
}
|
|
745
|
+
// Inject a one-time "restart needed" notice after a background auto-install.
|
|
746
|
+
// Fires only once per workspace per session, mirroring the notify-only pattern above.
|
|
747
|
+
const restartInfo = pendingRestartNotice.get(options.worktree);
|
|
748
|
+
if (restartInfo && !notifiedUpdateDirs.has(options.worktree + ":restart")) {
|
|
749
|
+
notifiedUpdateDirs.add(options.worktree + ":restart");
|
|
750
|
+
pendingRestartNotice.delete(options.worktree);
|
|
751
|
+
output.system.unshift(`OpenCodeRAG was automatically updated from v${restartInfo.fromVersion} to v${restartInfo.toVersion} ` +
|
|
752
|
+
`in the background. At the very start of your next response, tell the user the update was installed ` +
|
|
753
|
+
`and ask them to restart OpenCode to load the new version. Do not mention this notice again.`);
|
|
754
|
+
}
|
|
702
755
|
// Inject documentation mode system prompt if enabled
|
|
703
756
|
const docMode = getEffectiveCfg().documentationMode;
|
|
704
757
|
if (docMode?.enabled && docMode.systemPrompt) {
|
|
@@ -868,6 +921,67 @@ export function createRagHooks(options) {
|
|
|
868
921
|
}
|
|
869
922
|
return;
|
|
870
923
|
}
|
|
924
|
+
// Handle /quirk slash command
|
|
925
|
+
if (text.startsWith("/quirk")) {
|
|
926
|
+
const memoryCfg = getEffectiveCfg().memory;
|
|
927
|
+
if (!memoryCfg?.enabled) {
|
|
928
|
+
const parts = output?.parts ?? output?.message?.parts;
|
|
929
|
+
if (Array.isArray(parts) && parts.length > 0) {
|
|
930
|
+
const first = parts[0];
|
|
931
|
+
if (typeof first.text === "string") {
|
|
932
|
+
parts[0] = { ...first, text: "Quirk memory is not enabled. Set `memory.enabled` to `true` in opencode-rag.json." };
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const arg = text.slice(6).trim().toLowerCase();
|
|
938
|
+
const lines = [];
|
|
939
|
+
if (arg === "lint") {
|
|
940
|
+
const deps = { embedder, store, keywordIndex: keywordIndex, cfg: getEffectiveCfg(), storePath: options.storePath };
|
|
941
|
+
const issues = await lintQuirks(deps);
|
|
942
|
+
lines.push("## Quirk Lint", "");
|
|
943
|
+
if (issues.length === 0) {
|
|
944
|
+
lines.push("No issues found. All quirks look healthy.");
|
|
945
|
+
}
|
|
946
|
+
else {
|
|
947
|
+
lines.push(`Found ${issues.length} issue(s):\n`);
|
|
948
|
+
for (const issue of issues) {
|
|
949
|
+
lines.push(`- ${issue}`);
|
|
950
|
+
}
|
|
951
|
+
lines.push("", "Fix the issues and consider updating or removing the flagged quirks.");
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
else if (arg.startsWith("add ")) {
|
|
955
|
+
lines.push("## Add Quirk", "", "Use the `add_quirk` tool to store a new experiential memory entry. Example:", "", '`content`: "npm install needs --legacy-peer-deps due to LanceDB peer dep conflicts"', '`quirkType`: "gotcha"', "`tags`: [\"npm\", \"lancedb\", \"installation\"]");
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
// Status / list
|
|
959
|
+
const deps = { embedder, store, keywordIndex: keywordIndex, cfg: getEffectiveCfg(), storePath: options.storePath };
|
|
960
|
+
const quirks = await listQuirks(deps);
|
|
961
|
+
lines.push("## Quirk Memory", "");
|
|
962
|
+
if (quirks.length === 0) {
|
|
963
|
+
lines.push("No quirks stored yet.");
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
lines.push(`${quirks.length} quirk(s) stored:\n`);
|
|
967
|
+
for (const q of quirks) {
|
|
968
|
+
const badge = q.quirkType ? `[\`${q.quirkType}\`] ` : "";
|
|
969
|
+
const tags = q.tags.length > 0 ? ` _(${q.tags.join(", ")})_` : "";
|
|
970
|
+
lines.push(`- ${badge}${q.content.slice(0, 100)}${tags} `);
|
|
971
|
+
}
|
|
972
|
+
lines.push("", "Commands:", "- `/quirk` — show this status", "- `/quirk lint` — health-check quirks", "- `/quirk add <text>` — instructions to use the add_quirk tool");
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const quirkMsg = lines.join("\n");
|
|
976
|
+
const parts = output?.parts ?? output?.message?.parts;
|
|
977
|
+
if (Array.isArray(parts) && parts.length > 0) {
|
|
978
|
+
const first = parts[0];
|
|
979
|
+
if (typeof first.text === "string") {
|
|
980
|
+
parts[0] = { ...first, text: quirkMsg };
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
871
985
|
// Handle hotkey-triggered RAG injection (Ctrl+Enter / Ctrl+Alt+Enter)
|
|
872
986
|
const pendingInjection = consumePendingRagInjection(options.storePath);
|
|
873
987
|
if (pendingInjection) {
|
|
@@ -924,6 +1038,40 @@ export function createRagHooks(options) {
|
|
|
924
1038
|
});
|
|
925
1039
|
}
|
|
926
1040
|
}
|
|
1041
|
+
// Auto-inject quirks when memory.autoInject is enabled
|
|
1042
|
+
const memoryCfg = effectiveCfg.memory;
|
|
1043
|
+
if (memoryCfg?.autoInject) {
|
|
1044
|
+
try {
|
|
1045
|
+
const quirkDeps = { embedder, store, keywordIndex: keywordIndex, cfg: effectiveCfg, storePath: options.storePath };
|
|
1046
|
+
const quirkResults = await recallQuirks(quirkDeps, searchQuery, { topK: 3 });
|
|
1047
|
+
if (quirkResults.length > 0) {
|
|
1048
|
+
const quirkLines = ["", "---", "⚠ **Quirk memory**", ""];
|
|
1049
|
+
for (const qr of quirkResults) {
|
|
1050
|
+
const m = qr.chunk.metadata;
|
|
1051
|
+
const badge = m.quirkType ? `[\`${m.quirkType}\`] ` : "";
|
|
1052
|
+
quirkLines.push(`- ${badge}${qr.chunk.content.slice(0, 200)}`);
|
|
1053
|
+
}
|
|
1054
|
+
const quirkBlock = quirkLines.join("\n");
|
|
1055
|
+
const parts = output?.parts ?? output?.message?.parts;
|
|
1056
|
+
if (Array.isArray(parts) && parts.length > 0) {
|
|
1057
|
+
const first = parts[0];
|
|
1058
|
+
if (typeof first.text === "string") {
|
|
1059
|
+
parts[0] = { ...first, text: first.text + "\n\n" + quirkBlock };
|
|
1060
|
+
}
|
|
1061
|
+
const msgParts = output?.message?.parts;
|
|
1062
|
+
if (Array.isArray(msgParts) && msgParts !== parts) {
|
|
1063
|
+
const mfirst = msgParts[0];
|
|
1064
|
+
if (typeof mfirst.text === "string") {
|
|
1065
|
+
msgParts[0] = { ...mfirst, text: mfirst.text + "\n\n" + quirkBlock };
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
// Non-critical — must never throw
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
927
1075
|
}
|
|
928
1076
|
appendDebugLog(options.logFilePath, {
|
|
929
1077
|
scope: "chat.message",
|
|
@@ -1161,6 +1309,7 @@ export const ragPlugin = async (input, _options) => {
|
|
|
1161
1309
|
// Clean up stale config cache and pending update info for this directory
|
|
1162
1310
|
configCache.delete(input.directory);
|
|
1163
1311
|
pendingUpdateInfo.delete(input.directory);
|
|
1312
|
+
pendingRestartNotice.delete(input.directory);
|
|
1164
1313
|
notifiedUpdateDirs.delete(input.directory);
|
|
1165
1314
|
// Clean up idle HTTP sockets from previous provider connections
|
|
1166
1315
|
destroyAllPooledConnections();
|
|
@@ -1253,8 +1402,11 @@ export const ragPlugin = async (input, _options) => {
|
|
|
1253
1402
|
catch { /* ignore */ }
|
|
1254
1403
|
}
|
|
1255
1404
|
}
|
|
1256
|
-
// Auto-start MCP server if enabled (
|
|
1257
|
-
|
|
1405
|
+
// Auto-start standalone MCP server if enabled (default: off). Agent tools
|
|
1406
|
+
// (search_semantic, get_file_skeleton, find_usages, describe_image) and the
|
|
1407
|
+
// chat.message hook run in-process regardless. Enable only when an external
|
|
1408
|
+
// MCP client connects to `opencode-rag mcp`.
|
|
1409
|
+
const mcpCfg = effectiveCfg.mcp ?? { enabled: false };
|
|
1258
1410
|
const isTempDir = path.resolve(input.directory).startsWith(tmpdir());
|
|
1259
1411
|
if (mcpCfg.enabled && !isTempDir) {
|
|
1260
1412
|
const mcpInstance = startMcpServerProcess(input.directory, logFilePath, logLevel);
|
|
@@ -1262,14 +1414,75 @@ export const ragPlugin = async (input, _options) => {
|
|
|
1262
1414
|
}
|
|
1263
1415
|
// Auto-update check (non-blocking, best-effort). On by default; can be
|
|
1264
1416
|
// disabled via `autoUpdate.enabled: false` in opencode-rag.json. When a newer
|
|
1265
|
-
// release is found it is
|
|
1266
|
-
// the system transform hook (
|
|
1417
|
+
// release is found it is either surfaced as a one-time install prompt via
|
|
1418
|
+
// the system transform hook (default) or automatically installed in the
|
|
1419
|
+
// background when `autoUpdate.autoInstall: true` is configured.
|
|
1267
1420
|
const autoUpdateCfg = effectiveCfg.autoUpdate;
|
|
1268
1421
|
if (autoUpdateCfg?.enabled) {
|
|
1269
1422
|
const currentVersion = getCurrentVersion();
|
|
1270
1423
|
checkForUpdate(currentVersion)
|
|
1271
1424
|
.then((info) => {
|
|
1272
|
-
if (info.updateAvailable)
|
|
1425
|
+
if (!info.updateAvailable)
|
|
1426
|
+
return;
|
|
1427
|
+
if (autoUpdateCfg?.autoInstall) {
|
|
1428
|
+
// Background auto-install branch (opt-in). Persist a cooldown file
|
|
1429
|
+
// so we don't re-install on every plugin reload or after failures.
|
|
1430
|
+
const cooldownMs = autoUpdateCfg.cooldownMs ?? 3_600_000;
|
|
1431
|
+
const maxFailures = autoUpdateCfg.maxConsecutiveFailures ?? 3;
|
|
1432
|
+
const state = loadAutoUpdateState(storePath);
|
|
1433
|
+
const { attempt, reason } = shouldAttemptInstall(state, info.latestVersion, cooldownMs, maxFailures);
|
|
1434
|
+
if (!attempt) {
|
|
1435
|
+
appendDebugLog(logFilePath, {
|
|
1436
|
+
scope: "updater",
|
|
1437
|
+
message: `Auto-install skipped: ${reason}`,
|
|
1438
|
+
}, logLevel);
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
saveAutoUpdateState(storePath, {
|
|
1442
|
+
lastAttemptAt: Date.now(),
|
|
1443
|
+
lastAttemptedVersion: info.latestVersion,
|
|
1444
|
+
lastResult: "failure",
|
|
1445
|
+
consecutiveFailures: 1,
|
|
1446
|
+
});
|
|
1447
|
+
installLatestUpdate({ verbose: false })
|
|
1448
|
+
.then((result) => {
|
|
1449
|
+
if (result.success && result.toVersion && result.toVersion !== currentVersion) {
|
|
1450
|
+
pendingRestartNotice.set(input.directory, {
|
|
1451
|
+
fromVersion: currentVersion,
|
|
1452
|
+
toVersion: result.toVersion,
|
|
1453
|
+
});
|
|
1454
|
+
saveAutoUpdateState(storePath, {
|
|
1455
|
+
lastAttemptAt: Date.now(),
|
|
1456
|
+
lastAttemptedVersion: info.latestVersion,
|
|
1457
|
+
lastResult: "success",
|
|
1458
|
+
consecutiveFailures: 0,
|
|
1459
|
+
});
|
|
1460
|
+
appendDebugLog(logFilePath, {
|
|
1461
|
+
scope: "updater",
|
|
1462
|
+
message: `Auto-installed v${currentVersion} → v${result.toVersion}`,
|
|
1463
|
+
}, logLevel);
|
|
1464
|
+
}
|
|
1465
|
+
else {
|
|
1466
|
+
saveAutoUpdateState(storePath, {
|
|
1467
|
+
lastAttemptAt: Date.now(),
|
|
1468
|
+
lastAttemptedVersion: info.latestVersion,
|
|
1469
|
+
lastResult: "success",
|
|
1470
|
+
consecutiveFailures: 0,
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
})
|
|
1474
|
+
.catch(() => {
|
|
1475
|
+
const existing = loadAutoUpdateState(storePath);
|
|
1476
|
+
saveAutoUpdateState(storePath, {
|
|
1477
|
+
lastAttemptAt: Date.now(),
|
|
1478
|
+
lastAttemptedVersion: info.latestVersion,
|
|
1479
|
+
lastResult: "failure",
|
|
1480
|
+
consecutiveFailures: (existing?.consecutiveFailures ?? 0) + 1,
|
|
1481
|
+
});
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
else {
|
|
1485
|
+
// Notify-only: surface as a one-time agent prompt
|
|
1273
1486
|
pendingUpdateInfo.set(input.directory, info);
|
|
1274
1487
|
appendDebugLog(logFilePath, {
|
|
1275
1488
|
scope: "updater",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Patterns that are never allowed in quirk memory — the immutable trust boundary. */
|
|
2
|
+
const BLOCKED_PATTERNS = [
|
|
3
|
+
/\bskip\s+(?:\S+\s+)*?tests?\b/i,
|
|
4
|
+
/\bdisable\s+(lint|typecheck|strict)/i,
|
|
5
|
+
/\bignore\s+(all\s+)?errors/i,
|
|
6
|
+
/\brm\s+-rf\b/,
|
|
7
|
+
/\bforce\s+push\b/,
|
|
8
|
+
/\bgit\s+push\s+--force\b/,
|
|
9
|
+
/\bdelete\s+.+\.git/i,
|
|
10
|
+
/\btouch\s+(?:\.env|withheld)/i,
|
|
11
|
+
/\bbypass\s+(?:security|review|check)/i,
|
|
12
|
+
];
|
|
13
|
+
/** Check whether quirk content is allowed within the immutable environment boundary. */
|
|
14
|
+
export function isQuirkAllowed(content) {
|
|
15
|
+
for (const pattern of BLOCKED_PATTERNS) {
|
|
16
|
+
const match = content.match(pattern);
|
|
17
|
+
if (match) {
|
|
18
|
+
return { ok: false, reason: `Content matches blocked pattern: "${match[0]}"` };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { ok: true };
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=monitor.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { EmbeddingProvider, VectorStore, KeywordIndex, SearchResult } from "../core/interfaces.js";
|
|
2
|
+
import type { RagConfig } from "../core/config.js";
|
|
3
|
+
import type { Quirk, QuirkInput } from "./types.js";
|
|
4
|
+
/** Dependencies required by all quirk-store operations. */
|
|
5
|
+
export interface QuirkStoreDeps {
|
|
6
|
+
embedder: EmbeddingProvider;
|
|
7
|
+
store: VectorStore;
|
|
8
|
+
keywordIndex: KeywordIndex;
|
|
9
|
+
cfg: RagConfig;
|
|
10
|
+
storePath: string;
|
|
11
|
+
}
|
|
12
|
+
/** Add a new quirk to the vector store, keyword index, and audit log. */
|
|
13
|
+
export declare function addQuirk(deps: QuirkStoreDeps, input: QuirkInput): Promise<Quirk>;
|
|
14
|
+
/** Remove a quirk by its ID. */
|
|
15
|
+
export declare function removeQuirk(deps: QuirkStoreDeps, id: string): Promise<void>;
|
|
16
|
+
/** List all quirks sorted by lastObserved descending. */
|
|
17
|
+
export declare function listQuirks(deps: QuirkStoreDeps): Promise<Quirk[]>;
|
|
18
|
+
/** Recall quirks matching a query, with confidence re-weighting. */
|
|
19
|
+
export declare function recallQuirks(deps: QuirkStoreDeps, query: string, options?: {
|
|
20
|
+
topK?: number;
|
|
21
|
+
quirkType?: string;
|
|
22
|
+
tags?: string[];
|
|
23
|
+
}): Promise<SearchResult[]>;
|
|
24
|
+
/** Lint quirks for low confidence, staleness, duplicates, and orphan source refs. */
|
|
25
|
+
export declare function lintQuirks(deps: QuirkStoreDeps): Promise<string[]>;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { retrieve } from "../retriever/retriever.js";
|
|
5
|
+
import { isQuirkAllowed } from "./monitor.js";
|
|
6
|
+
const QUIRK_FILE_PREFIX = "quirk:";
|
|
7
|
+
const QUIKK_JSONL = "quirks.jsonl";
|
|
8
|
+
function jsonlPath(storePath) {
|
|
9
|
+
return path.join(storePath, QUIKK_JSONL);
|
|
10
|
+
}
|
|
11
|
+
function isMemoryStore(storePath) {
|
|
12
|
+
return storePath.startsWith("memory:");
|
|
13
|
+
}
|
|
14
|
+
/** In-memory backup for memory:// stores. */
|
|
15
|
+
const memQuirks = new Map();
|
|
16
|
+
function readJsonl(filePath) {
|
|
17
|
+
if (!existsSync(filePath))
|
|
18
|
+
return [];
|
|
19
|
+
const raw = readFileSync(filePath, "utf-8").trim();
|
|
20
|
+
if (!raw)
|
|
21
|
+
return [];
|
|
22
|
+
return raw.split("\n").map((l) => JSON.parse(l));
|
|
23
|
+
}
|
|
24
|
+
function appendJsonl(filePath, q) {
|
|
25
|
+
const dir = path.dirname(filePath);
|
|
26
|
+
if (!existsSync(dir)) {
|
|
27
|
+
mkdirSync(dir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
appendFileSync(filePath, JSON.stringify(q) + "\n", "utf-8");
|
|
30
|
+
}
|
|
31
|
+
function rewriteJsonl(filePath, quirks) {
|
|
32
|
+
const dir = path.dirname(filePath);
|
|
33
|
+
if (!existsSync(dir)) {
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
writeFileSync(filePath, quirks.map((q) => JSON.stringify(q)).join("\n") + "\n", "utf-8");
|
|
37
|
+
}
|
|
38
|
+
function nowISO() {
|
|
39
|
+
return new Date().toISOString();
|
|
40
|
+
}
|
|
41
|
+
/** Add a new quirk to the vector store, keyword index, and audit log. */
|
|
42
|
+
export async function addQuirk(deps, input) {
|
|
43
|
+
const allowed = isQuirkAllowed(input.content);
|
|
44
|
+
if (!allowed.ok) {
|
|
45
|
+
throw new Error(`Quirk rejected by trust monitor: ${allowed.reason}`);
|
|
46
|
+
}
|
|
47
|
+
const id = `quirk:${randomUUID()}`;
|
|
48
|
+
const lastObserved = nowISO();
|
|
49
|
+
const confidence = input.confidence ?? 1;
|
|
50
|
+
const quirk = {
|
|
51
|
+
id,
|
|
52
|
+
content: input.content,
|
|
53
|
+
quirkType: input.quirkType,
|
|
54
|
+
tags: input.tags ?? [],
|
|
55
|
+
confidence,
|
|
56
|
+
lastObserved,
|
|
57
|
+
sourceRef: input.sourceRef,
|
|
58
|
+
};
|
|
59
|
+
const prefix = deps.cfg.embedding.documentPrefix ?? "";
|
|
60
|
+
const chunkContent = prefix + input.content;
|
|
61
|
+
const embeddings = await deps.embedder.embed([chunkContent], "document");
|
|
62
|
+
const embedding = embeddings[0];
|
|
63
|
+
if (!embedding || embedding.length === 0) {
|
|
64
|
+
throw new Error("Embedding returned empty vector for quirk content");
|
|
65
|
+
}
|
|
66
|
+
const chunk = {
|
|
67
|
+
id,
|
|
68
|
+
content: input.content,
|
|
69
|
+
description: "",
|
|
70
|
+
embedding,
|
|
71
|
+
metadata: {
|
|
72
|
+
filePath: QUIRK_FILE_PREFIX + id,
|
|
73
|
+
startLine: 0,
|
|
74
|
+
endLine: 0,
|
|
75
|
+
language: "quirk",
|
|
76
|
+
kind: "quirk",
|
|
77
|
+
quirkType: input.quirkType,
|
|
78
|
+
tags: input.tags ?? [],
|
|
79
|
+
confidence,
|
|
80
|
+
lastObserved,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
await deps.store.addChunks([chunk]);
|
|
84
|
+
deps.keywordIndex.addChunks([chunk]);
|
|
85
|
+
if (!isMemoryStore(deps.storePath)) {
|
|
86
|
+
appendJsonl(jsonlPath(deps.storePath), quirk);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
memQuirks.set(id, quirk);
|
|
90
|
+
}
|
|
91
|
+
return quirk;
|
|
92
|
+
}
|
|
93
|
+
/** Remove a quirk by its ID. */
|
|
94
|
+
export async function removeQuirk(deps, id) {
|
|
95
|
+
const filePath = QUIRK_FILE_PREFIX + id;
|
|
96
|
+
await deps.store.deleteByFilePath(filePath);
|
|
97
|
+
deps.keywordIndex.removeByFilePath(filePath);
|
|
98
|
+
if (!isMemoryStore(deps.storePath)) {
|
|
99
|
+
const jp = jsonlPath(deps.storePath);
|
|
100
|
+
const all = readJsonl(jp).filter((q) => q.id !== id);
|
|
101
|
+
rewriteJsonl(jp, all);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
memQuirks.delete(id);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** List all quirks sorted by lastObserved descending. */
|
|
108
|
+
export async function listQuirks(deps) {
|
|
109
|
+
if (!isMemoryStore(deps.storePath)) {
|
|
110
|
+
const jp = jsonlPath(deps.storePath);
|
|
111
|
+
if (existsSync(jp)) {
|
|
112
|
+
const all = readJsonl(jp);
|
|
113
|
+
all.sort((a, b) => b.lastObserved.localeCompare(a.lastObserved));
|
|
114
|
+
return all;
|
|
115
|
+
}
|
|
116
|
+
// Fallback: scan the store
|
|
117
|
+
const filePaths = await deps.store.getFilePaths();
|
|
118
|
+
const quirkPaths = filePaths.filter((fp) => fp.startsWith(QUIRK_FILE_PREFIX));
|
|
119
|
+
const result = [];
|
|
120
|
+
for (const fp of quirkPaths) {
|
|
121
|
+
const chunks = await deps.store.getChunksByFilePath(fp);
|
|
122
|
+
for (const c of chunks) {
|
|
123
|
+
result.push({
|
|
124
|
+
id: c.id,
|
|
125
|
+
content: c.content,
|
|
126
|
+
quirkType: c.metadata.quirkType,
|
|
127
|
+
tags: c.metadata.tags ?? [],
|
|
128
|
+
confidence: c.metadata.confidence ?? 1,
|
|
129
|
+
lastObserved: c.metadata.lastObserved ?? "",
|
|
130
|
+
sourceRef: undefined,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
result.sort((a, b) => b.lastObserved.localeCompare(a.lastObserved));
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
const all = [...memQuirks.values()];
|
|
138
|
+
all.sort((a, b) => b.lastObserved.localeCompare(a.lastObserved));
|
|
139
|
+
return all;
|
|
140
|
+
}
|
|
141
|
+
/** Recall quirks matching a query, with confidence re-weighting. */
|
|
142
|
+
export async function recallQuirks(deps, query, options) {
|
|
143
|
+
const topK = options?.topK ?? 10;
|
|
144
|
+
const minConfidence = deps.cfg.memory?.minConfidence ?? 0.5;
|
|
145
|
+
const filter = { kinds: ["quirk"] };
|
|
146
|
+
if (options?.quirkType) {
|
|
147
|
+
filter.languages = [options.quirkType];
|
|
148
|
+
}
|
|
149
|
+
const recallMinScore = deps.cfg.memory?.recallMinScore ?? 0.72;
|
|
150
|
+
const raw = await retrieve(query, deps.embedder, deps.store, {
|
|
151
|
+
topK: topK * 3,
|
|
152
|
+
minScore: recallMinScore,
|
|
153
|
+
keywordIndex: deps.keywordIndex,
|
|
154
|
+
keywordWeight: deps.cfg.retrieval.hybridSearch?.keywordWeight,
|
|
155
|
+
hybridEnabled: false,
|
|
156
|
+
queryPrefix: deps.cfg.embedding.queryPrefix,
|
|
157
|
+
filter,
|
|
158
|
+
});
|
|
159
|
+
// Confidence re-weighting + type/tag filtering
|
|
160
|
+
const filtered = raw.filter((r) => {
|
|
161
|
+
const conf = r.chunk.metadata.confidence ?? 1;
|
|
162
|
+
if (conf < minConfidence)
|
|
163
|
+
return false;
|
|
164
|
+
if (options?.quirkType && r.chunk.metadata.quirkType !== options.quirkType)
|
|
165
|
+
return false;
|
|
166
|
+
if (options?.tags?.length) {
|
|
167
|
+
const chunkTags = r.chunk.metadata.tags ?? [];
|
|
168
|
+
if (!options.tags.some((t) => chunkTags.includes(t)))
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
});
|
|
173
|
+
// Re-weight score by confidence, re-sort
|
|
174
|
+
for (const r of filtered) {
|
|
175
|
+
const conf = r.chunk.metadata.confidence ?? 1;
|
|
176
|
+
r.score = r.score * Math.min(1, Math.max(0.01, conf));
|
|
177
|
+
}
|
|
178
|
+
filtered.sort((a, b) => b.score - a.score);
|
|
179
|
+
return filtered.slice(0, topK);
|
|
180
|
+
}
|
|
181
|
+
/** Lint quirks for low confidence, staleness, duplicates, and orphan source refs. */
|
|
182
|
+
export async function lintQuirks(deps) {
|
|
183
|
+
const issues = [];
|
|
184
|
+
const quirks = await listQuirks(deps);
|
|
185
|
+
const cfg = deps.cfg.memory;
|
|
186
|
+
for (const q of quirks) {
|
|
187
|
+
if (q.confidence < (cfg?.minConfidence ?? 0.5)) {
|
|
188
|
+
issues.push(`Low confidence (${q.confidence}): "${q.content.slice(0, 60)}..." [${q.id}]`);
|
|
189
|
+
}
|
|
190
|
+
if (cfg?.decay?.enabled && q.lastObserved) {
|
|
191
|
+
const ageDays = (Date.now() - new Date(q.lastObserved).getTime()) / 86_400_000;
|
|
192
|
+
const halfLife = cfg.decay.halfLifeDays;
|
|
193
|
+
if (halfLife > 0 && ageDays > halfLife * 2) {
|
|
194
|
+
issues.push(`Stale (${Math.round(ageDays)}d old): "${q.content.slice(0, 60)}..." [${q.id}]`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Duplicate detection (lexically similar content)
|
|
199
|
+
for (let i = 0; i < quirks.length; i++) {
|
|
200
|
+
for (let j = i + 1; j < quirks.length; j++) {
|
|
201
|
+
const sim = lexicalSimilarity(quirks[i].content, quirks[j].content);
|
|
202
|
+
if (sim > 0.85) {
|
|
203
|
+
issues.push(`Near-duplicate (${(sim * 100).toFixed(0)}% similar): "${quirks[i].content.slice(0, 50)}..." ↔ "${quirks[j].content.slice(0, 50)}..."`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return issues;
|
|
208
|
+
}
|
|
209
|
+
/** Simple Jaccard-based lexical similarity (word overlap). */
|
|
210
|
+
function lexicalSimilarity(a, b) {
|
|
211
|
+
const wordsA = new Set(a.toLowerCase().split(/\W+/).filter(Boolean));
|
|
212
|
+
const wordsB = new Set(b.toLowerCase().split(/\W+/).filter(Boolean));
|
|
213
|
+
const intersection = new Set([...wordsA].filter((w) => wordsB.has(w)));
|
|
214
|
+
const union = new Set([...wordsA, ...wordsB]);
|
|
215
|
+
return union.size === 0 ? 0 : intersection.size / union.size;
|
|
216
|
+
}
|
|
217
|
+
//# sourceMappingURL=quirk-store.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Quirk sub-type labels. */
|
|
2
|
+
export type QuirkType = "gotcha" | "preference" | "decision" | "environment-constraint";
|
|
3
|
+
/** Input for adding a new quirk. */
|
|
4
|
+
export interface QuirkInput {
|
|
5
|
+
content: string;
|
|
6
|
+
quirkType?: string;
|
|
7
|
+
tags?: string[];
|
|
8
|
+
confidence?: number;
|
|
9
|
+
sourceRef?: string;
|
|
10
|
+
}
|
|
11
|
+
/** A quirk entry stored in the vectordb and audit log. */
|
|
12
|
+
export interface Quirk {
|
|
13
|
+
id: string;
|
|
14
|
+
content: string;
|
|
15
|
+
quirkType?: string;
|
|
16
|
+
tags: string[];
|
|
17
|
+
confidence: number;
|
|
18
|
+
lastObserved: string;
|
|
19
|
+
sourceRef?: string;
|
|
20
|
+
}
|
|
@@ -184,6 +184,9 @@ export class KeywordIndex {
|
|
|
184
184
|
startLine: chunk.metadata.startLine,
|
|
185
185
|
endLine: chunk.metadata.endLine,
|
|
186
186
|
language: chunk.metadata.language,
|
|
187
|
+
kind: chunk.metadata.kind ?? "",
|
|
188
|
+
quirkType: chunk.metadata.quirkType ?? "",
|
|
189
|
+
tags: chunk.metadata.tags ? JSON.stringify(chunk.metadata.tags) : "",
|
|
187
190
|
},
|
|
188
191
|
])),
|
|
189
192
|
};
|
|
@@ -215,6 +218,14 @@ export class KeywordIndex {
|
|
|
215
218
|
index.invertedIndex.set(token, docMap);
|
|
216
219
|
}
|
|
217
220
|
for (const [id, data] of Object.entries(parsed.chunkMap)) {
|
|
221
|
+
let tags;
|
|
222
|
+
try {
|
|
223
|
+
if (data.tags)
|
|
224
|
+
tags = JSON.parse(data.tags);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
tags = undefined;
|
|
228
|
+
}
|
|
218
229
|
index.chunkMap.set(id, {
|
|
219
230
|
id: data.id,
|
|
220
231
|
content: data.content,
|
|
@@ -224,6 +235,9 @@ export class KeywordIndex {
|
|
|
224
235
|
startLine: data.startLine,
|
|
225
236
|
endLine: data.endLine,
|
|
226
237
|
language: data.language,
|
|
238
|
+
kind: data.kind || undefined,
|
|
239
|
+
quirkType: data.quirkType || undefined,
|
|
240
|
+
tags,
|
|
227
241
|
},
|
|
228
242
|
});
|
|
229
243
|
}
|
|
@@ -251,6 +265,8 @@ function matchesFilter(chunk, filter) {
|
|
|
251
265
|
return true;
|
|
252
266
|
if (filter.languages?.length && !filter.languages.includes(chunk.metadata.language))
|
|
253
267
|
return false;
|
|
268
|
+
if (filter.kinds?.length && !filter.kinds.includes(chunk.metadata.kind ?? ""))
|
|
269
|
+
return false;
|
|
254
270
|
if (filter.pathPatterns?.length) {
|
|
255
271
|
return filter.pathPatterns.some((p) => globMatch(p, chunk.metadata.filePath));
|
|
256
272
|
}
|