opencode-dejavu 2.5.0 → 2.6.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 +10 -0
- package/README.md +2 -1
- package/index.ts +15 -8
- package/package.json +1 -1
- package/src/AGENTS.md +1 -0
- package/src/store.ts +45 -3
- package/src/validate.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.6.0 — 2026-08-26
|
|
4
|
+
|
|
5
|
+
### Added (only well-grounded triggers)
|
|
6
|
+
- **Gates heal.** dejavu previously only saw failures, so a gate kept reminding even after the underlying command was fixed (the `ruff check .` false positive). Now a SUCCESS matching an enforced gate increments `succeededAfterGate`; after `HEAL_SUCCESSES` (3) consecutive successes the gate retires to `watching` and logs `healed`, so fixed commands stop triggering. A failure resets the streak.
|
|
7
|
+
|
|
8
|
+
## 2.5.1 — 2026-08-26
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Cross-project index + forensic log now key on the gate's OWN key (post fuzzy-consolidation), not the raw failure key. Before, a failure that fuzzy-merged into an existing gate indexed a key with no gate — orphaning the entry and silently starving that gate's cross-project escalation (the "INDEX ORPHANS" you'd see in doctor).
|
|
12
|
+
|
|
3
13
|
## 2.5.0 — 2026-08-24
|
|
4
14
|
|
|
5
15
|
### Changed (the three known gaps, closed)
|
package/README.md
CHANGED
|
@@ -72,6 +72,7 @@ Restart OpenCode. Gates appear automatically as failures recur — nothing to co
|
|
|
72
72
|
- **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.
|
|
73
73
|
- **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).
|
|
74
74
|
- **Self-healing** — every init reconciles the stores: an unparseable `gates.json` is quarantined (bytes preserved as `gates.json.corrupt-<ts>`), gate records are strictly parsed and mechanically repaired (inverted dates swapped, duplicate keys merged, secrets re-scrubbed, stale blocking demoted), unparseable log lines are excised to `log.jsonl.corrupt`, and the cross-project index is reconciled. Every repair is logged as a `repaired`/`quarantined` event.
|
|
75
|
+
- **Gates heal, not just accumulate** — dejavu sees successes too: a SUCCESS matching an enforced gate grows `succeededAfterGate`, and after 3 in a row the gate retires to `watching` (logged `healed`), so a command you fixed stops triggering reminders. A failure resets the streak. This kills the "ruff check passed 10 times but dejavu still reminds" false positive.
|
|
75
76
|
|
|
76
77
|
## Observability (debugging aids)
|
|
77
78
|
|
|
@@ -112,7 +113,7 @@ bun run typecheck # tsc --noEmit (index.ts + src/**)
|
|
|
112
113
|
bun test/smoke.ts # behavioral smoke test, no framework needed
|
|
113
114
|
```
|
|
114
115
|
|
|
115
|
-
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), `NOISE_TTL_DAYS` (7), `REVIEW_FIRES` (10), `MAX_GATES` (2000).
|
|
116
|
+
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), `NOISE_TTL_DAYS` (7), `REVIEW_FIRES` (10), `MAX_GATES` (2000), `HEAL_SUCCESSES` (3).
|
|
116
117
|
|
|
117
118
|
## Roadmap
|
|
118
119
|
|
package/index.ts
CHANGED
|
@@ -236,8 +236,6 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
236
236
|
// grep/pytest/linters: exit 1 is often the INTENDED outcome, not a mistake.
|
|
237
237
|
const intended = exitCode === 1 && isIntendedNonzero(rawCommand, 1)
|
|
238
238
|
const failed = exitCode !== null ? exitCode !== 0 && !intended : detection.matched
|
|
239
|
-
if (!failed) return
|
|
240
|
-
const snippet = scrubSecrets(detection.matched ? detection.snippet : `exit code ${exitCode}`)
|
|
241
239
|
|
|
242
240
|
const args = scrubbedArgs(((input as { args?: unknown }).args ?? {}) as Record<string, unknown>)
|
|
243
241
|
let signature = callSignature(input.tool, args)
|
|
@@ -247,9 +245,9 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
247
245
|
}
|
|
248
246
|
if (!signature) return
|
|
249
247
|
|
|
250
|
-
// Attribution: if a segment of
|
|
251
|
-
// pattern,
|
|
252
|
-
//
|
|
248
|
+
// Attribution: if a segment of the chain matches an already-known
|
|
249
|
+
// pattern, attribute to that segment's key — the chain wrapper changes
|
|
250
|
+
// every time, the recurring part does not.
|
|
253
251
|
let recordSignature = signature
|
|
254
252
|
if (input.tool === "bash" && typeof args.command === "string") {
|
|
255
253
|
for (const segSig of bashSegmentSignatures(args.command)) {
|
|
@@ -259,10 +257,19 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
259
257
|
}
|
|
260
258
|
}
|
|
261
259
|
}
|
|
262
|
-
|
|
263
260
|
const key = patternKey(recordSignature)
|
|
264
261
|
const session = typeof input.sessionID === "string" ? input.sessionID : "unknown"
|
|
265
262
|
|
|
263
|
+
// A SUCCESS matching an enforced gate is evidence the command got fixed —
|
|
264
|
+
// track the streak so healed commands stop reminding (only bash gates
|
|
265
|
+
// enforce, so only bash successes can heal).
|
|
266
|
+
if (!failed) {
|
|
267
|
+
if (isBash) await stores.recordSuccess({ key, signature: recordSignature, tool: input.tool })
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const snippet = scrubSecrets(detection.matched ? detection.snippet : `exit code ${exitCode}`)
|
|
272
|
+
|
|
266
273
|
const result = await stores.recordFailure({
|
|
267
274
|
key,
|
|
268
275
|
signature: recordSignature,
|
|
@@ -275,7 +282,7 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
275
282
|
|
|
276
283
|
await stores.logAll({
|
|
277
284
|
type: "detected",
|
|
278
|
-
key,
|
|
285
|
+
key: result.gate.key,
|
|
279
286
|
tool: input.tool,
|
|
280
287
|
session,
|
|
281
288
|
project: directory,
|
|
@@ -285,7 +292,7 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
285
292
|
})
|
|
286
293
|
|
|
287
294
|
if (result.promoted) {
|
|
288
|
-
await stores.logAll({ type: "promoted", key, tool: input.tool, session, project: directory })
|
|
295
|
+
await stores.logAll({ type: "promoted", key: result.gate.key, tool: input.tool, session, project: directory })
|
|
289
296
|
await logClient(
|
|
290
297
|
"info",
|
|
291
298
|
`dejavu: gate promoted — "${result.gate.signature}" (${result.gate.count}x, ${result.gate.sessions.length} sessions)`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-dejavu",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
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
|
@@ -40,6 +40,7 @@ Two dependency-free modules: `patterns.ts` (pure functions — call identity, no
|
|
|
40
40
|
- Inside `runLocked` always `load(true)`; unlocked `load()` peeks are routing hints only, never a basis for mutation
|
|
41
41
|
- Hot-path reads use the 1s TTL cache + key index (`byKey`/`enforcedOnly`); mutations inside locks use `load(true)`; `save()` refreshes the cache directly
|
|
42
42
|
- The remind→block chain is persisted ON THE GATE (`remindedSessions`/`failedSessions`) and enforced under the store lock — process memory holds nothing authoritative, so several windows and restarts share one escalation
|
|
43
|
+
- Successes heal: `recordSuccess` grows `succeededAfterGate` on an enforced gate; at `HEAL_SUCCESSES` (3) it retires to `watching` and logs `healed`. A failure resets the streak in `recordFailure`. Only bash successes heal (only bash gates enforce)
|
|
43
44
|
- Log appends and rotation take the log lock — every OpenCode window shares the global log; unlocked appends interleave into broken JSON
|
|
44
45
|
- Every gate read from disk crosses `coerceGateShape` + `repairGate` in `load()` — enforcement never sees raw state; hopeless records are dropped, repairable ones coerced
|
|
45
46
|
- Quarantine preserves bytes: unparseable files are renamed to `*.corrupt-*`, never deleted; every repair emits a `repaired`/`quarantined` log event
|
package/src/store.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { canBlock, canRemind, fuzzySimilar, FUZZY_MAX_LEN, isRepoLocal, scrubSec
|
|
|
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.
|
|
7
|
+
export const PLUGIN_VERSION = "2.6.0"
|
|
8
8
|
|
|
9
9
|
export interface Gate {
|
|
10
10
|
/** sha1 signature prefix — the pattern identity */
|
|
@@ -31,6 +31,9 @@ export interface Gate {
|
|
|
31
31
|
recurredAfterReminder: number
|
|
32
32
|
/** the core health metric: failures of this pattern AFTER it became a gate */
|
|
33
33
|
recurredAfterGate: number
|
|
34
|
+
/** consecutive successes after the gate was enforced — reaching HEAL_SUCCESSES
|
|
35
|
+
* retires the gate to watching (the underlying command got fixed) */
|
|
36
|
+
succeededAfterGate?: number
|
|
34
37
|
/** flagged for manual review when the gate fires often but errors stopped */
|
|
35
38
|
review?: boolean
|
|
36
39
|
/** sessions currently reminded about this gate: sessionID -> remind time (ms).
|
|
@@ -73,6 +76,7 @@ export type LogEventType =
|
|
|
73
76
|
| "quarantined"
|
|
74
77
|
| "degraded"
|
|
75
78
|
| "retired-healed"
|
|
79
|
+
| "healed"
|
|
76
80
|
|
|
77
81
|
export interface LogEvent {
|
|
78
82
|
type: LogEventType
|
|
@@ -108,6 +112,9 @@ export const PROMOTE_COUNT_PROBE = 5
|
|
|
108
112
|
export const PROBE_TOOLS = new Set(["read", "glob", "grep", "write", "edit"])
|
|
109
113
|
/** distinct sessions required — same-session loops never promote */
|
|
110
114
|
export const PROMOTE_SESSIONS = 2
|
|
115
|
+
/** consecutive successes after a gate that retire it — the command is fixed,
|
|
116
|
+
* so the gate must stop reminding (the ruff-check-false-positive case) */
|
|
117
|
+
export const HEAL_SUCCESSES = 3
|
|
111
118
|
/** store size bound: flooding with unique failures must not bloat gates.json
|
|
112
119
|
* or slow the fuzzy scan — the weakest watching gate is evicted past this */
|
|
113
120
|
export const MAX_GATES = 2000
|
|
@@ -933,6 +940,8 @@ export class Stores {
|
|
|
933
940
|
// Only an exact-key failure updates the evidence: a crafted near-duplicate
|
|
934
941
|
// must not overwrite a legitimate gate's snippet via fuzzy consolidation.
|
|
935
942
|
if (!fuzzyConsolidated) gate.snippet = scrubSecrets(input.snippet)
|
|
943
|
+
// A failure breaks any heal streak — the command is still broken.
|
|
944
|
+
gate.succeededAfterGate = 0
|
|
936
945
|
|
|
937
946
|
let promoted = false
|
|
938
947
|
const threshold = PROBE_TOOLS.has(input.tool) ? PROMOTE_COUNT_PROBE : PROMOTE_COUNT
|
|
@@ -954,14 +963,17 @@ export class Stores {
|
|
|
954
963
|
// ever sees its own store's directory, so alone it can never reach two
|
|
955
964
|
// projects. A pattern seen in enough distinct project dirs is an
|
|
956
965
|
// agent-level habit, not a repo quirk — move it to the global store.
|
|
966
|
+
// Keyed by the gate's OWN key (post fuzzy-consolidation), not the raw
|
|
967
|
+
// failure key — otherwise consolidated failures index a key that has no
|
|
968
|
+
// gate, orphaning the entry and starving the gate's escalation.
|
|
957
969
|
// Lock order is always gates -> index and project -> global: no cycles.
|
|
958
970
|
const moved = gate
|
|
959
971
|
const indexProjects = await this.globalStore.runLockedIndex(async () => {
|
|
960
972
|
const index = await this.globalStore.loadIndex(true)
|
|
961
|
-
let entry = index.keys[
|
|
973
|
+
let entry = index.keys[moved.key]
|
|
962
974
|
if (!entry) {
|
|
963
975
|
entry = { projects: [], lastSeen: now }
|
|
964
|
-
index.keys[
|
|
976
|
+
index.keys[moved.key] = entry
|
|
965
977
|
}
|
|
966
978
|
if (input.projectDir !== "" && !entry.projects.includes(input.projectDir)) {
|
|
967
979
|
entry.projects.push(input.projectDir)
|
|
@@ -1003,4 +1015,34 @@ export class Stores {
|
|
|
1003
1015
|
return { gate: moved, store, promoted, wentGlobal }
|
|
1004
1016
|
})
|
|
1005
1017
|
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* A SUCCESS matching an enforced gate is evidence the underlying command got
|
|
1021
|
+
* fixed. Track a streak; once it reaches HEAL_SUCCESSES the gate retires to
|
|
1022
|
+
* watching so it stops reminding on a now-healthy command (the
|
|
1023
|
+
* `ruff check .` false-positive case). Only enforced (blocking/reminding)
|
|
1024
|
+
* gates heal; a failure resets the streak in recordFailure.
|
|
1025
|
+
*/
|
|
1026
|
+
async recordSuccess(input: { key: string; signature: string; tool: string }): Promise<void> {
|
|
1027
|
+
const match = await this.findGate(input.key, input.signature)
|
|
1028
|
+
if (match === null || match.gate.status === "watching") return
|
|
1029
|
+
const store = match.store
|
|
1030
|
+
const gateKey = match.gate.key
|
|
1031
|
+
await store.runLocked(async () => {
|
|
1032
|
+
const fresh = (await store.load(true)).find((g) => g.key === gateKey)
|
|
1033
|
+
if (fresh === undefined || fresh.status === "watching") return
|
|
1034
|
+
fresh.succeededAfterGate = (fresh.succeededAfterGate ?? 0) + 1
|
|
1035
|
+
const healed = fresh.succeededAfterGate >= HEAL_SUCCESSES
|
|
1036
|
+
if (healed) fresh.status = "watching"
|
|
1037
|
+
await store.save()
|
|
1038
|
+
if (healed) {
|
|
1039
|
+
await store.log({
|
|
1040
|
+
type: "healed",
|
|
1041
|
+
key: fresh.key,
|
|
1042
|
+
tool: fresh.tool,
|
|
1043
|
+
snippet: `succeeded ${fresh.succeededAfterGate}x in a row after the gate — retired to watching`,
|
|
1044
|
+
})
|
|
1045
|
+
}
|
|
1046
|
+
})
|
|
1047
|
+
}
|
|
1006
1048
|
}
|
package/src/validate.ts
CHANGED
|
@@ -52,6 +52,9 @@ export function coerceGateShape(raw: unknown): Gate | null {
|
|
|
52
52
|
recurredAfterReminder: num(r.recurredAfterReminder, 0),
|
|
53
53
|
recurredAfterGate: num(r.recurredAfterGate, 0),
|
|
54
54
|
}
|
|
55
|
+
if (typeof r.succeededAfterGate === "number" && Number.isFinite(r.succeededAfterGate) && r.succeededAfterGate > 0) {
|
|
56
|
+
gate.succeededAfterGate = Math.floor(r.succeededAfterGate)
|
|
57
|
+
}
|
|
55
58
|
if (typeof r.correction === "string") gate.correction = r.correction
|
|
56
59
|
if (r.review === true) gate.review = true
|
|
57
60
|
if (r.remindedSessions !== null && typeof r.remindedSessions === "object" && !Array.isArray(r.remindedSessions)) {
|