opencode-dejavu 2.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 2.1.0 — 2026-08-22
4
+
5
+ First public release.
6
+
7
+ ### Added
8
+ - Gate enforcement state machine: remind on first same-session encounter, hard block after a reminded retry fails again; `dejavu:proceed` explicit escape hatch (logged as `override`).
9
+ - Two-scope store: project gates in `<repo>/.opencode/dejavu/`, escalation to global `~/.config/opencode/dejavu/` after 2+ distinct project dirs; lock order always project → global.
10
+ - Detection channels: `metadata.exit` (bash), line-by-line bash text scan, and `message.part.updated` event stream for tool-level errors; chain-segment matching so gates fire inside `a && gated` chains.
11
+ - Blocking policy: only non-diagnostic bash commands may block (`canBlock()`); probe tools use a higher promotion bar and never block.
12
+ - Secret scrubbing before any persistence (OpenAI/Anthropic/AWS/GitHub/Slack/Stripe/JWT/PEM/DB-conn/bearer/`root@host`, Google `AIza…`, full PEM blocks, `.env`-style `KEY=VALUE`).
13
+ - Near-duplicate consolidation via normalized Levenshtein ≤ 0.3 with an absolute floor of 3 edits.
14
+ - TTL expiry (60 days), log rotation, bounded in-memory session maps, `review: true` flagging, `recurredAfterGate` health metric.
15
+ - Observability: `log.jsonl` forensic events (`channel`, `via`, `exit`, `version`), `scripts/doctor.ts`, `scripts/analyze.ts`, `scripts/migrate.ts`.
16
+ - Companion agent skill (`skills/dejavu/`) and `/dejavu` status command (`command/dejavu.md`).
17
+ - `experimental.session.compacting` hook injecting active gates into compaction context.
18
+
19
+ ### Fixed (post-review hardening, same release line)
20
+ - `pendingCalls` no longer leaks entries for aborted (reminded/blocked) calls; capped at 1000.
21
+ - Quoted-string parameterization regex rewritten as an unrolled loop (no catastrophic backtracking).
22
+ - `migrate()` now also secret-scrubs gate `correction` fields.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WhiteBite
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ <p align="center">
2
+ <img src="logo/icon.svg" width="96" height="96" alt="dejavu logo — a lowercase d with two amber echo strokes">
3
+ </p>
4
+
5
+ <h3 align="center">dejavu — OpenCode error-gate plugin</h3>
6
+
7
+ <p align="center">
8
+ <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License">
9
+ <img src="https://img.shields.io/badge/OpenCode-plugin-3178c6.svg" alt="OpenCode plugin">
10
+ <img src="https://img.shields.io/badge/TypeScript-Bun-black.svg" alt="TypeScript + Bun">
11
+ <img src="https://github.com/WhiteBite/opencode-dejavu/actions/workflows/ci.yml/badge.svg" alt="CI">
12
+ </p>
13
+
14
+ Cross-session **memory prosthesis with teeth** for [OpenCode](https://github.com/anomalyco/opencode). AI agents repeat the same mistakes because they forget between sessions — and markdown rules don't fix that. dejavu mechanically detects recurring tool-call failures (bash, read, edit, write, glob, grep) and promotes them into enforced gates: a reminder on the next attempt, a hard block on same-session repeat offense. TypeScript + Bun, ships as source, no build step.
15
+
16
+ ## How it works
17
+
18
+ ```
19
+ tool call fails → signature normalized (paths/numbers/hashes stripped)
20
+ → pattern-key counted, sessions tracked
21
+ → 3 failures across 2 distinct sessions → gate promoted
22
+ next attempt → [dejavu] REMINDER thrown (call aborted, agent sees the correction)
23
+ retry fails again → same-session repeat offense → hard BLOCK on further attempts
24
+ ```
25
+
26
+ Design decisions (post-mortem of existing approaches):
27
+
28
+ - **Remind first, block on repeat.** Pure blocking starts an arms race — the agent routes around gates (`npm` blocked → uses `pnpm`). A reminder with the correction teaches; the block is reserved for ignored reminders.
29
+ - **Gate messages are teachers.** Every message carries `CORRECTION:` (what to do instead) and `EVIDENCE:` (N failures across M sessions), not just a prohibition.
30
+ - **Mechanical pattern-keys only.** No LLM-based error classification in the hot path — the unreliable component doesn't do reliability work.
31
+ - **Two scopes.** Repo-specific gotchas live in `<repo>/.opencode/dejavu/` (committable); patterns seen in 2+ project dirs are agent-level habits and move to `~/.config/opencode/dejavu/`.
32
+ - **Gates rot — so they expire.** 60 days without recurrence and a gate is dropped. A gate firing 10+ times while the error stopped gets `review: true` for manual inspection.
33
+ - **The metric is recurrence-after-gate.** Tracked per gate as `recurredAfterGate` — if gates don't reduce recurrence, the whole approach is wrong and you'll see it in the data.
34
+
35
+ ## Install
36
+
37
+ **npm (recommended)** — one line, OpenCode installs it automatically at startup:
38
+
39
+ ```jsonc
40
+ // ~/.config/opencode/opencode.json (global) or opencode.json (project)
41
+ { "plugin": ["opencode-dejavu"] }
42
+ ```
43
+
44
+ **From source:**
45
+
46
+ ```bash
47
+ git clone https://github.com/WhiteBite/opencode-dejavu ~/.config/opencode/vendor/dejavu
48
+ cd ~/.config/opencode/vendor/dejavu && bun install
49
+ ```
50
+
51
+ ```ts
52
+ // ~/.config/opencode/plugins/dejavu.ts
53
+ export { Dejavu } from "../vendor/dejavu/index.ts"
54
+ ```
55
+
56
+ Companion skill (agent behavior protocol): copy `skills/dejavu/` to `~/.config/opencode/skills/dejavu/`.
57
+ Status command: copy `command/dejavu.md` to `~/.config/opencode/command/dejavu.md`.
58
+
59
+ Restart OpenCode. Gates appear automatically as failures recur — nothing to configure.
60
+
61
+ ## Robustness & safety
62
+
63
+ - **Blocking policy** — only `bash` commands that are NOT diagnostics may ever become blocking gates. File probes (read/edit/write/glob/grep) and diagnostics (tsc/eslint/pytest/gradle-test/flutter/curl/grep...) stay `watching` forever: measured, visible in reports, but never interrupting the agent. `canBlock()` in `src/patterns.ts` is the single source of truth.
64
+ - **Secret scrubbing** — every signature and snippet passes `scrubSecrets()` (OpenAI/Anthropic/AWS/GitHub/Slack/Stripe/JWT/bearer/DB-conn-string/PEM patterns + `root@host`) before touching disk. Historical data is cleaned by `migrate()` at init or via `bun scripts/migrate.ts <dirs...>` (also scrubs logs).
65
+ - **Intended non-zero exits** — exit 1 from diagnostics is NOT a failure (that is their normal "found nothing / found issues" outcome). Exit ≥ 2 always counts.
66
+ - **File content is not command output** — text failure signatures are scanned for `bash` only; `read`/`edit`/`write` failures come exclusively from the event channel (a file containing "TypeError" is not a failure).
67
+ - **Concurrency** — gates.json mutations run under an exclusive lockfile; writes are tmp+rename with EPERM/EACCES/EBUSY retry (Windows AV/indexer). NT long paths get the `\\?\` prefix.
68
+ - **Near-duplicate consolidation** — new failures merge into existing patterns via normalized Levenshtein ≤ 0.3 with an absolute floor of 3 edits (replaces token Jaccard, which collapsed all `<str>` placeholders; the floor stops `git push` vs `git pull`-style merges).
69
+ - **Bounded memory** — per-session maps are capped (200 sessions) and freed on `session.deleted`; handled part IDs evict FIFO; TTL expiry re-runs every 6 h in long-lived processes.
70
+ - **Migration** — gates outside the blocking policy are demoted to `watching` automatically; nothing is deleted.
71
+
72
+ ## Observability (debugging aids)
73
+
74
+ - Every `log.jsonl` gets an `init` event with `PLUGIN_VERSION`; `detected` events carry `channel` (`exit`/`text`/`event`) and the raw exit code; `reminded`/`blocked` carry `via` (`exact`/`fuzzy`/`segment`). Stale plugin sessions are therefore visible in the data.
75
+ - `bun scripts/doctor.ts [projectDirs...]` — one-command pathology report: blocking gates outside policy, not-teaching gates (recurredAfterGate ≥ 3), annoying gates (reminded ≥ 10), secrets on disk, version drift.
76
+ - `bun scripts/analyze.ts [projectDirs...]` — store summary: statuses, tools, top patterns.
77
+ - `/dejavu` command (installed globally) runs doctor first, then reports.
78
+
79
+ ## Detection coverage
80
+
81
+ | Channel | Catches |
82
+ |---|---|
83
+ | `tool.execute.after` + `metadata.exit` | bash failures (non-zero exit, TS errors, test failures, stack traces) |
84
+ | `message.part.updated` event scan | tool-level failures (read of missing file, rejected edits) that never reach the after-hook; error text is Sentry-style parameterized (uuid/ip/url/hex/date → placeholders) |
85
+ | chain-segment matching | gates fire even when the gated command hides inside `x && gated-cmd` chains |
86
+ | companion skill | agent behavior protocol (how to react, when to annotate) |
87
+ | `/dejavu` command | status report: active gates, recurrence metric, review flags |
88
+
89
+ Not covered (by design, v1): semantically-equivalent-but-syntactically-different failures beyond fuzzy (Levenshtein ≤ 0.3, ≥ 3 edits) matching.
90
+
91
+ ## Data files
92
+
93
+ | File | Contents |
94
+ |---|---|
95
+ | `~/.config/opencode/dejavu/gates.json` | global gates (agent habits) |
96
+ | `<repo>/.opencode/dejavu/gates.json` | project gates (repo gotchas) |
97
+ | `*/dejavu/log.jsonl` | every event: detected, promoted, reminded, blocked, override, expired, recurred-after-gate |
98
+
99
+ Both are human-editable. Removing a gate object disables it. Editing `correction` improves what the agent is told.
100
+
101
+ ## Development
102
+
103
+ ```bash
104
+ bun install
105
+ bun run typecheck # tsc --noEmit (index.ts + src/**)
106
+ bun test/smoke.ts # behavioral smoke test, no framework needed
107
+ ```
108
+
109
+ Tunables are named constants at the top of `index.ts` and `src/store.ts`: `PROMOTE_COUNT` (3), `PROMOTE_COUNT_PROBE` (5), `PROMOTE_SESSIONS` (2), `GLOBAL_PROJECTS` (2), `TTL_DAYS` (60), `REVIEW_FIRES` (10).
110
+
111
+ ## Roadmap
112
+
113
+ - v2: recurrence-after-gate reporting command; V2 plugin API error hooks when stable
114
+ - v3: auto-proposal of ast-grep rules for statically detectable patterns (repo-level CI gates)
115
+
116
+ ## License
117
+
118
+ MIT
package/index.ts ADDED
@@ -0,0 +1,412 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+ import type { Plugin } from "@opencode-ai/plugin"
4
+ import {
5
+ bashSegmentSignatures,
6
+ callSignature,
7
+ detectFailure,
8
+ isIntendedNonzero,
9
+ parameterizeError,
10
+ patternKey,
11
+ scrubSecrets,
12
+ } from "./src/patterns"
13
+ import { GateStore, Stores, type Gate, PLUGIN_VERSION } from "./src/store"
14
+
15
+ // --- Tunables ---------------------------------------------------------------
16
+
17
+ /** distinct project dirs before a pattern is promoted to the global store */
18
+ const GLOBAL_PROJECTS = 2
19
+ /** gates expire when the pattern has not recurred for this many days */
20
+ const TTL_DAYS = 60
21
+ /** how often a long-lived process re-runs expiry */
22
+ const TTL_INTERVAL_MS = 6 * 60 * 60 * 1000
23
+ /** a gate firing this often without killing the error gets flagged for review */
24
+ const REVIEW_FIRES = 10
25
+ /** per-session state maps are capped to bound memory in long-lived processes */
26
+ const SESSION_MAP_CAP = 200
27
+ /** handled part IDs are capped FIFO-style */
28
+ const HANDLED_CAP = 5000
29
+ const HANDLED_KEEP = 2500
30
+ /** pendingCalls capped — aborted calls never reach the after-hook, so a cap bounds the fallback map */
31
+ const PENDING_CAP = 1000
32
+
33
+ /** Sentinel: intentional gate/reminder throws (rethrown); our own bugs are swallowed. */
34
+ class GateSignal extends Error {}
35
+
36
+ function addToSetMap(map: Map<string, Set<string>>, outer: string, inner: string): void {
37
+ let set = map.get(outer)
38
+ if (!set) {
39
+ set = new Set()
40
+ map.set(outer, set)
41
+ }
42
+ set.add(inner)
43
+ }
44
+
45
+ /** Drop oldest entries (Map preserves insertion order) to bound memory. */
46
+ function capMap(map: Map<string, Set<string>>, cap: number): void {
47
+ while (map.size > cap) {
48
+ const oldest = map.keys().next()
49
+ if (oldest.done) break
50
+ map.delete(oldest.value)
51
+ }
52
+ }
53
+
54
+ function scrubbedArgs(args: Record<string, unknown>): Record<string, unknown> {
55
+ if (typeof args.command === "string") return { ...args, command: scrubSecrets(args.command) }
56
+ if (typeof args.pattern === "string") return { ...args, pattern: scrubSecrets(args.pattern) }
57
+ return args
58
+ }
59
+
60
+ function remindMessage(gate: Gate): string {
61
+ const correction = gate.correction
62
+ ? `Correction: ${gate.correction}`
63
+ : "Do NOT retry it unchanged. Diagnose the root cause first, or take a different approach."
64
+ return [
65
+ `[dejavu] REMINDER — this exact call has already failed ${gate.count}x across ${gate.sessions.length} session(s).`,
66
+ `Last failure: ${gate.snippet}`,
67
+ correction,
68
+ `If you are certain it works now, retry — a repeated failure hardens this gate into a block. Explicit bypass: append the trailing comment "# dejavu:proceed" to the command — it is a marker read by the gate, NOT a shell command.`,
69
+ ].join("\n")
70
+ }
71
+
72
+ function blockMessage(gate: Gate, storeDir: string): string {
73
+ return [
74
+ `[dejavu] BLOCKED — you were reminded about this failing call in this session, retried it, and it failed again.`,
75
+ `CORRECTION: ${gate.correction ?? "Change approach entirely; do not repeat this exact call."}`,
76
+ `EVIDENCE: ${gate.count} failures across ${gate.sessions.length} sessions, first seen ${gate.firstSeen.slice(0, 10)}.`,
77
+ `Review or remove this gate: ${join(storeDir, "gates.json")} (key: ${gate.key})`,
78
+ ].join("\n")
79
+ }
80
+
81
+ export const Dejavu: Plugin = async ({ directory, client }) => {
82
+ // DEJAVU_HOME overrides the global store location (testing, custom setups).
83
+ const globalDir = process.env.DEJAVU_HOME ?? join(homedir(), ".config", "opencode", "dejavu")
84
+ const globalStore = new GateStore(globalDir)
85
+ const projectStore =
86
+ typeof directory === "string" && directory !== ""
87
+ ? new GateStore(join(directory, ".opencode", "dejavu"))
88
+ : null
89
+ const stores = new Stores(globalStore, projectStore)
90
+
91
+ /** sessions in which a gate key was already reminded about */
92
+ const reminded = new Map<string, Set<string>>()
93
+ /** sessions in which a reminded pattern failed again — next attempt is blocked */
94
+ const failedAfterReminder = new Map<string, Set<string>>()
95
+ /** callID -> signature fallback when the after-hook does not receive args */
96
+ const pendingCalls = new Map<string, string>()
97
+ /** message part IDs already counted as tool-level errors */
98
+ let handledParts = new Set<string>()
99
+
100
+ const logClient = async (level: "debug" | "info" | "warn" | "error", message: string): Promise<void> => {
101
+ try {
102
+ await client.app.log({ body: { service: "dejavu", level, message } })
103
+ } catch {
104
+ // logging must never break the plugin
105
+ }
106
+ }
107
+
108
+ // Init: migrate old data, expire stale gates, rotate logs, warm the caches.
109
+ try {
110
+ await stores.migrate()
111
+ await stores.expireAll(TTL_DAYS)
112
+ await stores.rotateLogs()
113
+ await stores.logAll({ type: "init", key: "dejavu", version: PLUGIN_VERSION })
114
+ await logClient("info", `dejavu initialized v${PLUGIN_VERSION}`)
115
+ } catch {
116
+ // init failures must not prevent hook registration
117
+ }
118
+
119
+ // Long-lived processes re-run expiry periodically.
120
+ const ttlTimer = setInterval(() => {
121
+ // expiry is best-effort; the timer keeps running regardless
122
+ stores.expireAll(TTL_DAYS).catch(() => {})
123
+ }, TTL_INTERVAL_MS)
124
+ ;(ttlTimer as { unref?: () => void }).unref?.()
125
+
126
+ return {
127
+ "tool.execute.before": async (input, output) => {
128
+ try {
129
+ const rawArgs = (output?.args ?? {}) as Record<string, unknown>
130
+ const args = scrubbedArgs(rawArgs)
131
+ const signature = callSignature(input.tool, args)
132
+ if (!signature) return
133
+
134
+ // Chain-bypass protection: a gate on "rm -rf /" must also fire when the
135
+ // command hides inside "git status && rm -rf /".
136
+ const candidates = [signature]
137
+ if (input.tool === "bash" && typeof args.command === "string") {
138
+ candidates.push(...bashSegmentSignatures(args.command))
139
+ }
140
+
141
+ let found: { gate: Gate; store: GateStore; via: "exact" | "fuzzy" | "segment" } | null = null
142
+ for (let i = 0; i < candidates.length; i++) {
143
+ const sig = candidates[i] ?? ""
144
+ const match = await stores.findGate(patternKey(sig), sig)
145
+ if (match && match.gate.status === "blocking") {
146
+ found = { gate: match.gate, store: match.store, via: i > 0 && match.via === "exact" ? "segment" : match.via }
147
+ break
148
+ }
149
+ }
150
+ if (!found) return
151
+ // Only track calls that will actually run: an aborted (thrown) call never
152
+ // reaches the after-hook, so recording it earlier would leak forever.
153
+ if (typeof input.callID === "string") {
154
+ pendingCalls.set(input.callID, signature)
155
+ while (pendingCalls.size > PENDING_CAP) {
156
+ const oldest = pendingCalls.keys().next()
157
+ if (oldest.done) break
158
+ pendingCalls.delete(oldest.value)
159
+ }
160
+ }
161
+
162
+ const gate = found.gate
163
+ const via = found.via
164
+ const session = typeof input.sessionID === "string" ? input.sessionID : "unknown"
165
+
166
+ // Explicit escape hatch — checked only in the actionable text field,
167
+ // with word boundaries, so unrelated args cannot bypass gates.
168
+ const commandText =
169
+ typeof rawArgs.command === "string"
170
+ ? rawArgs.command
171
+ : typeof rawArgs.pattern === "string"
172
+ ? rawArgs.pattern
173
+ : typeof rawArgs.filePath === "string"
174
+ ? rawArgs.filePath
175
+ : ""
176
+ if (/\bdejavu:proceed\b/.test(commandText)) {
177
+ await stores.logAll({ type: "override", key: gate.key, tool: gate.tool, session, project: directory })
178
+ return
179
+ }
180
+
181
+ // Repeat offense: reminded in this session, retried, failed again -> hard block.
182
+ if (failedAfterReminder.get(session)?.has(gate.key)) {
183
+ gate.blockedCount += 1
184
+ if (gate.blockedCount >= REVIEW_FIRES) gate.review = true
185
+ await found.store.save()
186
+ await stores.logAll({ type: "blocked", key: gate.key, tool: gate.tool, session, project: directory, via })
187
+ throw new GateSignal(blockMessage(gate, found.store.dir))
188
+ }
189
+
190
+ // First encounter this session -> remind (the call is aborted; agent may retry corrected).
191
+ if (!reminded.get(session)?.has(gate.key)) {
192
+ addToSetMap(reminded, session, gate.key)
193
+ addToSetMap(reminded, session, patternKey(signature)) // exact key too: retry may fuzzy-match differently
194
+ capMap(reminded, SESSION_MAP_CAP)
195
+ gate.remindedCount += 1
196
+ await found.store.save()
197
+ await stores.logAll({ type: "reminded", key: gate.key, tool: gate.tool, session, project: directory, via })
198
+ throw new GateSignal(remindMessage(gate))
199
+ }
200
+
201
+ // Already reminded, no repeated failure yet -> allow one retry.
202
+ await stores.logAll({ type: "retry-allowed", key: gate.key, tool: gate.tool, session, project: directory, via })
203
+ } catch (error) {
204
+ if (error instanceof GateSignal) throw error
205
+ // Our own bugs must never break the user's tool calls.
206
+ }
207
+ },
208
+
209
+ "tool.execute.after": async (input, output) => {
210
+ try {
211
+ // Primary signal: the tool's exit code in metadata (verified against live
212
+ // payloads — failed bash calls arrive as successful tool executions with
213
+ // metadata.exit !== 0 and often "(no output)" as the text).
214
+ const metadata = (output?.metadata ?? {}) as { exit?: unknown }
215
+ const exitCode = typeof metadata.exit === "number" ? metadata.exit : null
216
+ const isBash = input.tool === "bash"
217
+ // Text signatures apply to bash ONLY: for read/edit/write the output is
218
+ // file CONTENT, and scanning it for "TypeError" created false gates.
219
+ const text = typeof output?.output === "string" ? output.output : ""
220
+ const detection = isBash ? detectFailure(text) : { matched: false, snippet: "" }
221
+ const rawCommand = isBash && typeof (input as { args?: { command?: unknown } }).args?.command === "string"
222
+ ? String((input as { args: { command: string } }).args.command)
223
+ : ""
224
+ // grep/pytest/linters: exit 1 is often the INTENDED outcome, not a mistake.
225
+ const intended = exitCode === 1 && isIntendedNonzero(rawCommand, 1)
226
+ const failed = exitCode !== null ? exitCode !== 0 && !intended : detection.matched
227
+ if (!failed) return
228
+ const snippet = scrubSecrets(detection.matched ? detection.snippet : `exit code ${exitCode}`)
229
+
230
+ const args = scrubbedArgs(((input as { args?: unknown }).args ?? {}) as Record<string, unknown>)
231
+ let signature = callSignature(input.tool, args)
232
+ if (typeof input.callID === "string") {
233
+ if (!signature) signature = pendingCalls.get(input.callID) ?? null
234
+ pendingCalls.delete(input.callID)
235
+ }
236
+ if (!signature) return
237
+
238
+ // Attribution: if a segment of a failed chain matches an already-known
239
+ // pattern, record the failure under that segment's key — the chain
240
+ // wrapper changes every time, the recurring part does not.
241
+ let recordSignature = signature
242
+ if (input.tool === "bash" && typeof args.command === "string") {
243
+ for (const segSig of bashSegmentSignatures(args.command)) {
244
+ if (await stores.hasKey(patternKey(segSig))) {
245
+ recordSignature = segSig
246
+ break
247
+ }
248
+ }
249
+ }
250
+
251
+ const key = patternKey(recordSignature)
252
+ const session = typeof input.sessionID === "string" ? input.sessionID : "unknown"
253
+
254
+ const result = await stores.recordFailure({
255
+ key,
256
+ signature: recordSignature,
257
+ tool: input.tool,
258
+ sessionID: session,
259
+ projectDir: typeof directory === "string" ? directory : "",
260
+ snippet,
261
+ globalProjects: GLOBAL_PROJECTS,
262
+ })
263
+
264
+ await stores.logAll({
265
+ type: "detected",
266
+ key,
267
+ tool: input.tool,
268
+ session,
269
+ project: directory,
270
+ snippet,
271
+ channel: exitCode !== null ? "exit" : "text",
272
+ exit: exitCode ?? undefined,
273
+ })
274
+
275
+ if (result.promoted) {
276
+ await stores.logAll({ type: "promoted", key, tool: input.tool, session, project: directory })
277
+ await logClient(
278
+ "info",
279
+ `dejavu: gate promoted — "${result.gate.signature}" (${result.gate.count}x, ${result.gate.sessions.length} sessions)`,
280
+ )
281
+ }
282
+ if (result.wentGlobal) {
283
+ await logClient("info", `dejavu: gate went global — "${result.gate.signature}"`)
284
+ }
285
+
286
+ // Metric: failure of an already-enforced pattern (the event that
287
+ // promoted the gate does not count — the gate did not exist yet).
288
+ if (result.gate.status === "blocking" && !result.promoted) {
289
+ result.gate.recurredAfterGate += 1
290
+ await result.store.save()
291
+ await stores.logAll({ type: "recurred-after-gate", key, tool: input.tool, session, project: directory })
292
+ }
293
+
294
+ // Same-session repeat after a reminder -> escalate to hard block.
295
+ if (reminded.get(session)?.has(key) || reminded.get(session)?.has(result.gate.key)) {
296
+ addToSetMap(failedAfterReminder, session, result.gate.key)
297
+ capMap(failedAfterReminder, SESSION_MAP_CAP)
298
+ result.gate.recurredAfterReminder += 1
299
+ await result.store.save()
300
+ }
301
+ } catch {
302
+ // detection failures must never break the tool pipeline
303
+ }
304
+ },
305
+
306
+ event: async ({ event }) => {
307
+ try {
308
+ const type = (event as { type?: unknown }).type
309
+
310
+ // Free per-session state when a session is deleted.
311
+ if (type === "session.deleted") {
312
+ const props = (event as { properties?: unknown }).properties as { sessionID?: unknown } | undefined
313
+ if (typeof props?.sessionID === "string") {
314
+ reminded.delete(props.sessionID)
315
+ failedAfterReminder.delete(props.sessionID)
316
+ }
317
+ return
318
+ }
319
+
320
+ if (type !== "message.part.updated") return
321
+ // Tool-level failures (read of missing file, rejected edit, ...) never
322
+ // reach tool.execute.after — capture them from the message stream.
323
+ const props: unknown = (event as { properties?: unknown }).properties
324
+ if (typeof props !== "object" || props === null) return
325
+ const part: unknown = (props as { part?: unknown }).part
326
+ if (typeof part !== "object" || part === null) return
327
+ const p = part as { id?: unknown; type?: unknown; tool?: unknown; state?: unknown; sessionID?: unknown }
328
+ if (p.type !== "tool" || typeof p.id !== "string") return
329
+ if (handledParts.has(p.id)) return
330
+
331
+ const state: unknown = p.state
332
+ if (typeof state !== "object" || state === null) return
333
+ if ((state as { status?: unknown }).status !== "error") return
334
+
335
+ handledParts.add(p.id)
336
+ if (handledParts.size > HANDLED_CAP) {
337
+ handledParts = new Set([...handledParts].slice(-HANDLED_KEEP))
338
+ }
339
+
340
+ const toolName = typeof p.tool === "string" ? p.tool : "unknown"
341
+
342
+ const rawError: unknown = (state as { error?: unknown }).error
343
+ const rawText =
344
+ typeof rawError === "string" ? rawError : rawError === undefined ? "unknown error" : JSON.stringify(rawError)
345
+ // Never persist secrets or infrastructure details.
346
+ const errorText = scrubSecrets(rawText)
347
+ // Never count our own gate signals as failures — a thrown REMINDER/BLOCK
348
+ // comes back through this channel as a tool error.
349
+ if (errorText.includes("[dejavu]")) return
350
+ const session = typeof p.sessionID === "string" ? p.sessionID : "unknown"
351
+
352
+ // Prefer the real call signature from the tool input — it keeps the gate
353
+ // enforceable by the before-hook. Fall back to a parameterized error
354
+ // signature so "same root cause, different data" collapses to one key.
355
+ const toolInput: unknown = (state as { input?: unknown }).input
356
+ let signature: string | null = null
357
+ if (typeof toolInput === "object" && toolInput !== null) {
358
+ signature = callSignature(toolName, scrubbedArgs(toolInput as Record<string, unknown>))
359
+ }
360
+ if (!signature) {
361
+ signature = `${toolName}:tool-error:${parameterizeError(errorText).slice(0, 120)}`
362
+ }
363
+ const key = patternKey(signature)
364
+
365
+ const result = await stores.recordFailure({
366
+ key,
367
+ signature,
368
+ tool: toolName,
369
+ sessionID: session,
370
+ projectDir: typeof directory === "string" ? directory : "",
371
+ snippet: errorText.slice(0, 200),
372
+ globalProjects: GLOBAL_PROJECTS,
373
+ })
374
+ await stores.logAll({
375
+ type: "detected",
376
+ key,
377
+ tool: toolName,
378
+ session,
379
+ project: directory,
380
+ snippet: errorText.slice(0, 200),
381
+ channel: "event",
382
+ })
383
+ if (result.promoted) {
384
+ await stores.logAll({ type: "promoted", key, tool: toolName, session, project: directory })
385
+ await logClient("info", `dejavu: gate promoted — "${result.gate.signature}"`)
386
+ }
387
+ } catch {
388
+ // event stream must never be broken by us
389
+ }
390
+ },
391
+
392
+ "experimental.session.compacting": async (_input, output) => {
393
+ try {
394
+ const gates = await stores.blockingGates()
395
+ if (gates.length === 0) return
396
+ const lines = gates
397
+ .slice(0, 20)
398
+ .map(
399
+ (g) =>
400
+ `- \`${g.signature}\` — failed ${g.count}x in ${g.sessions.length} session(s). ${g.correction ?? "Do not retry unchanged; find the root cause first."}`,
401
+ )
402
+ output.context.push(
403
+ `## dejavu — active error gates\nThese tool calls have repeatedly failed before. Do not attempt them unchanged:\n${lines.join("\n")}`,
404
+ )
405
+ } catch {
406
+ // compaction enrichment is best-effort
407
+ }
408
+ },
409
+ }
410
+ }
411
+
412
+ export default Dejavu
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "opencode-dejavu",
3
+ "version": "2.1.0",
4
+ "description": "Cross-session memory prosthesis for OpenCode: detects recurring tool-call failures and promotes them into enforced gates. Remind first, block on same-session repeat offense.",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "files": [
8
+ "index.ts",
9
+ "src",
10
+ "LICENSE",
11
+ "README.md",
12
+ "CHANGELOG.md"
13
+ ],
14
+ "keywords": [
15
+ "opencode",
16
+ "opencode-plugin",
17
+ "ai-agents",
18
+ "coding-agent",
19
+ "error-gates",
20
+ "memory",
21
+ "reliability",
22
+ "bun"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/WhiteBite/opencode-dejavu.git"
27
+ },
28
+ "homepage": "https://github.com/WhiteBite/opencode-dejavu",
29
+ "bugs": "https://github.com/WhiteBite/opencode-dejavu/issues",
30
+ "scripts": {
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "license": "MIT",
34
+ "devDependencies": {
35
+ "@opencode-ai/plugin": "^1.18.0",
36
+ "@types/node": "^24.0.0",
37
+ "typescript": "^5.5.0"
38
+ }
39
+ }
package/src/AGENTS.md ADDED
@@ -0,0 +1,37 @@
1
+ # src/ — pattern engine + gate persistence
2
+
3
+ ## OVERVIEW
4
+
5
+ Two dependency-free modules: `patterns.ts` (pure functions — call identity, normalization, detection, policy) and `store.ts` (stateful — gates.json/log.jsonl I/O under locks, promotion, scope escalation).
6
+
7
+ ## WHERE TO LOOK
8
+
9
+ | Task | File | Symbols |
10
+ |------|------|---------|
11
+ | Call identity / gate keys | patterns.ts | `callSignature` → `normalizeCommand`/`normalizeFilePath` → `patternKey` |
12
+ | Chain-bypass protection | patterns.ts | `splitChain` (quote/paren-aware) → `bashSegmentSignatures` |
13
+ | Free-form error collapsing | patterns.ts | `parameterizeError` (event channel) vs `normalizeCommand` (bash) |
14
+ | Near-duplicate merge | patterns.ts | `fuzzySimilar` = normalized `levenshtein` ≤ 0.3 |
15
+ | Failure text scan | patterns.ts | `detectFailure` + `FAILURE_SIGNATURES` |
16
+ | Diagnostic/intended-exit logic | patterns.ts | `DIAGNOSTIC_VERBS`, `isIntendedNonzero`, `canBlock` |
17
+ | One scope (gates.json + log.jsonl) | store.ts | `GateStore` — `load`/`save`/`log`/`expire`/`rotateLog` |
18
+ | Two-scope logic + promotion | store.ts | `Stores` — `findGate`/`recordFailure`/`migrate`/`blockingGates` |
19
+ | fs safety | store.ts | `ntPath`, `atomicWrite`, `withLock` |
20
+
21
+ ## INVARIANTS (do not break)
22
+
23
+ - Rule order in `PARAM_RULES` matters: quoted strings first, specific tokens (uuid/sha/ip/url/date), generic numbers last — reordering fragments signatures
24
+ - `scrubSecrets()` runs on every string before it touches disk; `recordFailure` re-scrubs defensively
25
+ - `canBlock(tool, sig)` = `tool === "bash" && !diagnostic` — the ONLY path to `blocking`; probe tools use `PROMOTE_COUNT_PROBE` and never block
26
+ - `DIAGNOSTIC_VERBS` serves two callers (exit-1 allowlist + blocking policy) — one list, two uses; edit knowing both move
27
+ - Lock order is always project → global (see `recordFailure` escalation) — reversing deadlocks
28
+ - Inside `runLocked` always `load(true)`; unlocked `load()` peeks are routing hints only, never a basis for mutation
29
+ - `GateStore.load` caches by mtime — after external edits the cache refreshes on next stat; `save()` refreshes it manually
30
+ - Fuzzy matching is Levenshtein-based on purpose: token Jaccard collapsed all `<str>` placeholders into one bucket; ratio ≤ 0.3 PLUS absolute distance ≥ 3 (verb-level-different commands must never merge)
31
+
32
+ ## ANTI-PATTERNS
33
+
34
+ - Do NOT add a tool to `callSignature` without deciding its class: `PROBE_TOOLS` (higher bar, never blocks) or bash-class
35
+ - Do NOT widen `FAILURE_SIGNATURES` to cover file-tool output — that text is file content; extend the event channel instead
36
+ - Do NOT write gates.json directly — always `runLocked` + `save()` (atomicWrite); logs are append-only via `log()`
37
+ - Do NOT let `withLock` throw on contention — it degrades to unlocked after `LOCK_WAIT_MS` by design (pipeline must not hang)