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/src/store.ts ADDED
@@ -0,0 +1,449 @@
1
+ import { appendFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"
2
+ import { dirname, join } from "node:path"
3
+ import { canBlock, fuzzySimilar, scrubSecrets } from "./patterns"
4
+
5
+ /** Bumped on behavior changes; stamped into init log events so stale sessions are visible. */
6
+ export const PLUGIN_VERSION = "2.1.0"
7
+
8
+ export interface Gate {
9
+ /** sha1 signature prefix — the pattern identity */
10
+ key: string
11
+ /** normalized call signature, e.g. "bash:npm install --legacy-peer-deps" */
12
+ signature: string
13
+ tool: string
14
+ /** watching = collecting evidence; blocking = gate is enforced */
15
+ status: "watching" | "blocking"
16
+ count: number
17
+ /** distinct session IDs where the failure was seen */
18
+ sessions: string[]
19
+ /** distinct project directories where the failure was seen */
20
+ projects: string[]
21
+ firstSeen: string
22
+ lastSeen: string
23
+ /** last observed error line, as evidence (secret-scrubbed) */
24
+ snippet: string
25
+ /** optional human/agent-written guidance shown in reminder/block messages */
26
+ correction?: string
27
+ remindedCount: number
28
+ blockedCount: number
29
+ recurredAfterReminder: number
30
+ /** the core health metric: failures of this pattern AFTER it became a gate */
31
+ recurredAfterGate: number
32
+ /** flagged for manual review when the gate fires often but errors stopped */
33
+ review?: boolean
34
+ }
35
+
36
+ interface GatesFile {
37
+ version: 1
38
+ gates: Gate[]
39
+ }
40
+
41
+ export type LogEventType =
42
+ | "detected"
43
+ | "promoted"
44
+ | "reminded"
45
+ | "retry-allowed"
46
+ | "blocked"
47
+ | "override"
48
+ | "expired"
49
+ | "recurred-after-gate"
50
+ | "init"
51
+
52
+ export interface LogEvent {
53
+ type: LogEventType
54
+ key: string
55
+ tool?: string
56
+ session?: string
57
+ project?: string
58
+ snippet?: string
59
+ /** which detection channel fired: metadata exit, bash text scan, or event stream */
60
+ channel?: "exit" | "text" | "event"
61
+ /** raw tool exit code when available */
62
+ exit?: number
63
+ /** how the gate matched the call */
64
+ via?: "exact" | "fuzzy" | "segment"
65
+ /** plugin version (init events) */
66
+ version?: string
67
+ }
68
+
69
+ const MAX_SESSIONS = 50
70
+ const MAX_PROJECTS = 20
71
+ const LOG_ROTATE_BYTES = 512 * 1024
72
+ const LOG_ROTATE_KEEP_LINES = 1000
73
+ const DAY_MS = 24 * 60 * 60 * 1000
74
+
75
+ /** failures required before a pattern becomes an enforced gate */
76
+ export const PROMOTE_COUNT = 3
77
+ /** file-probe tools fail routinely during normal probing — higher bar, never block */
78
+ export const PROMOTE_COUNT_PROBE = 5
79
+ export const PROBE_TOOLS = new Set(["read", "glob", "grep", "write", "edit"])
80
+ /** distinct sessions required — same-session loops never promote */
81
+ export const PROMOTE_SESSIONS = 2
82
+
83
+ // --- Windows-safe fs helpers -------------------------------------------------
84
+
85
+ /** NT long-path prefix so deeply nested project dirs do not hit MAX_PATH. */
86
+ function ntPath(p: string): string {
87
+ if (process.platform !== "win32") return p
88
+ if (p.startsWith("\\\\?\\")) return p
89
+ return `\\\\?\\${p}`
90
+ }
91
+
92
+ const RETRYABLE = new Set(["EPERM", "EACCES", "EBUSY"])
93
+
94
+ /** tmp + rename with exponential-backoff retry (Windows AV/indexer locks). */
95
+ async function atomicWrite(path: string, content: string): Promise<void> {
96
+ const tmp = `${path}.${process.pid}.tmp`
97
+ for (let attempt = 0; ; attempt++) {
98
+ try {
99
+ await writeFile(ntPath(tmp), content, "utf8")
100
+ await rename(ntPath(tmp), ntPath(path))
101
+ return
102
+ } catch (error) {
103
+ const code = (error as { code?: string }).code ?? ""
104
+ if (RETRYABLE.has(code) && attempt < 5) {
105
+ await new Promise((resolve) => setTimeout(resolve, 50 * 2 ** attempt))
106
+ continue
107
+ }
108
+ try {
109
+ await unlink(ntPath(tmp))
110
+ } catch {
111
+ // orphan tmp is harmless
112
+ }
113
+ throw error
114
+ }
115
+ }
116
+ }
117
+
118
+ const LOCK_STALE_MS = 5000
119
+ const LOCK_WAIT_MS = 3000
120
+
121
+ /**
122
+ * Exclusive lockfile ("wx" create) with stale-lock stealing and graceful
123
+ * degradation: if the lock cannot be acquired within LOCK_WAIT_MS the
124
+ * critical section runs unlocked rather than hanging the tool pipeline.
125
+ */
126
+ async function withLock<T>(lockTarget: string, fn: () => Promise<T>): Promise<T> {
127
+ const lock = `${lockTarget}.lock`
128
+ await mkdir(ntPath(dirname(lock)), { recursive: true })
129
+ const started = Date.now()
130
+ for (;;) {
131
+ try {
132
+ await writeFile(ntPath(lock), String(process.pid), { flag: "wx" })
133
+ break
134
+ } catch (error) {
135
+ const code = (error as { code?: string }).code ?? ""
136
+ if (code !== "EEXIST") throw error
137
+ try {
138
+ const info = await stat(ntPath(lock))
139
+ if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
140
+ await unlink(ntPath(lock)).catch(() => {})
141
+ continue
142
+ }
143
+ } catch {
144
+ continue // lock vanished between attempts
145
+ }
146
+ if (Date.now() - started > LOCK_WAIT_MS) break
147
+ await new Promise((resolve) => setTimeout(resolve, 50))
148
+ }
149
+ }
150
+ try {
151
+ return await fn()
152
+ } finally {
153
+ try {
154
+ await unlink(ntPath(lock))
155
+ } catch {
156
+ // best effort
157
+ }
158
+ }
159
+ }
160
+
161
+ export class GateStore {
162
+ private gates: Gate[] | null = null
163
+ private mtimeMs = 0
164
+
165
+ constructor(public readonly dir: string) {}
166
+
167
+ private get gatesPath(): string {
168
+ return join(this.dir, "gates.json")
169
+ }
170
+
171
+ private get logPath(): string {
172
+ return join(this.dir, "log.jsonl")
173
+ }
174
+
175
+ /** Run a load→mutate→save section under the store's exclusive lock. */
176
+ async runLocked<T>(fn: () => Promise<T>): Promise<T> {
177
+ return withLock(this.gatesPath, fn)
178
+ }
179
+
180
+ /** force=true bypasses the mtime cache (always used inside locks). */
181
+ async load(force = false): Promise<Gate[]> {
182
+ try {
183
+ const info = await stat(ntPath(this.gatesPath))
184
+ if (!force && this.gates !== null && info.mtimeMs === this.mtimeMs) {
185
+ return this.gates
186
+ }
187
+ const raw = await readFile(ntPath(this.gatesPath), "utf8")
188
+ const parsed = JSON.parse(raw) as Partial<GatesFile>
189
+ this.gates = Array.isArray(parsed.gates) ? parsed.gates : []
190
+ this.mtimeMs = info.mtimeMs
191
+ return this.gates
192
+ } catch {
193
+ // missing or unreadable gates.json — treat as an empty store
194
+ if (this.gates === null) this.gates = []
195
+ return this.gates
196
+ }
197
+ }
198
+
199
+ async save(): Promise<void> {
200
+ if (this.gates === null) return
201
+ await mkdir(ntPath(this.dir), { recursive: true })
202
+ const payload: GatesFile = { version: 1, gates: this.gates }
203
+ await atomicWrite(this.gatesPath, `${JSON.stringify(payload, null, 2)}\n`)
204
+ try {
205
+ this.mtimeMs = (await stat(ntPath(this.gatesPath))).mtimeMs
206
+ } catch {
207
+ // mtime refresh is best-effort
208
+ }
209
+ }
210
+
211
+ async log(event: LogEvent): Promise<void> {
212
+ await mkdir(ntPath(this.dir), { recursive: true })
213
+ const line = `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`
214
+ await appendFile(ntPath(this.logPath), line, "utf8")
215
+ }
216
+
217
+ /** Caller must hold the lock. */
218
+ async expire(ttlDays: number): Promise<Gate[]> {
219
+ const gates = await this.load(true)
220
+ const cutoff = Date.now() - ttlDays * DAY_MS
221
+ const expired = gates.filter((g) => Date.parse(g.lastSeen) < cutoff)
222
+ if (expired.length === 0) return []
223
+ this.gates = gates.filter((g) => Date.parse(g.lastSeen) >= cutoff)
224
+ await this.save()
225
+ return expired
226
+ }
227
+
228
+ async rotateLog(): Promise<void> {
229
+ try {
230
+ const info = await stat(ntPath(this.logPath))
231
+ if (info.size < LOG_ROTATE_BYTES) return
232
+ const raw = await readFile(ntPath(this.logPath), "utf8")
233
+ const lines = raw.split("\n").filter((l) => l.trim() !== "")
234
+ const kept = lines.slice(-LOG_ROTATE_KEEP_LINES)
235
+ await writeFile(ntPath(this.logPath), `${kept.join("\n")}\n`, "utf8")
236
+ } catch {
237
+ // missing or unreadable log is fine
238
+ }
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Two-scope gate management: project-local gates live in the repo
244
+ * (`.opencode/dejavu/`), cross-project agent habits are promoted to the
245
+ * global store (`~/.config/opencode/dejavu/`).
246
+ */
247
+ export class Stores {
248
+ constructor(
249
+ public readonly globalStore: GateStore,
250
+ public readonly projectStore: GateStore | null,
251
+ ) {}
252
+
253
+ private scopes(): GateStore[] {
254
+ return this.projectStore ? [this.projectStore, this.globalStore] : [this.globalStore]
255
+ }
256
+
257
+ /** True if a pattern with this key exists in any scope (chain attribution). */
258
+ async hasKey(key: string): Promise<boolean> {
259
+ for (const store of this.scopes()) {
260
+ if ((await store.load()).some((g) => g.key === key)) return true
261
+ }
262
+ return false
263
+ }
264
+
265
+ /** Exact key match first (any status), then fuzzy near-duplicate over blocking gates. */
266
+ async findGate(
267
+ key: string,
268
+ signature: string,
269
+ ): Promise<{ gate: Gate; store: GateStore; via: "exact" | "fuzzy" } | null> {
270
+ for (const store of this.scopes()) {
271
+ const exact = (await store.load()).find((g) => g.key === key)
272
+ if (exact) return { gate: exact, store, via: "exact" }
273
+ }
274
+ let best: { gate: Gate; store: GateStore; score: number } | null = null
275
+ for (const store of this.scopes()) {
276
+ for (const gate of await store.load()) {
277
+ if (gate.status !== "blocking") continue
278
+ if (!fuzzySimilar(signature, gate.signature)) continue
279
+ const score = Math.abs(signature.length - gate.signature.length)
280
+ if (best === null || score < best.score) best = { gate, store, score }
281
+ }
282
+ }
283
+ return best === null ? null : { gate: best.gate, store: best.store, via: "fuzzy" }
284
+ }
285
+
286
+ /** All currently enforced gates, project scope first, highest-count first. */
287
+ async blockingGates(): Promise<Gate[]> {
288
+ const result: Gate[] = []
289
+ for (const store of this.scopes()) {
290
+ for (const gate of await store.load()) {
291
+ if (gate.status === "blocking") result.push(gate)
292
+ }
293
+ }
294
+ return result.sort((a, b) => b.count - a.count)
295
+ }
296
+
297
+ async logAll(event: LogEvent): Promise<void> {
298
+ for (const store of this.scopes()) {
299
+ await store.log(event)
300
+ }
301
+ }
302
+
303
+ async expireAll(ttlDays: number): Promise<void> {
304
+ for (const store of this.scopes()) {
305
+ await store.runLocked(async () => {
306
+ const expired = await store.expire(ttlDays)
307
+ for (const gate of expired) {
308
+ await store.log({ type: "expired", key: gate.key, tool: gate.tool })
309
+ }
310
+ })
311
+ }
312
+ }
313
+
314
+ async rotateLogs(): Promise<void> {
315
+ for (const store of this.scopes()) {
316
+ await store.rotateLog()
317
+ }
318
+ }
319
+
320
+ /**
321
+ * One-time (idempotent) schema/behavior migration:
322
+ * - probe-tool gates never block (they were learned under the old policy)
323
+ * - signatures and snippets are secret-scrubbed (cleans historical leaks)
324
+ */
325
+ async migrate(): Promise<void> {
326
+ for (const store of this.scopes()) {
327
+ await store.runLocked(async () => {
328
+ const gates = await store.load(true)
329
+ let changed = false
330
+ for (const gate of gates) {
331
+ if (!canBlock(gate.tool, gate.signature) && gate.status === "blocking") {
332
+ gate.status = "watching"
333
+ changed = true
334
+ }
335
+ const signature = scrubSecrets(gate.signature)
336
+ const snippet = scrubSecrets(gate.snippet)
337
+ if (signature !== gate.signature) {
338
+ gate.signature = signature
339
+ changed = true
340
+ }
341
+ if (snippet !== gate.snippet) {
342
+ gate.snippet = snippet
343
+ changed = true
344
+ }
345
+ if (gate.correction !== undefined) {
346
+ const correction = scrubSecrets(gate.correction)
347
+ if (correction !== gate.correction) {
348
+ gate.correction = correction
349
+ changed = true
350
+ }
351
+ }
352
+ }
353
+ if (changed) await store.save()
354
+ })
355
+ }
356
+ }
357
+
358
+ async recordFailure(input: {
359
+ key: string
360
+ signature: string
361
+ tool: string
362
+ sessionID: string
363
+ projectDir: string
364
+ snippet: string
365
+ globalProjects: number
366
+ }): Promise<{ gate: Gate; store: GateStore; promoted: boolean; wentGlobal: boolean }> {
367
+ const now = new Date().toISOString()
368
+ // Route to the store that already knows this key (cheap unlocked peek).
369
+ let store = this.projectStore ?? this.globalStore
370
+ if (!(await store.load()).some((g) => g.key === input.key)) {
371
+ if ((await this.globalStore.load()).some((g) => g.key === input.key)) {
372
+ store = this.globalStore
373
+ }
374
+ }
375
+
376
+ return store.runLocked(async () => {
377
+ const gates = await store.load(true)
378
+ let gate = gates.find((g) => g.key === input.key)
379
+ // Consolidation: same tool + near-duplicate signature merges into the
380
+ // existing pattern instead of fragmenting ("gradlew :x:compiletestjava").
381
+ if (!gate) {
382
+ gate = gates.find((g) => g.tool === input.tool && fuzzySimilar(input.signature, g.signature))
383
+ }
384
+ if (!gate) {
385
+ gate = {
386
+ key: input.key,
387
+ signature: scrubSecrets(input.signature),
388
+ tool: input.tool,
389
+ status: "watching",
390
+ count: 0,
391
+ sessions: [],
392
+ projects: [],
393
+ firstSeen: now,
394
+ lastSeen: now,
395
+ snippet: scrubSecrets(input.snippet),
396
+ remindedCount: 0,
397
+ blockedCount: 0,
398
+ recurredAfterReminder: 0,
399
+ recurredAfterGate: 0,
400
+ }
401
+ gates.push(gate)
402
+ }
403
+
404
+ gate.count += 1
405
+ if (!gate.sessions.includes(input.sessionID)) gate.sessions.push(input.sessionID)
406
+ if (gate.sessions.length > MAX_SESSIONS) gate.sessions = gate.sessions.slice(-MAX_SESSIONS)
407
+ if (input.projectDir !== "" && !gate.projects.includes(input.projectDir)) {
408
+ gate.projects.push(input.projectDir)
409
+ if (gate.projects.length > MAX_PROJECTS) gate.projects = gate.projects.slice(-MAX_PROJECTS)
410
+ }
411
+ gate.lastSeen = now
412
+ gate.snippet = scrubSecrets(input.snippet)
413
+
414
+ let promoted = false
415
+ const threshold = PROBE_TOOLS.has(input.tool) ? PROMOTE_COUNT_PROBE : PROMOTE_COUNT
416
+ // Policy: only bash non-diagnostic commands may ever become gates.
417
+ if (
418
+ gate.status === "watching" &&
419
+ canBlock(gate.tool, gate.signature) &&
420
+ gate.count >= threshold &&
421
+ gate.sessions.length >= PROMOTE_SESSIONS
422
+ ) {
423
+ gate.status = "blocking"
424
+ promoted = true
425
+ }
426
+
427
+ await store.save()
428
+
429
+ // Scope escalation: a pattern seen in enough distinct project directories
430
+ // is an agent-level habit, not a repo quirk — move it to the global store.
431
+ // Lock order is always project -> global, so no deadlock.
432
+ let wentGlobal = false
433
+ const moved = gate
434
+ if (store !== this.globalStore && this.projectStore && gate.projects.length >= input.globalProjects) {
435
+ const idx = gates.findIndex((g) => g.key === moved.key)
436
+ if (idx >= 0) gates.splice(idx, 1)
437
+ await store.save()
438
+ await this.globalStore.runLocked(async () => {
439
+ const globalGates = await this.globalStore.load(true)
440
+ if (!globalGates.some((g) => g.key === moved.key)) globalGates.push(moved)
441
+ await this.globalStore.save()
442
+ })
443
+ wentGlobal = true
444
+ }
445
+
446
+ return { gate: moved, store, promoted, wentGlobal }
447
+ })
448
+ }
449
+ }