opencode-dejavu 2.2.0 → 2.2.1
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 +16 -0
- package/README.md +1 -1
- package/index.ts +29 -8
- package/package.json +1 -1
- package/src/AGENTS.md +2 -0
- package/src/patterns.ts +20 -5
- package/src/store.ts +19 -8
- package/src/validate.ts +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.1 — 2026-08-24
|
|
4
|
+
|
|
5
|
+
### Fixed (adversarial-review round)
|
|
6
|
+
- Escalation order: the gate is written to the global store BEFORE being removed from the project store — a crash between the two writes leaves a duplicate (healed by migrate), never a hole.
|
|
7
|
+
- `dejavu:proceed` inside quoted strings no longer bypasses gates (`echo "dejavu:proceed" && gated-cmd` stays enforced); the marker is honored only outside quotes.
|
|
8
|
+
- Concurrent first-encounter race: calls dispatched in the same burst as a REMINDER (within 500ms) are reminded too instead of slipping through as a "retry".
|
|
9
|
+
- CRLF/CR commands normalize identically to LF; `splitChain` splits on CR — no more line-ending fragmentation.
|
|
10
|
+
- `normalizeCommand` is fully idempotent: quoted spans are parameterized BEFORE path rules (a `<str>` substitution inserts spaces that would expose an adjacent `/` to the path rule only on a second pass), fingerprint payloads are trimmed, and already-parameterized payloads are never re-fingerprinted.
|
|
11
|
+
- Interpreter flags glued to their payload (`node -e"code"`) fingerprint identically to the spaced form.
|
|
12
|
+
- Session state maps: inner key sets are capped — long sessions no longer grow unbounded.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- Lock degradation (contention > 3s) emits a `degraded` log event — the only window where concurrent writes can lose updates is now visible.
|
|
16
|
+
- `test/property.ts` — property-based tests for the normalization pipeline (idempotency, no nested tokens, output bound, one-liner distinctness, marker neutrality, splitChain atomicity).
|
|
17
|
+
- `test/fuzz.ts` — seeded mutation fuzzer with a metamorphic oracle and case shrinking; both harnesses run in CI. The harnesses caught the idempotency, marker-neutrality, glued-flag and nested-token-detector bugs above before production did.
|
|
18
|
+
|
|
3
19
|
## 2.2.0 — 2026-08-23
|
|
4
20
|
|
|
5
21
|
### Added
|
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ Restart OpenCode. Gates appear automatically as failures recur — nothing to co
|
|
|
66
66
|
- **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.
|
|
67
67
|
- **Aborted ≠ failed** — cancelled/aborted tool executions ("Tool execution aborted") are infrastructure noise and are never counted as failures.
|
|
68
68
|
- **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).
|
|
69
|
-
- **Concurrency** — gates.json mutations run under an exclusive lockfile; log appends and rotation take their own lock (every OpenCode window shares the global log); writes are tmp+rename with EPERM/EACCES/EBUSY retry (Windows AV/indexer). NT long paths get the `\\?\` prefix.
|
|
69
|
+
- **Concurrency** — gates.json mutations run under an exclusive lockfile; log appends and rotation take their own lock (every OpenCode window shares the global log); writes are tmp+rename with EPERM/EACCES/EBUSY retry (Windows AV/indexer). NT long paths get the `\\?\` prefix. If a lock cannot be acquired within 3s the critical section degrades to unlocked (the tool pipeline must never hang) and emits a `degraded` log event — the only window where updates can be lost is visible.
|
|
70
70
|
- **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).
|
|
71
71
|
- **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.
|
|
72
72
|
- **Migration** — gates outside the blocking policy are demoted to `watching` automatically; project copies of already-global gates are merged into the global gate (evidence is consolidated, never deleted).
|
package/index.ts
CHANGED
|
@@ -25,6 +25,12 @@ const TTL_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
|
25
25
|
const REVIEW_FIRES = 10
|
|
26
26
|
/** per-session state maps are capped to bound memory in long-lived processes */
|
|
27
27
|
const SESSION_MAP_CAP = 200
|
|
28
|
+
/** per-session key sets are capped too — one long session must not grow unbounded */
|
|
29
|
+
const SESSION_KEY_CAP = 500
|
|
30
|
+
/** a "retry" arriving this soon after a reminder was dispatched concurrently with it
|
|
31
|
+
* (same tool-call burst) and never saw the reminder — it gets reminded as well.
|
|
32
|
+
* A true agent retry needs a full model turn (≥1s in practice), so 500ms separates both. */
|
|
33
|
+
const REMINDER_RACE_WINDOW_MS = 500
|
|
28
34
|
/** handled part IDs are capped FIFO-style */
|
|
29
35
|
const HANDLED_CAP = 5000
|
|
30
36
|
const HANDLED_KEEP = 2500
|
|
@@ -41,10 +47,15 @@ function addToSetMap(map: Map<string, Set<string>>, outer: string, inner: string
|
|
|
41
47
|
map.set(outer, set)
|
|
42
48
|
}
|
|
43
49
|
set.add(inner)
|
|
50
|
+
while (set.size > SESSION_KEY_CAP) {
|
|
51
|
+
const oldest = set.values().next()
|
|
52
|
+
if (oldest.done) break
|
|
53
|
+
set.delete(oldest.value)
|
|
54
|
+
}
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
/** Drop oldest entries (Map preserves insertion order) to bound memory. */
|
|
47
|
-
function capMap(map: Map<
|
|
58
|
+
function capMap<K, V>(map: Map<K, V>, cap: number): void {
|
|
48
59
|
while (map.size > cap) {
|
|
49
60
|
const oldest = map.keys().next()
|
|
50
61
|
if (oldest.done) break
|
|
@@ -89,8 +100,8 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
89
100
|
: null
|
|
90
101
|
const stores = new Stores(globalStore, projectStore)
|
|
91
102
|
|
|
92
|
-
/** sessions in which a gate key was already reminded about */
|
|
93
|
-
const reminded = new Map<string,
|
|
103
|
+
/** sessions in which a gate key was already reminded about; value = remind time (race guard) */
|
|
104
|
+
const reminded = new Map<string, Map<string, number>>()
|
|
94
105
|
/** sessions in which a reminded pattern failed again — next attempt is blocked */
|
|
95
106
|
const failedAfterReminder = new Map<string, Set<string>>()
|
|
96
107
|
/** callID -> signature fallback when the after-hook does not receive args */
|
|
@@ -169,7 +180,10 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
169
180
|
const session = typeof input.sessionID === "string" ? input.sessionID : "unknown"
|
|
170
181
|
|
|
171
182
|
// Explicit escape hatch — checked only in the actionable text field,
|
|
172
|
-
// with word boundaries, so unrelated args cannot bypass gates.
|
|
183
|
+
// with word boundaries, so unrelated args cannot bypass gates. Quoted
|
|
184
|
+
// spans are stripped first: `echo "dejavu:proceed" && gated-cmd` must
|
|
185
|
+
// NOT bypass the gate on the chained command — the marker is a
|
|
186
|
+
// comment-style annotation, not data.
|
|
173
187
|
const commandText =
|
|
174
188
|
typeof rawArgs.command === "string"
|
|
175
189
|
? rawArgs.command
|
|
@@ -178,7 +192,7 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
178
192
|
: typeof rawArgs.filePath === "string"
|
|
179
193
|
? rawArgs.filePath
|
|
180
194
|
: ""
|
|
181
|
-
if (/\bdejavu:proceed\b/.test(commandText)) {
|
|
195
|
+
if (/\bdejavu:proceed\b/.test(commandText.replace(/"[^"]*"|'[^']*'/g, " "))) {
|
|
182
196
|
await stores.logAll({ type: "override", key: gate.key, tool: gate.tool, session, project: directory })
|
|
183
197
|
return
|
|
184
198
|
}
|
|
@@ -193,9 +207,16 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
193
207
|
}
|
|
194
208
|
|
|
195
209
|
// First encounter this session -> remind (the call is aborted; agent may retry corrected).
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
210
|
+
// Race guard: calls dispatched in the same burst all arrive before the agent can
|
|
211
|
+
// have seen any reminder, so a "retry" within REMINDER_RACE_WINDOW_MS of the
|
|
212
|
+
// remind is itself a concurrent first encounter and gets reminded too.
|
|
213
|
+
const sessionReminded = reminded.get(session) ?? new Map<string, number>()
|
|
214
|
+
if (!reminded.has(session)) reminded.set(session, sessionReminded)
|
|
215
|
+
const remindedAt = sessionReminded.get(gate.key)
|
|
216
|
+
if (remindedAt === undefined || Date.now() - remindedAt < REMINDER_RACE_WINDOW_MS) {
|
|
217
|
+
sessionReminded.set(gate.key, Date.now())
|
|
218
|
+
sessionReminded.set(patternKey(signature), Date.now()) // exact key too: retry may fuzzy-match differently
|
|
219
|
+
capMap(sessionReminded, SESSION_KEY_CAP)
|
|
199
220
|
capMap(reminded, SESSION_MAP_CAP)
|
|
200
221
|
gate.remindedCount += 1
|
|
201
222
|
await found.store.save()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-dejavu",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.1",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
package/src/AGENTS.md
CHANGED
|
@@ -24,11 +24,13 @@ Two dependency-free modules: `patterns.ts` (pure functions — call identity, no
|
|
|
24
24
|
## INVARIANTS (do not break)
|
|
25
25
|
|
|
26
26
|
- Rule order in `PARAM_RULES` matters: quoted strings first, specific tokens (uuid/sha/ip/url/date), generic numbers last — reordering fragments signatures
|
|
27
|
+
- Rule order in `normalizeCommand` matters too: quoted strings are parameterized BEFORE path rules — a `<str>` substitution inserts spaces that would expose an adjacent `/` to the path rule on a second pass (idempotency); interpreter payload hashing runs while the payload is still raw
|
|
27
28
|
- `scrubSecrets()` runs on every string before it touches disk; `recordFailure` re-scrubs defensively
|
|
28
29
|
- `canBlock(tool, sig)` = bash && non-diagnostic && not a bare one-liner shape — the ONLY path to `blocking`; probe tools use `PROMOTE_COUNT_PROBE` and never block
|
|
29
30
|
- `DIAGNOSTIC_VERBS` serves two callers (exit-1 allowlist + blocking policy) — one list, two uses; edit knowing both move
|
|
30
31
|
- Lock order is always project → global, gates → index (see `recordFailure` escalation) — reversing deadlocks; the log lock is separate and leaf-level
|
|
31
32
|
- Cross-project evidence lives ONLY in the global `index.json` — a gate's own `projects` array sees one store and never drives escalation alone
|
|
33
|
+
- Escalation writes the global gate FIRST, then removes the project copy — a crash between the two writes must leave a duplicate (healed by migrate), never a hole
|
|
32
34
|
- Inside `runLocked` always `load(true)`; unlocked `load()` peeks are routing hints only, never a basis for mutation
|
|
33
35
|
- `GateStore.load` caches by mtime — after external edits the cache refreshes on next stat; `save()` refreshes it manually
|
|
34
36
|
- Log appends and rotation take the log lock — every OpenCode window shares the global log; unlocked appends interleave into broken JSON
|
package/src/patterns.ts
CHANGED
|
@@ -51,16 +51,24 @@ export function scrubSecrets(text: string): string {
|
|
|
51
51
|
* Secrets are scrubbed before hashing so they neither persist nor fragment.
|
|
52
52
|
*/
|
|
53
53
|
const INTERPRETER_ONELINER =
|
|
54
|
-
/(?:^|[|;&(\n]\s*)(?:\S+[\\/])?(python3?|node|bun|deno|perl|ruby|pwsh|powershell)(?:\.exe)?(?:\s+-\w+)*\s+(-c|-e|--eval|-command)\s
|
|
54
|
+
/(?:^|[|;&(\n]\s*)(?:\S+[\\/])?(python3?|node|bun|deno|perl|ruby|pwsh|powershell)(?:\.exe)?(?:\s+-\w+)*\s+(-c|-e|--eval|-command)\s*/i
|
|
55
55
|
|
|
56
56
|
function hashInterpreterPayload(command: string): string {
|
|
57
57
|
const match = INTERPRETER_ONELINER.exec(command)
|
|
58
58
|
if (!match) return command
|
|
59
59
|
const payload = command.slice(match.index + match[0].length)
|
|
60
60
|
if (payload.trim() === "") return command
|
|
61
|
+
// Already fingerprinted (re-normalization) — keep the existing token so
|
|
62
|
+
// normalizeCommand stays idempotent.
|
|
63
|
+
if (/^<code:[0-9a-f]+>$/.test(payload.trim())) return command
|
|
64
|
+
// Already-parameterized placeholders are data, not code — never hash them
|
|
65
|
+
// (idempotency: a second pass must not fingerprint a <str>).
|
|
66
|
+
if (/^(?:<(?:str|path|n|hash|uuid|sha|md5|ip|url|email|date)>\s*)+$/.test(payload.trim())) return command
|
|
61
67
|
// For whole (unchained) commands the payload runs to end of string; chain
|
|
62
68
|
// segments are normalized separately, so segment keys stay exact.
|
|
63
|
-
|
|
69
|
+
// Trim before hashing: trailing whitespace (e.g. a stripped override marker)
|
|
70
|
+
// is not part of the code's identity.
|
|
71
|
+
const fingerprint = createHash("sha1").update(scrubSecrets(payload.trim())).digest("hex").slice(0, 8)
|
|
64
72
|
return `${command.slice(0, match.index + match[0].length)}<code:${fingerprint}>`
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -70,11 +78,18 @@ function hashInterpreterPayload(command: string): string {
|
|
|
70
78
|
* away so that "same failure, different instance" collapses into one pattern.
|
|
71
79
|
*/
|
|
72
80
|
export function normalizeCommand(command: string): string {
|
|
73
|
-
|
|
81
|
+
// CRLF/CR commands (Windows pastes, agent multi-line) normalize to LF —
|
|
82
|
+
// otherwise the same command fragments across line-ending styles.
|
|
83
|
+
let s = command.replace(/\r\n?/g, "\n")
|
|
84
|
+
s = s.replace(COMMENT_LINE, "$1").toLowerCase()
|
|
74
85
|
s = hashInterpreterPayload(s)
|
|
86
|
+
// Quoted spans come out FIRST: they are data, and removing them before the
|
|
87
|
+
// path rules keeps normalization idempotent — a <str> replacement inserts
|
|
88
|
+
// spaces that would otherwise expose an adjacent "/" to the path rule only
|
|
89
|
+
// on a second pass.
|
|
90
|
+
s = s.replace(/"[^"]*"|'[^']*'/g, " <str> ")
|
|
75
91
|
s = s.replace(/[a-z]:[\\/][^\s"']+/gi, " <path> ")
|
|
76
92
|
s = s.replace(/(^|\s)\/[^\s"']+/g, "$1<path> ")
|
|
77
|
-
s = s.replace(/"[^"]*"|'[^']*'/g, " <str> ")
|
|
78
93
|
// lookbehind: never re-parameterize the <code:...> fingerprint hex
|
|
79
94
|
s = s.replace(/(?<!<code:)\b[0-9a-f]{7,64}\b/gi, " <hash> ")
|
|
80
95
|
s = s.replace(/(?<!<code:)\b\d[\d.]*\b/g, " <n> ")
|
|
@@ -213,7 +228,7 @@ export function splitChain(command: string): string[] {
|
|
|
213
228
|
continue
|
|
214
229
|
}
|
|
215
230
|
if (depth === 0) {
|
|
216
|
-
if (ch === ";" || ch === "\n") {
|
|
231
|
+
if (ch === ";" || ch === "\n" || ch === "\r") {
|
|
217
232
|
flush()
|
|
218
233
|
i += 1
|
|
219
234
|
continue
|
package/src/store.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { canBlock, fuzzySimilar, scrubSecrets } from "./patterns"
|
|
|
4
4
|
import { coerceGateShape, repairGate } from "./validate"
|
|
5
5
|
|
|
6
6
|
/** Bumped on behavior changes; stamped into init log events so stale sessions are visible. */
|
|
7
|
-
export const PLUGIN_VERSION = "2.2.
|
|
7
|
+
export const PLUGIN_VERSION = "2.2.1"
|
|
8
8
|
|
|
9
9
|
export interface Gate {
|
|
10
10
|
/** sha1 signature prefix — the pattern identity */
|
|
@@ -62,6 +62,7 @@ export type LogEventType =
|
|
|
62
62
|
| "init"
|
|
63
63
|
| "repaired"
|
|
64
64
|
| "quarantined"
|
|
65
|
+
| "degraded"
|
|
65
66
|
|
|
66
67
|
export interface LogEvent {
|
|
67
68
|
type: LogEventType
|
|
@@ -137,7 +138,7 @@ const LOCK_WAIT_MS = 3000
|
|
|
137
138
|
* degradation: if the lock cannot be acquired within LOCK_WAIT_MS the
|
|
138
139
|
* critical section runs unlocked rather than hanging the tool pipeline.
|
|
139
140
|
*/
|
|
140
|
-
async function withLock<T>(lockTarget: string, fn: () => Promise<T
|
|
141
|
+
async function withLock<T>(lockTarget: string, fn: () => Promise<T>, onDegrade?: () => void): Promise<T> {
|
|
141
142
|
const lock = `${lockTarget}.lock`
|
|
142
143
|
await mkdir(ntPath(dirname(lock)), { recursive: true })
|
|
143
144
|
const started = Date.now()
|
|
@@ -157,7 +158,11 @@ async function withLock<T>(lockTarget: string, fn: () => Promise<T>): Promise<T>
|
|
|
157
158
|
} catch {
|
|
158
159
|
continue // lock vanished between attempts
|
|
159
160
|
}
|
|
160
|
-
if (Date.now() - started > LOCK_WAIT_MS)
|
|
161
|
+
if (Date.now() - started > LOCK_WAIT_MS) {
|
|
162
|
+
// The only window where concurrent writes can lose updates — make it visible.
|
|
163
|
+
if (onDegrade) onDegrade()
|
|
164
|
+
break
|
|
165
|
+
}
|
|
161
166
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
162
167
|
}
|
|
163
168
|
}
|
|
@@ -194,7 +199,9 @@ export class GateStore {
|
|
|
194
199
|
|
|
195
200
|
/** Run a load→mutate→save section under the store's exclusive lock. */
|
|
196
201
|
async runLocked<T>(fn: () => Promise<T>): Promise<T> {
|
|
197
|
-
return withLock(this.gatesPath, fn)
|
|
202
|
+
return withLock(this.gatesPath, fn, () => {
|
|
203
|
+
this.log({ type: "degraded", key: "gates.lock", snippet: `lock contention exceeded ${LOCK_WAIT_MS}ms; critical section ran unlocked` }).catch(() => {})
|
|
204
|
+
})
|
|
198
205
|
}
|
|
199
206
|
|
|
200
207
|
/**
|
|
@@ -273,7 +280,9 @@ export class GateStore {
|
|
|
273
280
|
|
|
274
281
|
/** Run an index load→mutate→save section under the index's own lock. */
|
|
275
282
|
async runLockedIndex<T>(fn: () => Promise<T>): Promise<T> {
|
|
276
|
-
return withLock(this.indexPath, fn)
|
|
283
|
+
return withLock(this.indexPath, fn, () => {
|
|
284
|
+
this.log({ type: "degraded", key: "index.lock", snippet: `lock contention exceeded ${LOCK_WAIT_MS}ms; critical section ran unlocked` }).catch(() => {})
|
|
285
|
+
})
|
|
277
286
|
}
|
|
278
287
|
|
|
279
288
|
/**
|
|
@@ -776,9 +785,8 @@ export class Stores {
|
|
|
776
785
|
|
|
777
786
|
let wentGlobal = false
|
|
778
787
|
if (store !== this.globalStore && this.projectStore && indexProjects >= input.globalProjects) {
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
await store.save()
|
|
788
|
+
// Global FIRST, then remove the local copy: a crash between the two
|
|
789
|
+
// writes must leave a duplicate (healed by migrate), never a hole.
|
|
782
790
|
await this.globalStore.runLocked(async () => {
|
|
783
791
|
const globalGates = await this.globalStore.load(true)
|
|
784
792
|
const existing = globalGates.find((g) => g.key === moved.key)
|
|
@@ -789,6 +797,9 @@ export class Stores {
|
|
|
789
797
|
}
|
|
790
798
|
await this.globalStore.save()
|
|
791
799
|
})
|
|
800
|
+
const idx = gates.findIndex((g) => g.key === moved.key)
|
|
801
|
+
if (idx >= 0) gates.splice(idx, 1)
|
|
802
|
+
await store.save()
|
|
792
803
|
wentGlobal = true
|
|
793
804
|
}
|
|
794
805
|
|
package/src/validate.ts
CHANGED
|
@@ -97,11 +97,11 @@ export function repairGate(gate: Gate): boolean {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* Corruption fingerprint of
|
|
101
|
-
* (
|
|
102
|
-
*
|
|
103
|
-
*
|
|
100
|
+
* Corruption fingerprint of a placeholder re-parameterized inside another
|
|
101
|
+
* token (`<code: <n> >` — a fingerprint eaten by the number rule). Only the
|
|
102
|
+
* `<code:` token carries nested content, so the check is scoped to it —
|
|
103
|
+
* shell text like heredoc `<<eof:` must NOT trip the detector.
|
|
104
104
|
*/
|
|
105
105
|
export function hasNestedTokens(signature: string): boolean {
|
|
106
|
-
return /<
|
|
106
|
+
return /<code:\s*</.test(signature)
|
|
107
107
|
}
|