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/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 (skip in temp dirs / test environments)
1257
- const mcpCfg = effectiveCfg.mcp ?? { enabled: true };
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 stored and surfaced as a one-time install prompt via
1266
- // the system transform hook (see createRagHooks).
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,5 @@
1
+ /** Check whether quirk content is allowed within the immutable environment boundary. */
2
+ export declare function isQuirkAllowed(content: string): {
3
+ ok: boolean;
4
+ reason?: string;
5
+ };
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -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
  }