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 +22 -0
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/index.ts +412 -0
- package/package.json +39 -0
- package/src/AGENTS.md +37 -0
- package/src/patterns.ts +333 -0
- package/src/store.ts +449 -0
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)
|