opencode-dejavu 2.5.1 → 2.7.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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.7.0 — 2026-08-26
4
+
5
+ ### Added (no more manual corrections)
6
+ - **Auto-corrections.** A promoted gate now always ships with a mechanical, overridable default correction (`suggestCorrection`), chosen by command family (stale `--check` artifacts, failing tests, type errors, network, installs) or from the captured error line — so a gate never sits "NOT TEACHING" awaiting a human. `migrate()` backfills existing enforced gates.
7
+ - **Richer snippets.** For exit-code failures whose output matched no signature, dejavu keeps the last non-empty output line (`failureSnippet`) instead of a bare "exit code N", giving corrections real context.
8
+
9
+ ## 2.6.0 — 2026-08-26
10
+
11
+ ### Added (only well-grounded triggers)
12
+ - **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.
13
+
3
14
  ## 2.5.1 — 2026-08-26
4
15
 
5
16
  ### Fixed
package/README.md CHANGED
@@ -72,6 +72,8 @@ 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.
76
+ - **Auto-corrections, no manual work** — a promoted gate always ships with a mechanical, overridable default correction chosen by command family (stale `--check` artifacts, failing tests, type errors, network, installs) or from the captured error line, so a gate never sits "NOT TEACHING" awaiting a human. `migrate()` backfills existing gates. Snippets now keep the last output line (`failureSnippet`) instead of a bare "exit code N".
75
77
 
76
78
  ## Observability (debugging aids)
77
79
 
@@ -112,7 +114,7 @@ bun run typecheck # tsc --noEmit (index.ts + src/**)
112
114
  bun test/smoke.ts # behavioral smoke test, no framework needed
113
115
  ```
114
116
 
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).
117
+ 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
118
 
117
119
  ## Roadmap
118
120
 
package/index.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  bashSegmentSignatures,
6
6
  callSignature,
7
7
  detectFailure,
8
+ failureSnippet,
8
9
  isIntendedNonzero,
9
10
  isNoiseError,
10
11
  parameterizeError,
@@ -236,8 +237,6 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
236
237
  // grep/pytest/linters: exit 1 is often the INTENDED outcome, not a mistake.
237
238
  const intended = exitCode === 1 && isIntendedNonzero(rawCommand, 1)
238
239
  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
240
 
242
241
  const args = scrubbedArgs(((input as { args?: unknown }).args ?? {}) as Record<string, unknown>)
243
242
  let signature = callSignature(input.tool, args)
@@ -247,9 +246,9 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
247
246
  }
248
247
  if (!signature) return
249
248
 
250
- // Attribution: if a segment of a failed chain matches an already-known
251
- // pattern, record the failure under that segment's key — the chain
252
- // wrapper changes every time, the recurring part does not.
249
+ // Attribution: if a segment of the chain matches an already-known
250
+ // pattern, attribute to that segment's key — the chain wrapper changes
251
+ // every time, the recurring part does not.
253
252
  let recordSignature = signature
254
253
  if (input.tool === "bash" && typeof args.command === "string") {
255
254
  for (const segSig of bashSegmentSignatures(args.command)) {
@@ -259,10 +258,19 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
259
258
  }
260
259
  }
261
260
  }
262
-
263
261
  const key = patternKey(recordSignature)
264
262
  const session = typeof input.sessionID === "string" ? input.sessionID : "unknown"
265
263
 
264
+ // A SUCCESS matching an enforced gate is evidence the command got fixed —
265
+ // track the streak so healed commands stop reminding (only bash gates
266
+ // enforce, so only bash successes can heal).
267
+ if (!failed) {
268
+ if (isBash) await stores.recordSuccess({ key, signature: recordSignature, tool: input.tool })
269
+ return
270
+ }
271
+
272
+ const snippet = scrubSecrets(detection.matched ? detection.snippet : failureSnippet(text, exitCode))
273
+
266
274
  const result = await stores.recordFailure({
267
275
  key,
268
276
  signature: recordSignature,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-dejavu",
3
- "version": "2.5.1",
3
+ "version": "2.7.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/patterns.ts CHANGED
@@ -460,6 +460,22 @@ export function detectFailure(outputText: string): FailureDetection {
460
460
  return { matched: false, snippet: "" }
461
461
  }
462
462
 
463
+ /**
464
+ * For exit-code failures whose output matched no signature, a bare
465
+ * "exit code N" gives a human/agent nothing to write a correction from.
466
+ * Bash output is command output (safe to surface), so keep the last non-empty
467
+ * line — compilers/test runners print their summary at the end.
468
+ */
469
+ export function failureSnippet(outputText: string, exitCode: number | null): string {
470
+ const lines = outputText
471
+ .split("\n")
472
+ .map((l) => l.trim())
473
+ .filter((l) => l !== "")
474
+ const tail = lines[lines.length - 1]
475
+ if (tail !== undefined && tail !== "") return tail.slice(0, 200)
476
+ return `exit code ${exitCode}`
477
+ }
478
+
463
479
  // --- Noise filtering ----------------------------------------------------------
464
480
 
465
481
  /**
@@ -478,3 +494,33 @@ const NOISE_ERRORS: RegExp[] = [
478
494
  export function isNoiseError(errorText: string): boolean {
479
495
  return NOISE_ERRORS.some((rule) => rule.test(errorText))
480
496
  }
497
+
498
+ // --- Default corrections ------------------------------------------------------
499
+
500
+ /**
501
+ * Mechanical, overridable default correction chosen by command family, so a
502
+ * promoted gate always ships with SOME teaching text instead of sitting
503
+ * "NOT TEACHING" until a human writes one. Rules, not an LLM — the hot path
504
+ * stays mechanical; a human/agent may refine the text later.
505
+ */
506
+ export function suggestCorrection(signature: string, snippet: string): string {
507
+ if (/(^|\s)(--check|--dry-run|verify|check)\b/i.test(signature) && /dart run|generate|sync/i.test(signature)) {
508
+ return "Generated artifacts are stale — run the same script WITHOUT the check flag to regenerate, then commit the result."
509
+ }
510
+ if (/\b(pytest|jest|vitest|mocha|cucumbertest|flutter test|npm test|gradlew\b[^\n]*test|dart test)\b/i.test(signature)) {
511
+ return "A test is failing — read the failing assertion in the output and fix the code or the expectation; do not re-run the suite blindly."
512
+ }
513
+ if (/\b(tsc|typecheck|type-check)\b/i.test(signature)) {
514
+ return "Type errors — run the compiler, read the reported file:line diagnostics, and fix the types before retrying."
515
+ }
516
+ if (/\b(curl|wget)\b/i.test(signature)) {
517
+ return "Network/endpoint failure — verify the URL is reachable, check rate limits and timeouts; retry with backoff, not immediately."
518
+ }
519
+ if (/\b(npm|yarn|pnpm|bun)\s+(install|ci)\b/i.test(signature)) {
520
+ return "Dependency install failed — inspect the resolver error; try the lockfile/legacy-peer-deps route the repo documents."
521
+ }
522
+ if (snippet !== "" && snippet !== "exit code 1") {
523
+ return `Last error: "${snippet}" — address that specific error before retrying this exact call.`
524
+ }
525
+ return "This exact call keeps failing — inspect the last output line and change approach before retrying."
526
+ }
package/src/store.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { appendFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"
2
2
  import { dirname, join } from "node:path"
3
- import { canBlock, canRemind, fuzzySimilar, FUZZY_MAX_LEN, isRepoLocal, scrubSecrets } from "./patterns"
3
+ import { canBlock, canRemind, fuzzySimilar, FUZZY_MAX_LEN, isRepoLocal, scrubSecrets, suggestCorrection } 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.5.1"
7
+ export const PLUGIN_VERSION = "2.7.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
@@ -720,6 +727,12 @@ export class Stores {
720
727
  changed = true
721
728
  }
722
729
  }
730
+ // Backfill: an enforced gate with no correction gets a mechanical
731
+ // default so it teaches immediately instead of sitting "NOT TEACHING".
732
+ if (gate.status !== "watching" && gate.correction === undefined) {
733
+ gate.correction = suggestCorrection(gate.signature, gate.snippet)
734
+ changed = true
735
+ }
723
736
  }
724
737
  if (changed) await store.save()
725
738
  })
@@ -933,6 +946,8 @@ export class Stores {
933
946
  // Only an exact-key failure updates the evidence: a crafted near-duplicate
934
947
  // must not overwrite a legitimate gate's snippet via fuzzy consolidation.
935
948
  if (!fuzzyConsolidated) gate.snippet = scrubSecrets(input.snippet)
949
+ // A failure breaks any heal streak — the command is still broken.
950
+ gate.succeededAfterGate = 0
936
951
 
937
952
  let promoted = false
938
953
  const threshold = PROBE_TOOLS.has(input.tool) ? PROMOTE_COUNT_PROBE : PROMOTE_COUNT
@@ -946,6 +961,11 @@ export class Stores {
946
961
  gate.status = "reminding"
947
962
  promoted = true
948
963
  }
964
+ // A promoted gate always ships with SOME teaching text (mechanical
965
+ // default, overridable) so it never sits "NOT TEACHING" awaiting a human.
966
+ if (promoted && gate.correction === undefined) {
967
+ gate.correction = suggestCorrection(gate.signature, gate.snippet)
968
+ }
949
969
  }
950
970
 
951
971
  await store.save()
@@ -1006,4 +1026,34 @@ export class Stores {
1006
1026
  return { gate: moved, store, promoted, wentGlobal }
1007
1027
  })
1008
1028
  }
1029
+
1030
+ /**
1031
+ * A SUCCESS matching an enforced gate is evidence the underlying command got
1032
+ * fixed. Track a streak; once it reaches HEAL_SUCCESSES the gate retires to
1033
+ * watching so it stops reminding on a now-healthy command (the
1034
+ * `ruff check .` false-positive case). Only enforced (blocking/reminding)
1035
+ * gates heal; a failure resets the streak in recordFailure.
1036
+ */
1037
+ async recordSuccess(input: { key: string; signature: string; tool: string }): Promise<void> {
1038
+ const match = await this.findGate(input.key, input.signature)
1039
+ if (match === null || match.gate.status === "watching") return
1040
+ const store = match.store
1041
+ const gateKey = match.gate.key
1042
+ await store.runLocked(async () => {
1043
+ const fresh = (await store.load(true)).find((g) => g.key === gateKey)
1044
+ if (fresh === undefined || fresh.status === "watching") return
1045
+ fresh.succeededAfterGate = (fresh.succeededAfterGate ?? 0) + 1
1046
+ const healed = fresh.succeededAfterGate >= HEAL_SUCCESSES
1047
+ if (healed) fresh.status = "watching"
1048
+ await store.save()
1049
+ if (healed) {
1050
+ await store.log({
1051
+ type: "healed",
1052
+ key: fresh.key,
1053
+ tool: fresh.tool,
1054
+ snippet: `succeeded ${fresh.succeededAfterGate}x in a row after the gate — retired to watching`,
1055
+ })
1056
+ }
1057
+ })
1058
+ }
1009
1059
  }
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)) {