opencode-rag-plugin 1.19.1 → 1.19.3

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 CHANGED
@@ -153,12 +153,16 @@ OpenCodeRAG gives your agent **persistent, cross-session memory** of non-obvious
153
153
  |------|----------|
154
154
  | `recall_quirks(query)` | You hit an error or need to remember a gotcha, preference, or decision from past sessions |
155
155
  | `add_quirk(content, { type, tags })` | You just discovered a non-obvious fact, workaround, or convention worth remembering |
156
+ | `update_quirk(id, { content, type, tags })` | A recalled quirk is outdated or wrong — fix it instead of adding a duplicate |
157
+ | `delete_quirk(id)` | A quirk is fixed, obsolete, or no longer applies — remove it |
156
158
 
157
159
  **CLI — manage quirks directly:**
158
160
 
159
161
  ```bash
160
162
  opencode-rag quirk add "npm needs --legacy-peer-deps" --type gotcha --tag installation
161
163
  opencode-rag quirk list
164
+ opencode-rag quirk update <id> --content "..." --type decision
165
+ opencode-rag quirk rm <id>
162
166
  opencode-rag quirk lint # flag low-confidence / stale / duplicate quirks
163
167
  opencode-rag quirk test "npm needs --legacy-peer-deps"
164
168
  # ✓ Quirk has been appended:
@@ -166,7 +170,7 @@ opencode-rag quirk test "npm needs --legacy-peer-deps"
166
170
  # 99% confidence
167
171
  ```
168
172
 
169
- When `memory.autoInject` is `true`, the plugin checks for relevant quirks on every user message using the combined agent-response + user-query as the search query. Quirks are only injected when their relevance score exceeds the threshold — `recallMinScore` (default 0.72) for the user message, `autoInjectMinScore` (default 0.45) for the system prompt. A latency budget (`autoInjectLatencyBudgetMs`, default 2000ms) prevents slow embedders from blocking message processing. To avoid polluting the context window, each quirk is injected **at most once per session** — once recalled, it is filtered out from all subsequent auto-injections. Every `add_quirk` is vetted by an immutable trust monitor that rejects destructive patterns (e.g. `rm -rf`, `force push`, `bypass security`). See [Plugin documentation](doc/plugin.md#9-quirk-memory-experiential-memory) and [CLI Reference: `quirk`](doc/cli.md#quirk).
173
+ When `memory.autoInject` is `true`, the plugin checks for relevant quirks on every user message using the combined agent-response + user-query as the search query. Quirks are only injected when their relevance score exceeds the threshold — `recallMinScore` (default 0.72) for the user message, `autoInjectMinScore` (default 0.45) for the system prompt. A latency budget (`autoInjectLatencyBudgetMs`, default 2000ms) prevents slow embedders from blocking message processing. To avoid polluting the context window, each quirk is injected **at most once per session** — once recalled, it is filtered out from all subsequent auto-injections. Every `add_quirk` and every content-changing `update_quirk` is vetted by an immutable trust monitor that rejects destructive patterns (e.g. `rm -rf`, `force push`, `bypass security`). Outdated or fixed quirks should be corrected with `update_quirk` / `delete_quirk` rather than left to contradict newer memory. See [Plugin documentation](doc/plugin.md#9-quirk-memory-experiential-memory) and [CLI Reference: `quirk`](doc/cli.md#quirk).
170
174
 
171
175
  ## MCP Server (Optional)
172
176
 
@@ -168,6 +168,7 @@ export function generateSkillFile() {
168
168
  "5. User asks about an image or visual asset → `describe_image(filePath)` to retrieve its generated description, then optionally `search_semantic` for related code",
169
169
  "6. You encounter an error or need a known pitfall → `recall_quirks(query)`",
170
170
  "7. You discover a non-obvious fact or workaround → `add_quirk(content)` to persist it",
171
+ "8. A recalled quirk is outdated or wrong → `update_quirk(id, ...)` to fix it, or `delete_quirk(id)` if it no longer applies",
171
172
  "",
172
173
  "### When to use each tool",
173
174
  "",
@@ -179,6 +180,8 @@ export function generateSkillFile() {
179
180
  "| `describe_image` | When the user refers to an image or asks \"what's in this screenshot/diagram?\" | `\"assets/login-screen.png\"` |",
180
181
  "| `recall_quirks` | You hit an error or need to remember a gotcha, preference, or decision from past sessions | `\"lancedb type casting\"` |",
181
182
  "| `add_quirk` | You just discovered a non-obvious fact, workaround, or convention worth remembering | `'\"npm needs --legacy-peer-deps\" --type gotcha --tag installation'` |",
183
+ "| `update_quirk` | A recalled quirk is outdated or wrong — fix its content, type, or tags | `id` from `recall_quirks` output + `content: \"...\"` |",
184
+ "| `delete_quirk` | A quirk is fixed, obsolete, or no longer applies | `id` from `recall_quirks` output |",
182
185
  "",
183
186
  "### Workflow",
184
187
  "",
@@ -203,6 +206,10 @@ export function generateSkillFile() {
203
206
  "- `get_file_skeleton`: `filePath` (req)",
204
207
  "- `find_usages`: `symbolName` (req), `pathHint?`, `topK?`",
205
208
  "- `describe_image`: `filePath` (req)",
209
+ "- `recall_quirks`: `query` (req), `topK?`, `quirkType?`, `tags?`",
210
+ "- `add_quirk`: `content` (req), `quirkType?`, `tags?`, `sourceRef?`",
211
+ "- `update_quirk`: `id` (req) + at least one of `content?`, `quirkType?`, `tags?`, `confidence?`, `sourceRef?`",
212
+ "- `delete_quirk`: `id` (req)",
206
213
  "",
207
214
  "### Tips",
208
215
  "",
@@ -1,5 +1,5 @@
1
1
  import { resolveCliContext, cleanupContext, logCliInfo, logCliError, c } from "../format.js";
2
- import { addQuirk, listQuirks, lintQuirks, recallQuirks, removeQuirk } from "../../quirks/quirk-store.js";
2
+ import { addQuirk, listQuirks, lintQuirks, recallQuirks, removeQuirk, updateQuirk } from "../../quirks/quirk-store.js";
3
3
  /**
4
4
  * Register the `quirk` command on the given Commander program.
5
5
  *
@@ -37,6 +37,40 @@ export function registerQuirkCommand(program) {
37
37
  process.exit(1);
38
38
  }
39
39
  });
40
+ quirkCmd
41
+ .command("update")
42
+ .description("Update a quirk by ID (content, type, tags, confidence, source ref)")
43
+ .argument("<id>", "quirk ID")
44
+ .option("--content <text>", "replacement quirk text")
45
+ .option("-t, --type <type>", "quirk type: gotcha, preference, decision, environment-constraint")
46
+ .option("--tag <tags...>", "replacement tags for filtering")
47
+ .option("--confidence <0-1>", "replacement confidence", parseFloat)
48
+ .option("--source-ref <path>", "source file path reference")
49
+ .option("-c, --config <path>", "path to config file")
50
+ .action(async (id, options) => {
51
+ try {
52
+ const ctx = await resolveCliContext(options, resolveLogPath());
53
+ const { config, embedder, store, keywordIndex } = ctx;
54
+ const tags = options.tag;
55
+ const quirk = await updateQuirk({ embedder, store, keywordIndex: keywordIndex, cfg: config, storePath: ctx.storePath }, id, {
56
+ content: options.content,
57
+ quirkType: options.type,
58
+ tags: tags ? (Array.isArray(tags) ? tags : [tags]) : undefined,
59
+ confidence: options.confidence,
60
+ sourceRef: options.sourceRef,
61
+ });
62
+ logCliInfo(ctx.logFilePath, "quirk update", `\n${c.success("Quirk updated:")}`);
63
+ logCliInfo(ctx.logFilePath, "quirk update", ` ${c.label("ID:")} ${quirk.id}`);
64
+ logCliInfo(ctx.logFilePath, "quirk update", ` ${c.label("Type:")} ${quirk.quirkType ?? "general"}`);
65
+ logCliInfo(ctx.logFilePath, "quirk update", ` ${c.label("Confidence:")} ${(quirk.confidence * 100).toFixed(0)}%`);
66
+ logCliInfo(ctx.logFilePath, "quirk update", ` ${c.label("Content:")} ${quirk.content}`);
67
+ await cleanupContext(ctx);
68
+ }
69
+ catch (err) {
70
+ logCliError(resolveLogPath(), "quirk update", `Failed to update quirk: ${err.message}`, err);
71
+ process.exit(1);
72
+ }
73
+ });
40
74
  quirkCmd
41
75
  .command("list")
42
76
  .description("List all quirks")
@@ -7,7 +7,7 @@
7
7
  import path from "node:path";
8
8
  import os from "node:os";
9
9
  import fs from "node:fs";
10
- import { c, resolveCliContext, cleanupContext, logCliError, logCliInfo, formatTimestamp } from "../format.js";
10
+ import { c, resolveCliContext, logCliError, logCliInfo, formatTimestamp } from "../format.js";
11
11
  import { getIndexStatusSummary } from "../../indexer.js";
12
12
  import { getPackageMetadata } from "../helpers.js";
13
13
  import { checkForUpdate } from "../../core/version-check.js";
@@ -156,7 +156,9 @@ export function registerStatusCommand(program) {
156
156
  }
157
157
  }).catch(() => { });
158
158
  }
159
- await cleanupContext(ctx);
159
+ // Force exit — avoid LanceDB close() hanging on Windows native bindings.
160
+ // Status is read-only so there's no state to lose.
161
+ process.exit(0);
160
162
  }
161
163
  catch (err) {
162
164
  const message = err.message || String(err);
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  /**
2
3
  * @fileoverview CLI entry point creating the Commander program, wiring all command modules, and handling auto-run detection.
3
4
  */
package/dist/cli/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  /**
2
3
  * @fileoverview CLI entry point creating the Commander program, wiring all command modules, and handling auto-run detection.
3
4
  */
@@ -266,14 +266,14 @@ export const DEFAULT_CONFIG = {
266
266
  },
267
267
  memory: {
268
268
  enabled: true,
269
- autoInject: false,
269
+ autoInject: true,
270
270
  minConfidence: 0.5,
271
- recallMinScore: 0.72,
272
- autoInjectMinScore: 0.6,
271
+ recallMinScore: 0.6,
272
+ autoInjectMinScore: 0.5,
273
273
  autoInjectLatencyBudgetMs: 2000,
274
274
  autoInjectTopK: 2,
275
275
  autoInjectMinTokenOverlap: 1,
276
- passiveCapture: false,
276
+ passiveCapture: true,
277
277
  promptEnforcement: true,
278
278
  sessionEndExtraction: true,
279
279
  autoCaptureMaxPerTurn: 2,
@@ -20,6 +20,8 @@ export const MANDATORY_GUIDANCE_LINES = [
20
20
  "- `describe_image(filePath)`: describe an image file using a vision model. Call when user refers to a screenshot, diagram, or image.",
21
21
  "- `recall_quirks(query)`: query experiential quirk memory (gotchas, preferences, decisions). Call when you hit an error or need to recall known pitfalls.",
22
22
  "- `add_quirk(content)`: store a new experiential memory. Call when you discover a non-obvious fact, gotcha, or coding convention.",
23
+ "- `update_quirk(id, ...)`: fix an outdated or wrong quirk (content, type, tags, confidence, source ref). The ID is shown in `recall_quirks` output.",
24
+ "- `delete_quirk(id)`: delete a quirk that is fixed, obsolete, or no longer applies. The ID is shown in `recall_quirks` output.",
23
25
  "",
24
26
  "Decision tree — ALWAYS follow this order:",
25
27
  "1. User mentions code behavior/architecture → `search_semantic(query)`",
@@ -29,6 +31,7 @@ export const MANDATORY_GUIDANCE_LINES = [
29
31
  "5. User asks about an image or visual asset → `describe_image(filePath)` to retrieve its generated description, then optionally `search_semantic` for related code",
30
32
  "6. You encounter an error or need to recall a known pitfall → `recall_quirks(query)`",
31
33
  "7. You discover a non-obvious fact or workaround → `add_quirk(content)` to persist it for future sessions",
34
+ "8. A recalled quirk is outdated or wrong → `update_quirk(id, ...)` to fix it, or `delete_quirk(id)` if it no longer applies",
32
35
  "",
33
36
  "Proactive triggers — you MUST call these tools when:",
34
37
  "- User asks about code behavior, architecture, or implementation details",
@@ -45,7 +48,7 @@ export const MANDATORY_GUIDANCE_LINES = [
45
48
  "- Answering code questions without calling `search_semantic` first (you guess at behavior)",
46
49
  "- Using `grep`/`glob` when `search_semantic` would find the answer faster",
47
50
  "- Treating image files as text — use `describe_image` instead of reading raw bytes",
48
- "- Using `npx opencode-rag quirk` shell commands instead of the built-in `add_quirk` / `recall_quirks` tools (the tools are faster, already loaded in-process, and go through the trust monitor)",
51
+ "- Using `npx opencode-rag quirk` shell commands instead of the built-in quirk tools (`add_quirk` / `recall_quirks` / `update_quirk` / `delete_quirk`) (the tools are faster, already loaded in-process, and go through the trust monitor)",
49
52
  ];
50
53
  /**
51
54
  * The conditional quirk-capture enforcement lines. Only included when
@@ -60,6 +63,9 @@ export const QUIRK_ENFORCEMENT_LINES = [
60
63
  "- You make a design decision that future sessions should remember",
61
64
  "- You resolve a gotcha that cost more than one attempt",
62
65
  "",
66
+ "MANDATORY quirk hygiene — you MUST call `update_quirk` or `delete_quirk` when:",
67
+ "- A stored quirk is outdated, wrong, or has been fixed — update it or delete it instead of adding a contradicting duplicate",
68
+ "",
63
69
  "Anti-pattern — NEVER finish a coding session without adding quirks for resolved errors.",
64
70
  ];
65
71
  /**
@@ -89,6 +95,7 @@ export function buildAgentsMdDirective(opts) {
89
95
  "- **Images via describe** — `describe_image(filePath)` — never read raw bytes",
90
96
  "- **Recall quirks** — `recall_quirks(query)` when you hit a known pitfall",
91
97
  "- **Add quirks** — `add_quirk(content)` when you discover a non-obvious fact",
98
+ "- **Fix quirks** — `update_quirk(id, ...)` / `delete_quirk(id)` when a stored quirk is outdated or wrong",
92
99
  "",
93
100
  "If no results, run `opencode-rag index`.",
94
101
  "",
@@ -100,6 +107,7 @@ export function buildAgentsMdDirective(opts) {
100
107
  "5. User asks about an image or visual asset → `describe_image(filePath)` to retrieve its generated description, then optionally `search_semantic` for related code",
101
108
  "6. You encounter an error or need to recall a known pitfall → `recall_quirks(query)`",
102
109
  "7. You discover a non-obvious fact or workaround → `add_quirk(content)` to persist it for future sessions",
110
+ "8. A recalled quirk is outdated or wrong → `update_quirk(id, ...)` to fix it, or `delete_quirk(id)` if it no longer applies",
103
111
  "",
104
112
  "### Proactive triggers — you MUST call these tools when",
105
113
  "- User asks about code behavior, architecture, or implementation details",
@@ -116,10 +124,10 @@ export function buildAgentsMdDirective(opts) {
116
124
  "- Answering code questions without calling `search_semantic` first (you guess at behavior)",
117
125
  "- Using `grep`/`glob` when `search_semantic` would find the answer faster",
118
126
  "- Treating image files as text — use `describe_image` instead of reading raw bytes",
119
- "- Using `npx opencode-rag quirk` shell commands instead of the built-in `add_quirk` / `recall_quirks` tools (the tools are faster, already loaded in-process, and go through the trust monitor)",
127
+ "- Using `npx opencode-rag quirk` shell commands instead of the built-in quirk tools (`add_quirk` / `recall_quirks` / `update_quirk` / `delete_quirk`) (the tools are faster, already loaded in-process, and go through the trust monitor)",
120
128
  ];
121
129
  if (opts.promptEnforcement) {
122
- lines.push("", "### MANDATORY quirk capture rules — you MUST call `add_quirk` when", "- A build, test, or type-check command fails and you resolve it", "- You discover an undocumented library constraint, peer dep, or workaround", "- You learn an environment-specific requirement (OS, tool version, etc.)", "- You make a design decision that future sessions should remember", "- You resolve a gotcha that cost more than one attempt", "- NEVER finish a coding session without adding quirks for resolved errors.");
130
+ lines.push("", "### MANDATORY quirk capture rules — you MUST call `add_quirk` when", "- A build, test, or type-check command fails and you resolve it", "- You discover an undocumented library constraint, peer dep, or workaround", "- You learn an environment-specific requirement (OS, tool version, etc.)", "- You make a design decision that future sessions should remember", "- You resolve a gotcha that cost more than one attempt", "", "### MANDATORY quirk hygiene — you MUST call `update_quirk` or `delete_quirk` when", "- A stored quirk is outdated, wrong, or has been fixed — update it or delete it instead of adding a contradicting duplicate", "- NEVER finish a coding session without adding quirks for resolved errors.");
123
131
  }
124
132
  lines.push(END_MARKER);
125
133
  return lines.join("\n");
@@ -105,3 +105,41 @@ export interface AddQuirkToolOptions {
105
105
  * @returns A tool definition suitable for OpenCode plugin registration.
106
106
  */
107
107
  export declare function createAddQuirkTool(options: AddQuirkToolOptions): ToolDefinition;
108
+ /** Options for creating the `update_quirk` tool. */
109
+ export interface UpdateQuirkToolOptions {
110
+ store: VectorStore;
111
+ embedder: EmbeddingProvider;
112
+ cfg: RagConfig;
113
+ keywordIndex: KeywordIndex;
114
+ storePath: string;
115
+ }
116
+ /**
117
+ * Create the `update_quirk` tool.
118
+ *
119
+ * Updates an existing quirk by ID — replaces content, type, tags, confidence,
120
+ * or source ref. When content changes, the quirk is re-embedded so recall
121
+ * matches the corrected text. The new content passes the trust monitor.
122
+ *
123
+ * @param options - Store, embedder, config, keyword index, store path.
124
+ * @returns A tool definition suitable for OpenCode plugin registration.
125
+ */
126
+ export declare function createUpdateQuirkTool(options: UpdateQuirkToolOptions): ToolDefinition;
127
+ /** Options for creating the `delete_quirk` tool. */
128
+ export interface DeleteQuirkToolOptions {
129
+ store: VectorStore;
130
+ embedder: EmbeddingProvider;
131
+ cfg: RagConfig;
132
+ keywordIndex: KeywordIndex;
133
+ storePath: string;
134
+ }
135
+ /**
136
+ * Create the `delete_quirk` tool.
137
+ *
138
+ * Removes a quirk by ID from the vector store, keyword index, and audit log.
139
+ * Use when a quirk is wrong, no longer applies, or has been superseded by an
140
+ * updated version.
141
+ *
142
+ * @param options - Store, embedder, config, keyword index, store path.
143
+ * @returns A tool definition suitable for OpenCode plugin registration.
144
+ */
145
+ export declare function createDeleteQuirkTool(options: DeleteQuirkToolOptions): ToolDefinition;
@@ -20,7 +20,7 @@ import { Parser } from "web-tree-sitter";
20
20
  import { initParser, loadLanguage, walkTree } from "../chunker/grammar.js";
21
21
  import { readFileSync } from "node:fs";
22
22
  import { resolveWorkspacePath } from "./tool-args.js";
23
- import { addQuirk, recallQuirks } from "../quirks/quirk-store.js";
23
+ import { addQuirk, updateQuirk, removeQuirk, recallQuirks } from "../quirks/quirk-store.js";
24
24
  const SKELETON_CONFIGS = {
25
25
  ".ts": { grammarName: "typescript", nodeTypes: ["function_declaration", "method_definition", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration"] },
26
26
  ".tsx": { grammarName: "typescript", nodeTypes: ["function_declaration", "method_definition", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration", "arrow_function"] },
@@ -620,4 +620,117 @@ export function createAddQuirkTool(options) {
620
620
  },
621
621
  });
622
622
  }
623
+ /**
624
+ * Create the `update_quirk` tool.
625
+ *
626
+ * Updates an existing quirk by ID — replaces content, type, tags, confidence,
627
+ * or source ref. When content changes, the quirk is re-embedded so recall
628
+ * matches the corrected text. The new content passes the trust monitor.
629
+ *
630
+ * @param options - Store, embedder, config, keyword index, store path.
631
+ * @returns A tool definition suitable for OpenCode plugin registration.
632
+ */
633
+ export function createUpdateQuirkTool(options) {
634
+ const { store, embedder, cfg, keywordIndex, storePath } = options;
635
+ return tool({
636
+ description: "Update an existing experiential memory (quirk) by its ID — fix outdated " +
637
+ "content, change its type, tags, or source ref. Use when a quirk from a " +
638
+ "past session is outdated, wrong, or superseded. Find the ID via " +
639
+ "`recall_quirks` (it is listed in the recall output) or `quirk list` " +
640
+ "in the CLI.",
641
+ args: {
642
+ id: tool.schema.string().min(1, "Quirk ID is required."),
643
+ content: tool.schema.string().optional(),
644
+ quirkType: tool.schema.string().optional(),
645
+ tags: tool.schema.array(tool.schema.string().min(1)).max(10).optional(),
646
+ confidence: tool.schema.number().min(0).max(1).optional(),
647
+ sourceRef: tool.schema.string().optional(),
648
+ },
649
+ async execute(args) {
650
+ try {
651
+ const deps = { embedder, store, keywordIndex, cfg, storePath };
652
+ const fields = [
653
+ args.content,
654
+ args.quirkType,
655
+ args.tags,
656
+ args.confidence,
657
+ args.sourceRef,
658
+ ];
659
+ if (fields.every((f) => f === undefined)) {
660
+ return {
661
+ title: "Quirk update",
662
+ output: "Nothing to update — provide at least one of `content`, `quirkType`, `tags`, `confidence`, or `sourceRef`.",
663
+ metadata: { tool: "update_quirk", quirkId: args.id, error: "no fields provided" },
664
+ };
665
+ }
666
+ const quirk = await updateQuirk(deps, args.id, {
667
+ content: args.content,
668
+ quirkType: args.quirkType,
669
+ tags: args.tags,
670
+ confidence: args.confidence,
671
+ sourceRef: args.sourceRef,
672
+ });
673
+ return {
674
+ title: "Quirk updated",
675
+ output: `**Quirk updated** (id: \`${quirk.id}\`)\n` +
676
+ `\`${quirk.quirkType ?? "general"}\` | confidence=${(quirk.confidence * 100).toFixed(0)}% | tags=${(quirk.tags ?? []).join(", ") || "none"}\n` +
677
+ quirk.content,
678
+ metadata: {
679
+ tool: "update_quirk",
680
+ quirkId: quirk.id,
681
+ quirkType: quirk.quirkType,
682
+ confidence: quirk.confidence,
683
+ },
684
+ };
685
+ }
686
+ catch (err) {
687
+ return {
688
+ title: "Quirk update",
689
+ output: `Failed to update quirk: ${err instanceof Error ? err.message : String(err)}`,
690
+ metadata: { tool: "update_quirk", error: String(err) },
691
+ };
692
+ }
693
+ },
694
+ });
695
+ }
696
+ /**
697
+ * Create the `delete_quirk` tool.
698
+ *
699
+ * Removes a quirk by ID from the vector store, keyword index, and audit log.
700
+ * Use when a quirk is wrong, no longer applies, or has been superseded by an
701
+ * updated version.
702
+ *
703
+ * @param options - Store, embedder, config, keyword index, store path.
704
+ * @returns A tool definition suitable for OpenCode plugin registration.
705
+ */
706
+ export function createDeleteQuirkTool(options) {
707
+ const { store, embedder, cfg, keywordIndex, storePath } = options;
708
+ return tool({
709
+ description: "Delete an experiential memory (quirk) by its ID. Use when a quirk is " +
710
+ "wrong, outdated, or superseded — for example a gotcha that was fixed " +
711
+ "or a decision that was reversed. Find the ID via `recall_quirks` (it " +
712
+ "is listed in the recall output) or `quirk list` in the CLI.",
713
+ args: {
714
+ id: tool.schema.string().min(1, "Quirk ID is required."),
715
+ },
716
+ async execute(args) {
717
+ try {
718
+ const deps = { embedder, store, keywordIndex, cfg, storePath };
719
+ await removeQuirk(deps, args.id);
720
+ return {
721
+ title: "Quirk deleted",
722
+ output: `**Quirk deleted** (id: \`${args.id}\`). It will no longer be recalled or auto-injected.`,
723
+ metadata: { tool: "delete_quirk", quirkId: args.id },
724
+ };
725
+ }
726
+ catch (err) {
727
+ return {
728
+ title: "Quirk delete",
729
+ output: `Failed to delete quirk: ${err instanceof Error ? err.message : String(err)}`,
730
+ metadata: { tool: "delete_quirk", error: String(err) },
731
+ };
732
+ }
733
+ },
734
+ });
735
+ }
623
736
  //# sourceMappingURL=tools.js.map
package/dist/plugin.js CHANGED
@@ -15,7 +15,7 @@ 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, createRecallQuirksTool, createAddQuirkTool, } from "./opencode/tools.js";
18
+ import { createFileSkeletonTool, createFindUsagesTool, createDescribeImageTool, createRecallQuirksTool, createAddQuirkTool, createUpdateQuirkTool, createDeleteQuirkTool, } 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";
@@ -645,6 +645,40 @@ export function createRagHooks(options) {
645
645
  error: err,
646
646
  });
647
647
  }
648
+ try {
649
+ const updateQuirkTool = createUpdateQuirkTool({
650
+ store,
651
+ embedder,
652
+ cfg: effectiveCfg,
653
+ keywordIndex: keywordIndex,
654
+ storePath: options.storePath,
655
+ });
656
+ tools["update_quirk"] = updateQuirkTool;
657
+ }
658
+ catch (err) {
659
+ appendDebugLog(options.logFilePath, {
660
+ scope: "plugin",
661
+ message: "Failed to register update_quirk tool",
662
+ error: err,
663
+ });
664
+ }
665
+ try {
666
+ const deleteQuirkTool = createDeleteQuirkTool({
667
+ store,
668
+ embedder,
669
+ cfg: effectiveCfg,
670
+ keywordIndex: keywordIndex,
671
+ storePath: options.storePath,
672
+ });
673
+ tools["delete_quirk"] = deleteQuirkTool;
674
+ }
675
+ catch (err) {
676
+ appendDebugLog(options.logFilePath, {
677
+ scope: "plugin",
678
+ message: "Failed to register delete_quirk tool",
679
+ error: err,
680
+ });
681
+ }
648
682
  if (readOverride) {
649
683
  const readTool = createRagReadTool({
650
684
  worktree: options.worktree,
@@ -9,9 +9,22 @@ export interface QuirkStoreDeps {
9
9
  cfg: RagConfig;
10
10
  storePath: string;
11
11
  }
12
+ /** Look up a single quirk by its ID, or `undefined` when not found. */
13
+ export declare function getQuirk(deps: QuirkStoreDeps, id: string): Promise<Quirk | undefined>;
14
+ /**
15
+ * Update an existing quirk by ID. Fields in `patch` override the stored values.
16
+ *
17
+ * When `content` changes, the new text must pass the trust monitor, the quirk
18
+ * is re-embedded, and the vector-store chunk + keyword index entry are replaced
19
+ * (same ID, new embedding). The audit log entry is rewritten in place.
20
+ *
21
+ * @throws If no quirk with the given ID exists, or the new content is rejected
22
+ * by the trust monitor.
23
+ */
24
+ export declare function updateQuirk(deps: QuirkStoreDeps, id: string, patch: Partial<QuirkInput>): Promise<Quirk>;
12
25
  /** Add a new quirk to the vector store, keyword index, and audit log. */
13
26
  export declare function addQuirk(deps: QuirkStoreDeps, input: QuirkInput): Promise<Quirk>;
14
- /** Remove a quirk by its ID. */
27
+ /** Remove a quirk by its ID. Throws if no quirk with the given ID exists. */
15
28
  export declare function removeQuirk(deps: QuirkStoreDeps, id: string): Promise<void>;
16
29
  /** List all quirks sorted by lastObserved descending. */
17
30
  export declare function listQuirks(deps: QuirkStoreDeps): Promise<Quirk[]>;
@@ -38,6 +38,106 @@ function rewriteJsonl(filePath, quirks) {
38
38
  function nowISO() {
39
39
  return new Date().toISOString();
40
40
  }
41
+ /** Look up a single quirk by its ID, or `undefined` when not found. */
42
+ export async function getQuirk(deps, id) {
43
+ if (!isMemoryStore(deps.storePath)) {
44
+ const jp = jsonlPath(deps.storePath);
45
+ if (existsSync(jp)) {
46
+ const found = readJsonl(jp).find((q) => q.id === id);
47
+ if (found)
48
+ return found;
49
+ }
50
+ const chunks = await deps.store.getChunksByFilePath(QUIRK_FILE_PREFIX + id);
51
+ const c = chunks[0];
52
+ if (c) {
53
+ return {
54
+ id: c.id,
55
+ content: c.content,
56
+ quirkType: c.metadata.quirkType,
57
+ tags: c.metadata.tags ?? [],
58
+ confidence: c.metadata.confidence ?? 1,
59
+ lastObserved: c.metadata.lastObserved ?? "",
60
+ sourceRef: undefined,
61
+ };
62
+ }
63
+ return undefined;
64
+ }
65
+ return memQuirks.get(id);
66
+ }
67
+ /**
68
+ * Update an existing quirk by ID. Fields in `patch` override the stored values.
69
+ *
70
+ * When `content` changes, the new text must pass the trust monitor, the quirk
71
+ * is re-embedded, and the vector-store chunk + keyword index entry are replaced
72
+ * (same ID, new embedding). The audit log entry is rewritten in place.
73
+ *
74
+ * @throws If no quirk with the given ID exists, or the new content is rejected
75
+ * by the trust monitor.
76
+ */
77
+ export async function updateQuirk(deps, id, patch) {
78
+ const existing = await getQuirk(deps, id);
79
+ if (!existing) {
80
+ throw new Error(`Quirk not found: ${id}`);
81
+ }
82
+ const content = patch.content ?? existing.content;
83
+ const quirkType = patch.quirkType ?? existing.quirkType;
84
+ const tags = patch.tags ?? existing.tags;
85
+ const confidence = patch.confidence ?? existing.confidence;
86
+ const sourceRef = patch.sourceRef ?? existing.sourceRef;
87
+ if (content !== existing.content) {
88
+ const allowed = isQuirkAllowed(content);
89
+ if (!allowed.ok) {
90
+ throw new Error(`Quirk rejected by trust monitor: ${allowed.reason}`);
91
+ }
92
+ }
93
+ const updated = {
94
+ id,
95
+ content,
96
+ quirkType,
97
+ tags,
98
+ confidence,
99
+ lastObserved: existing.lastObserved,
100
+ sourceRef,
101
+ };
102
+ const filePath = QUIRK_FILE_PREFIX + id;
103
+ await deps.store.deleteByFilePath(filePath);
104
+ deps.keywordIndex.removeByFilePath(filePath);
105
+ const prefix = deps.cfg.embedding.documentPrefix ?? "";
106
+ const chunkContent = prefix + content;
107
+ const embeddings = await deps.embedder.embed([chunkContent], "document");
108
+ const embedding = embeddings[0];
109
+ if (!embedding || embedding.length === 0) {
110
+ throw new Error("Embedding returned empty vector for quirk content");
111
+ }
112
+ const chunk = {
113
+ id,
114
+ content,
115
+ description: "",
116
+ embedding,
117
+ metadata: {
118
+ filePath,
119
+ startLine: 0,
120
+ endLine: 0,
121
+ language: "quirk",
122
+ kind: "quirk",
123
+ quirkType,
124
+ tags,
125
+ confidence,
126
+ lastObserved: updated.lastObserved,
127
+ },
128
+ };
129
+ await deps.store.addChunks([chunk]);
130
+ deps.keywordIndex.addChunks([chunk]);
131
+ if (!isMemoryStore(deps.storePath)) {
132
+ const jp = jsonlPath(deps.storePath);
133
+ const all = readJsonl(jp).map((q) => (q.id === id ? updated : q));
134
+ rewriteJsonl(jp, all);
135
+ }
136
+ else {
137
+ memQuirks.set(id, updated);
138
+ }
139
+ return updated;
140
+ }
41
141
  /** Add a new quirk to the vector store, keyword index, and audit log. */
42
142
  export async function addQuirk(deps, input) {
43
143
  const allowed = isQuirkAllowed(input.content);
@@ -90,8 +190,12 @@ export async function addQuirk(deps, input) {
90
190
  }
91
191
  return quirk;
92
192
  }
93
- /** Remove a quirk by its ID. */
193
+ /** Remove a quirk by its ID. Throws if no quirk with the given ID exists. */
94
194
  export async function removeQuirk(deps, id) {
195
+ const existing = await getQuirk(deps, id);
196
+ if (!existing) {
197
+ throw new Error(`Quirk not found: ${id}`);
198
+ }
95
199
  const filePath = QUIRK_FILE_PREFIX + id;
96
200
  await deps.store.deleteByFilePath(filePath);
97
201
  deps.keywordIndex.removeByFilePath(filePath);
@@ -10,6 +10,19 @@ export declare function l2Normalize(vec: number[]): number[];
10
10
  * @returns True if the error matches a known corruption pattern.
11
11
  */
12
12
  export declare function isCorruptionError(err: unknown): boolean;
13
+ /**
14
+ * Check whether an error is a LanceDB transient transaction conflict
15
+ * (e.g. "Incompatible transaction: This Append transaction is incompatible
16
+ * with concurrent transaction Restore at version ...").
17
+ *
18
+ * These are recoverable by retrying after the conflicting transaction finishes.
19
+ * Cross-process writes are the primary source; in-process writes are serialized
20
+ * by the write lock.
21
+ *
22
+ * @param err - The error to inspect.
23
+ * @returns True if the error matches a transient transaction conflict.
24
+ */
25
+ export declare function isTransientConflictError(err: unknown): boolean;
13
26
  /**
14
27
  * Atomically replace one LanceDB store directory with another.
15
28
  * Swaps the real directory with a temporary one that was built during a rebuild.
@@ -31,6 +44,23 @@ export declare class LanceDbStore implements VectorStore {
31
44
  private table;
32
45
  private tableInit;
33
46
  private writeLock;
47
+ /**
48
+ * Execute an async function under an exclusive write lock.
49
+ *
50
+ * All write operations (addChunks, deleteByFilePath, optimize, tryRepair) must
51
+ * go through this helper to prevent concurrent LanceDB transactions from
52
+ * conflicting (e.g. Append vs Restore, which produces the "Incompatible
53
+ * transaction" error).
54
+ *
55
+ * The lock is a Promise chain: each caller chains onto `this.writeLock` and
56
+ * sets it to a new promise that resolves only when its operation finishes
57
+ * (or throws). This guarantees FIFO serialization without any busy-waiting
58
+ * or timers.
59
+ *
60
+ * @param fn - The async function to execute under the lock.
61
+ * @returns The result of `fn`.
62
+ */
63
+ private withWriteLock;
34
64
  /**
35
65
  * @param dbPath - Filesystem path to the LanceDB database directory.
36
66
  * @param vectorDimension - Dimension of the embedding vectors. Default: 384.
@@ -191,4 +221,10 @@ export declare class LanceDbStore implements VectorStore {
191
221
  */
192
222
  private withCorruptionRecovery;
193
223
  private tryRepair;
224
+ /**
225
+ * Drop the existing chunks table and let getTable() create a fresh one.
226
+ * All indexed data is lost — callers should detect the empty table and
227
+ * trigger a re-index if needed.
228
+ */
229
+ private tryRebuildTable;
194
230
  }
@@ -27,9 +27,32 @@ export function l2Normalize(vec) {
27
27
  */
28
28
  export function isCorruptionError(err) {
29
29
  if (err instanceof Error) {
30
- return (err.message.includes("Not found") &&
30
+ return ((err.message.includes("Not found") &&
31
31
  err.message.includes(".lance") &&
32
- err.message.includes("lance error"));
32
+ err.message.includes("lance error")) ||
33
+ // Database has an incompatible transaction (e.g. a Restore from a prior
34
+ // version that conflicts with new Appends). This is a recoverable
35
+ // corruption — tryRepair() iterates prior versions to find a consistent one.
36
+ (err.message.includes("Incompatible transaction") &&
37
+ err.message.includes("version")));
38
+ }
39
+ return false;
40
+ }
41
+ /**
42
+ * Check whether an error is a LanceDB transient transaction conflict
43
+ * (e.g. "Incompatible transaction: This Append transaction is incompatible
44
+ * with concurrent transaction Restore at version ...").
45
+ *
46
+ * These are recoverable by retrying after the conflicting transaction finishes.
47
+ * Cross-process writes are the primary source; in-process writes are serialized
48
+ * by the write lock.
49
+ *
50
+ * @param err - The error to inspect.
51
+ * @returns True if the error matches a transient transaction conflict.
52
+ */
53
+ export function isTransientConflictError(err) {
54
+ if (err instanceof Error) {
55
+ return err.message.includes("Incompatible transaction");
33
56
  }
34
57
  return false;
35
58
  }
@@ -76,6 +99,43 @@ export class LanceDbStore {
76
99
  table = null;
77
100
  tableInit = null;
78
101
  writeLock = Promise.resolve(void 0);
102
+ /**
103
+ * Execute an async function under an exclusive write lock.
104
+ *
105
+ * All write operations (addChunks, deleteByFilePath, optimize, tryRepair) must
106
+ * go through this helper to prevent concurrent LanceDB transactions from
107
+ * conflicting (e.g. Append vs Restore, which produces the "Incompatible
108
+ * transaction" error).
109
+ *
110
+ * The lock is a Promise chain: each caller chains onto `this.writeLock` and
111
+ * sets it to a new promise that resolves only when its operation finishes
112
+ * (or throws). This guarantees FIFO serialization without any busy-waiting
113
+ * or timers.
114
+ *
115
+ * @param fn - The async function to execute under the lock.
116
+ * @returns The result of `fn`.
117
+ */
118
+ async withWriteLock(fn) {
119
+ const prev = this.writeLock;
120
+ let release = () => { };
121
+ this.writeLock = new Promise((resolve) => { release = resolve; });
122
+ await prev;
123
+ try {
124
+ return await fn();
125
+ }
126
+ catch (err) {
127
+ // Cross-process transient conflict (e.g. CLI vs plugin):
128
+ // wait briefly and retry once, still under the same lock hold.
129
+ if (isTransientConflictError(err)) {
130
+ await new Promise((resolve) => setTimeout(resolve, 100));
131
+ return await fn();
132
+ }
133
+ throw err;
134
+ }
135
+ finally {
136
+ release();
137
+ }
138
+ }
79
139
  /**
80
140
  * @param dbPath - Filesystem path to the LanceDB database directory.
81
141
  * @param vectorDimension - Dimension of the embedding vectors. Default: 384.
@@ -242,21 +302,18 @@ export class LanceDbStore {
242
302
  async addChunks(chunks) {
243
303
  if (chunks.length === 0)
244
304
  return;
245
- const done = this.writeLock.then(() => this.addChunksInternal(chunks));
246
- this.writeLock = done.catch(() => { });
247
- try {
248
- await done;
249
- }
250
- catch (err) {
251
- this.writeLock = Promise.resolve();
252
- if (isCorruptionError(err) && await this.tryRepair()) {
253
- const retry = this.addChunksInternal(chunks);
254
- this.writeLock = retry.catch(() => { });
255
- await retry;
256
- return;
305
+ await this.withWriteLock(async () => {
306
+ try {
307
+ await this.addChunksInternal(chunks);
257
308
  }
258
- throw err;
259
- }
309
+ catch (err) {
310
+ if (isCorruptionError(err) && await this.tryRepair()) {
311
+ await this.addChunksInternal(chunks);
312
+ return;
313
+ }
314
+ throw err;
315
+ }
316
+ });
260
317
  }
261
318
  async addChunksInternal(chunks) {
262
319
  const table = await this.getTable();
@@ -343,7 +400,7 @@ export class LanceDbStore {
343
400
  }
344
401
  catch (err) {
345
402
  if (isCorruptionError(err)) {
346
- const repaired = await this.tryRepair();
403
+ const repaired = await this.withWriteLock(() => this.tryRepair());
347
404
  if (repaired) {
348
405
  return this.searchInternal(embedding, topK, filter);
349
406
  }
@@ -577,19 +634,21 @@ export class LanceDbStore {
577
634
  * Should be called at the end of a successful index pass.
578
635
  */
579
636
  async optimize() {
580
- try {
581
- const table = await this.getTable();
582
- // Clean up versions older than 1 hour — not "right now" — so in-flight
583
- // queries (e.g. Web UI search, background auto-index) can finish before
584
- // their data files are reclaimed. Using new Date() here caused data-file
585
- // race conditions where a reader got "Not found: .lance" because the
586
- // GC deleted fragments that the current version still referenced.
587
- const threshold = new Date(Date.now() - 60 * 60 * 1000);
588
- await table.optimize({ cleanupOlderThan: threshold, deleteUnverified: false });
589
- }
590
- catch {
591
- // Optimize is best-effort — must not break indexing.
592
- }
637
+ await this.withWriteLock(async () => {
638
+ try {
639
+ const table = await this.getTable();
640
+ // Clean up versions older than 1 hour �?" not "right now" �?" so in-flight
641
+ // queries (e.g. Web UI search, background auto-index) can finish before
642
+ // their data files are reclaimed. Using new Date() here caused data-file
643
+ // race conditions where a reader got "Not found: �?� .lance" because the
644
+ // GC deleted fragments that the current version still referenced.
645
+ const threshold = new Date(Date.now() - 60 * 60 * 1000);
646
+ await table.optimize({ cleanupOlderThan: threshold, deleteUnverified: false });
647
+ }
648
+ catch {
649
+ // Optimize is best-effort �?" must not break indexing.
650
+ }
651
+ });
593
652
  }
594
653
  /**
595
654
  * Return all unique file paths currently stored in the index.
@@ -727,16 +786,18 @@ export class LanceDbStore {
727
786
  * @param filePath - The file path whose chunks should be deleted.
728
787
  */
729
788
  async deleteByFilePath(filePath) {
730
- try {
731
- await this.deleteByFilePathInternal(filePath);
732
- }
733
- catch (err) {
734
- if (isCorruptionError(err) && await this.tryRepair()) {
789
+ await this.withWriteLock(async () => {
790
+ try {
735
791
  await this.deleteByFilePathInternal(filePath);
736
- return;
737
792
  }
738
- throw err;
739
- }
793
+ catch (err) {
794
+ if (isCorruptionError(err) && await this.tryRepair()) {
795
+ await this.deleteByFilePathInternal(filePath);
796
+ return;
797
+ }
798
+ throw err;
799
+ }
800
+ });
740
801
  }
741
802
  async deleteByFilePathInternal(filePath) {
742
803
  const db = await this.getDb();
@@ -791,8 +852,13 @@ export class LanceDbStore {
791
852
  return await fn();
792
853
  }
793
854
  catch (err) {
794
- if (isCorruptionError(err) && await this.tryRepair()) {
795
- return fn();
855
+ if (isCorruptionError(err)) {
856
+ // Repair must be under writeLock to prevent Restore from conflicting
857
+ // with concurrent Append transactions (addChunks / deleteByFilePath).
858
+ const repaired = await this.withWriteLock(() => this.tryRepair());
859
+ if (repaired) {
860
+ return fn();
861
+ }
796
862
  }
797
863
  throw err;
798
864
  }
@@ -823,7 +889,7 @@ export class LanceDbStore {
823
889
  return false;
824
890
  }
825
891
  if (versions.length <= 1) {
826
- return false;
892
+ return this.tryRebuildTable(db);
827
893
  }
828
894
  const sorted = [...versions].sort((a, b) => b.version - a.version);
829
895
  for (const ver of sorted.slice(1)) {
@@ -839,10 +905,29 @@ export class LanceDbStore {
839
905
  continue;
840
906
  }
841
907
  }
842
- console.error("[lancedb] All version-restore attempts failed. " +
843
- "Run 'opencode-rag index --force' to rebuild the index.");
908
+ // All version-restore attempts failed (likely corrupted version graph
909
+ // with incompatible Restore transactions). Drop and recreate the table.
910
+ console.warn("[lancedb] Version restore failed. Dropping and recreating table to recover from corrupt version graph.");
911
+ return this.tryRebuildTable(db);
912
+ }
913
+ catch {
844
914
  return false;
845
915
  }
916
+ }
917
+ /**
918
+ * Drop the existing chunks table and let getTable() create a fresh one.
919
+ * All indexed data is lost — callers should detect the empty table and
920
+ * trigger a re-index if needed.
921
+ */
922
+ async tryRebuildTable(db) {
923
+ try {
924
+ await db.dropTable(TABLE_NAME).catch(() => { });
925
+ this.table = null;
926
+ // Re-create fresh via getTable → initTable
927
+ await this.getTable();
928
+ console.warn("[lancedb] Table recreated from scratch after corruption recovery.");
929
+ return true;
930
+ }
846
931
  catch {
847
932
  return false;
848
933
  }
package/dist/web/api.js CHANGED
@@ -244,7 +244,16 @@ async function handleQuirkLint(deps) {
244
244
  }
245
245
  /** Delete a single quirk by its ID from the store, index, and audit log. */
246
246
  async function handleQuirkDelete(deps, id) {
247
- await removeQuirk(deps, id);
247
+ try {
248
+ await removeQuirk(deps, id);
249
+ }
250
+ catch (err) {
251
+ const message = err instanceof Error ? err.message : String(err);
252
+ if (/Quirk not found/.test(message)) {
253
+ return { status: 404, body: { error: message } };
254
+ }
255
+ throw err;
256
+ }
248
257
  return { status: 200, body: { deleted: true, id } };
249
258
  }
250
259
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-rag-plugin",
3
- "version": "1.19.1",
3
+ "version": "1.19.3",
4
4
  "description": "OpenCode plugin for local-first RAG-based semantic code search",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin-entry.js",