@jmtrin/opencode-kevin 0.2.0 → 0.4.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.
Files changed (58) hide show
  1. package/README.md +108 -35
  2. package/dist/migrations/004_v03_knowledge.sql +138 -0
  3. package/dist/migrations/005_v04_signal.sql +57 -0
  4. package/dist/plugin/CausalChain.d.ts +22 -0
  5. package/dist/plugin/CausalChain.js +179 -0
  6. package/dist/plugin/CausalChain.js.map +1 -0
  7. package/dist/plugin/ContextInjector.d.ts +89 -1
  8. package/dist/plugin/ContextInjector.js +276 -71
  9. package/dist/plugin/ContextInjector.js.map +1 -1
  10. package/dist/plugin/InjectionLedger.d.ts +85 -0
  11. package/dist/plugin/InjectionLedger.js +189 -0
  12. package/dist/plugin/InjectionLedger.js.map +1 -0
  13. package/dist/plugin/LessonFixer.d.ts +44 -0
  14. package/dist/plugin/LessonFixer.js +46 -0
  15. package/dist/plugin/LessonFixer.js.map +1 -0
  16. package/dist/plugin/MemoryService.d.ts +123 -2
  17. package/dist/plugin/MemoryService.js +439 -31
  18. package/dist/plugin/MemoryService.js.map +1 -1
  19. package/dist/plugin/Migrate.js +22 -0
  20. package/dist/plugin/Migrate.js.map +1 -1
  21. package/dist/plugin/QualityGate.d.ts +71 -0
  22. package/dist/plugin/QualityGate.js +78 -0
  23. package/dist/plugin/QualityGate.js.map +1 -0
  24. package/dist/plugin/Reflector.d.ts +33 -0
  25. package/dist/plugin/Reflector.js +126 -32
  26. package/dist/plugin/Reflector.js.map +1 -1
  27. package/dist/plugin/Retrospective.js +10 -1
  28. package/dist/plugin/Retrospective.js.map +1 -1
  29. package/dist/plugin/ToolCallObserver.d.ts +1 -0
  30. package/dist/plugin/ToolCallObserver.js +16 -9
  31. package/dist/plugin/ToolCallObserver.js.map +1 -1
  32. package/dist/plugin/confidence.d.ts +6 -0
  33. package/dist/plugin/confidence.js +23 -0
  34. package/dist/plugin/confidence.js.map +1 -0
  35. package/dist/plugin/index.d.ts +5 -0
  36. package/dist/plugin/index.js +336 -44
  37. package/dist/plugin/index.js.map +1 -1
  38. package/dist/plugin/kevin_why.d.ts +23 -0
  39. package/dist/plugin/kevin_why.js +108 -0
  40. package/dist/plugin/kevin_why.js.map +1 -0
  41. package/dist/plugin/memory-format.d.ts +12 -0
  42. package/dist/plugin/memory-format.js +45 -5
  43. package/dist/plugin/memory-format.js.map +1 -1
  44. package/dist/plugin/metrics.d.ts +7 -1
  45. package/dist/plugin/metrics.js +19 -0
  46. package/dist/plugin/metrics.js.map +1 -1
  47. package/dist/plugin/okf-export.d.ts +3 -0
  48. package/dist/plugin/okf-export.js +127 -0
  49. package/dist/plugin/okf-export.js.map +1 -0
  50. package/dist/plugin/okf-import.d.ts +76 -0
  51. package/dist/plugin/okf-import.js +272 -0
  52. package/dist/plugin/okf-import.js.map +1 -0
  53. package/dist/plugin/query-tokenizer.d.ts +13 -0
  54. package/dist/plugin/query-tokenizer.js +86 -0
  55. package/dist/plugin/query-tokenizer.js.map +1 -0
  56. package/migrations/004_v03_knowledge.sql +138 -0
  57. package/migrations/005_v04_signal.sql +57 -0
  58. package/package.json +1 -1
@@ -1,19 +1,34 @@
1
+ import { deterministicFixLine } from "./LessonFixer.js";
2
+ import { computeConfidence } from "./confidence.js";
1
3
  import { fingerprint as computeFingerprint } from "./fingerprint.js";
4
+ import { toMatchClause, tokenizeQuery } from "./query-tokenizer.js";
2
5
  import { uuidv7 } from "./uuid.js";
3
6
  const MAX_SNIPPET_CHARS = 200;
4
- function toSlim(mem) {
5
- const rawScore = mem.metadata?.score;
6
- return {
7
+ function toSlim(mem, evidence = false) {
8
+ const base = {
7
9
  id: mem.id,
8
10
  type: mem.type,
9
11
  scope: mem.scope,
10
- score: typeof rawScore === "number" ? rawScore : mem.relevanceScore,
12
+ score: typeof mem.metadata?.score ===
13
+ "number"
14
+ ? mem.metadata.score
15
+ : mem.relevanceScore,
11
16
  snippet: mem.content.slice(0, MAX_SNIPPET_CHARS),
12
17
  };
18
+ if (!evidence)
19
+ return base;
20
+ return {
21
+ ...base,
22
+ confidence: mem.confidence ?? null,
23
+ evidence_count: mem.evidenceCount ?? null,
24
+ last_verified_at: mem.lastVerifiedAt ?? null,
25
+ };
13
26
  }
14
27
  const TYPE_PRIORITY = {
15
28
  error: 0,
16
29
  pattern: 1,
30
+ rule: 1,
31
+ solution: 1,
17
32
  decision: 2,
18
33
  context: 3,
19
34
  };
@@ -26,6 +41,7 @@ const RELEVANCE_MAX = 1.0;
26
41
  // notes, all else equal. No embeddings, no RRF.
27
42
  const ORIGIN_BOOST_REFLECTOR = 2;
28
43
  const ORIGIN_BOOST_PATTERN = 1.5;
44
+ const ORIGIN_BOOST_CASUAL = 2;
29
45
  const ORIGIN_BOOST_AGENT = 1;
30
46
  const RECENCY_DECAY_PER_DAY = 0.95; // newer = closer to 1 (less penalty)
31
47
  function sqliteUtcToMs(createdAt) {
@@ -45,6 +61,11 @@ function sqliteUtcNowPlusHours(hours) {
45
61
  const pad = (n) => String(n).padStart(2, "0");
46
62
  return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
47
63
  }
64
+ /** Shared column list for row reads (must stay in sync with MemoryRow). */
65
+ const MEMORY_ROW_SELECT = `id, type, content, scope, relevance_score, source_tool, source_session,
66
+ metadata, created_at, updated_at, expires_at,
67
+ project_id, fingerprint, origin,
68
+ evidence_count, recurrence_count, last_verified_at, status, fix_args`;
48
69
  function mapRow(row, score) {
49
70
  const mem = {
50
71
  id: row.id,
@@ -63,6 +84,14 @@ function mapRow(row, score) {
63
84
  projectId: row.project_id ?? null,
64
85
  fingerprint: row.fingerprint ?? null,
65
86
  origin: row.origin ?? null,
87
+ confidence: typeof row.evidence_count === "number"
88
+ ? computeConfidence(row.evidence_count ?? 0, row.recurrence_count ?? 0)
89
+ : null,
90
+ evidenceCount: row.evidence_count ?? null,
91
+ lastVerifiedAt: row.last_verified_at ?? null,
92
+ status: row.status ?? "active",
93
+ fixArgs: row.fix_args ?? null,
94
+ recurrenceCount: row.recurrence_count ?? null,
66
95
  };
67
96
  if (score !== undefined) {
68
97
  if (!mem.metadata)
@@ -97,12 +126,30 @@ export class MemoryService {
97
126
  // so that Metrics can be added without changing the parameter order callers
98
127
  // have been using since v0.1.0.
99
128
  store;
129
+ // v0.4.0 (BUG-008) — cached column probe for pre-005 DBs (which lack
130
+ // `recurrence_count`); save() must not reference the column there.
131
+ hasRecurrenceColumn() {
132
+ if (this._hasRecurrenceColumn === undefined) {
133
+ try {
134
+ this.store
135
+ .prepare("SELECT recurrence_count FROM memories LIMIT 1")
136
+ .get();
137
+ this._hasRecurrenceColumn = true;
138
+ }
139
+ catch {
140
+ this._hasRecurrenceColumn = false;
141
+ }
142
+ }
143
+ return this._hasRecurrenceColumn;
144
+ }
145
+ _hasRecurrenceColumn;
100
146
  save(input) {
101
147
  const scope = input.scope ?? "project";
102
148
  const relevanceScore = input.relevanceScore ?? 0.5;
103
149
  const metadata = input.metadata ? JSON.stringify(input.metadata) : null;
104
150
  const origin = input.origin ?? "agent";
105
151
  const projectId = input.projectId ?? null;
152
+ const status = input.status ?? "active";
106
153
  // Fingerprint is used for dedup (error memories via migration 003
107
154
  // partial unique index) AND for pattern idempotency (K2-021 — pattern
108
155
  // memories store an explicit fingerprint so PatternMiner's SELECT-before-
@@ -121,14 +168,46 @@ export class MemoryService {
121
168
  if (scope === "session" && !input.expiresAt) {
122
169
  expiresAt = sqliteUtcNowPlusHours(SESSION_DEFAULT_TTL_HOURS);
123
170
  }
124
- const id = uuidv7();
125
- try {
171
+ const id = input.id ?? uuidv7();
172
+ // v0.3.0 (K3-014) — supersede model: when saving a decision/rule with
173
+ // the same fingerprint as an existing active row, mark the old as
174
+ // superseded and insert the fresh version as active.
175
+ // v0.4.0 (K4-011) — `memories_superseded` is only counted here,
176
+ // where a row is truly replaced; penalization of recurring
177
+ // reflectors no longer increments it.
178
+ const supersedableTypes = ["decision", "rule"];
179
+ if (fp !== null && supersedableTypes.includes(input.type)) {
126
180
  this.store
127
- .prepare(`INSERT INTO memories
181
+ .prepare(`UPDATE memories
182
+ SET status = 'superseded', updated_at = datetime('now')
183
+ WHERE fingerprint = ?
184
+ AND type = ?
185
+ AND status = 'active'
186
+ AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))`)
187
+ .run(fp, input.type, projectId, projectId);
188
+ const after = this.store.prepare("SELECT changes() AS n").get();
189
+ if (after.n > 0) {
190
+ this.metrics?.incr("memories_superseded", 1);
191
+ }
192
+ }
193
+ try {
194
+ // v0.4.0 (BUG-008) — recurrence_count is only persisted when the
195
+ // column exists (migration 005); pre-005 DBs get the legacy shape.
196
+ const withRecurrence = this.hasRecurrenceColumn();
197
+ const insert = withRecurrence
198
+ ? `INSERT INTO memories
128
199
  (id, type, content, scope, relevance_score, source_tool, source_session,
129
- metadata, expires_at, project_id, fingerprint, origin)
130
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
131
- .run(id, input.type, input.content, scope, relevanceScore, input.sourceTool ?? null, input.sourceSession ?? null, metadata, expiresAt, projectId, fp, origin);
200
+ metadata, expires_at, project_id, fingerprint, origin,
201
+ evidence_count, last_verified_at, status, recurrence_count)
202
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
203
+ : `INSERT INTO memories
204
+ (id, type, content, scope, relevance_score, source_tool, source_session,
205
+ metadata, expires_at, project_id, fingerprint, origin,
206
+ evidence_count, last_verified_at, status)
207
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
208
+ this.store
209
+ .prepare(insert)
210
+ .run(id, input.type, input.content, scope, relevanceScore, input.sourceTool ?? null, input.sourceSession ?? null, metadata, expiresAt, projectId, fp, origin, input.evidenceCount ?? 0, input.lastVerifiedAt ?? null, status, ...(withRecurrence ? [input.recurrenceCount ?? 0] : []));
132
211
  return id;
133
212
  }
134
213
  catch (err) {
@@ -160,13 +239,26 @@ export class MemoryService {
160
239
  }
161
240
  getById(id) {
162
241
  const row = this.store
163
- .prepare(`SELECT id, type, content, scope, relevance_score, source_tool, source_session,
164
- metadata, created_at, updated_at, expires_at,
165
- project_id, fingerprint, origin
242
+ .prepare(`SELECT ${MEMORY_ROW_SELECT}
166
243
  FROM memories WHERE id = ?`)
167
244
  .get(id);
168
245
  return row ? mapRow(row) : null;
169
246
  }
247
+ /**
248
+ * v0.4.0 (K4-016) — most recent ACTIVE memory for a fingerprint,
249
+ * optionally filtered by type. Feeds the HITL suggestion lookup
250
+ * (most-recurred fingerprint → its pattern memory).
251
+ */
252
+ getByFingerprint(fingerprint, type) {
253
+ const row = this.store
254
+ .prepare(`SELECT ${MEMORY_ROW_SELECT}
255
+ FROM memories
256
+ WHERE fingerprint = ? AND status = 'active'
257
+ ${type ? "AND type = ?" : ""}
258
+ ORDER BY created_at DESC LIMIT 1`)
259
+ .get(...(type ? [fingerprint, type] : [fingerprint]));
260
+ return row ? mapRow(row) : null;
261
+ }
170
262
  update(id, fields) {
171
263
  const cols = [];
172
264
  const vals = [];
@@ -194,6 +286,25 @@ export class MemoryService {
194
286
  cols.push("expires_at = ?");
195
287
  vals.push(fields.expiresAt);
196
288
  }
289
+ if (fields.evidenceCount !== undefined) {
290
+ cols.push("evidence_count = ?");
291
+ vals.push(fields.evidenceCount);
292
+ }
293
+ if (fields.lastVerifiedAt !== undefined) {
294
+ cols.push("last_verified_at = ?");
295
+ vals.push(fields.lastVerifiedAt);
296
+ }
297
+ if (fields.status !== undefined) {
298
+ cols.push("status = ?");
299
+ vals.push(fields.status);
300
+ }
301
+ // v0.4.0 (BUG-008) — recurrence_count is writable so okf-import can
302
+ // restore negative evidence across a round-trip. Guarded by the
303
+ // caller (pre-005 DBs lack the column; update() throws).
304
+ if (fields.recurrenceCount !== undefined) {
305
+ cols.push("recurrence_count = ?");
306
+ vals.push(fields.recurrenceCount);
307
+ }
197
308
  if (cols.length === 0)
198
309
  return;
199
310
  cols.push("updated_at = datetime('now')");
@@ -217,12 +328,16 @@ export class MemoryService {
217
328
  m.source_tool, m.source_session, m.metadata,
218
329
  m.created_at, m.updated_at, m.expires_at,
219
330
  m.project_id, m.fingerprint, m.origin,
331
+ m.evidence_count, m.last_verified_at, m.status,
220
332
  bm25(memories_fts) AS score
221
333
  FROM memories_fts
222
334
  JOIN memories m ON m.rowid = memories_fts.rowid
223
335
  WHERE memories_fts MATCH ?
224
336
  AND (m.expires_at IS NULL OR m.expires_at > datetime('now'))`;
225
337
  const params = [match];
338
+ if (!input.includeSuperseded) {
339
+ sql += "\n AND m.status = 'active'";
340
+ }
226
341
  if (input.type) {
227
342
  sql += " AND m.type = ?";
228
343
  params.push(input.type);
@@ -234,18 +349,58 @@ export class MemoryService {
234
349
  sql += " ORDER BY bm25(memories_fts) LIMIT ?";
235
350
  params.push(limit);
236
351
  const rows = this.store.prepare(sql).all(...params);
352
+ // v0.3.0 (K3-019) — cross-project opt-in must also gate kevin_query
353
+ // (bug #12 fix): when cross_project_enabled is OFF, imported rows
354
+ // (project_id IS NULL AND origin='imported') are hidden.
355
+ const crossProjectOn = this.isCrossProjectEnabled();
237
356
  const memories = rows
238
357
  .map((r) => mapRow(r, r.score))
239
- .filter((m) => !isNotSearchable(m));
240
- return input.full === true ? memories : memories.map(toSlim);
358
+ .filter((m) => !isNotSearchable(m))
359
+ .filter((m) => crossProjectOn || m.projectId !== null || m.origin !== "imported");
360
+ return input.full === true
361
+ ? memories
362
+ : memories.map((m) => toSlim(m, input.evidence === true));
363
+ }
364
+ isCrossProjectEnabled() {
365
+ try {
366
+ const row = this.store
367
+ .prepare("SELECT value FROM kevin_settings WHERE key = 'cross_project_enabled'")
368
+ .get();
369
+ // BUG-002 — the column stores TEXT ('0'/'1'); the old numeric
370
+ // comparison `=== 1` could never match '1'.
371
+ return (row?.value ?? "0") === "1";
372
+ }
373
+ catch {
374
+ return false;
375
+ }
376
+ }
377
+ /**
378
+ * v0.4.0 (K4-012) — read a kevin_settings flag by key. Falls back to
379
+ * the caller-provided default when the key is missing or the table is
380
+ * unavailable (legacy DB without the settings table).
381
+ */
382
+ getSetting(key, fallback = "0") {
383
+ try {
384
+ const row = this.store
385
+ .prepare("SELECT value FROM kevin_settings WHERE key = ?")
386
+ .get(key);
387
+ return row?.value ?? fallback;
388
+ }
389
+ catch {
390
+ return fallback;
391
+ }
241
392
  }
242
- loadAll(scope) {
393
+ loadAll(scope, includeSuperseded = false) {
243
394
  let sql = `
244
395
  SELECT id, type, content, scope, relevance_score, source_tool, source_session,
245
396
  metadata, created_at, updated_at, expires_at,
246
- project_id, fingerprint, origin
397
+ project_id, fingerprint, origin,
398
+ evidence_count, last_verified_at, status
247
399
  FROM memories
248
400
  WHERE (expires_at IS NULL OR expires_at > datetime('now'))`;
401
+ if (!includeSuperseded) {
402
+ sql += "\n AND status = 'active'";
403
+ }
249
404
  const params = [];
250
405
  if (scope !== "all") {
251
406
  sql += " AND scope = ?";
@@ -254,24 +409,25 @@ export class MemoryService {
254
409
  sql += " ORDER BY relevance_score DESC, created_at DESC";
255
410
  return this.store.prepare(sql).all(...params);
256
411
  }
257
- queryRelevant(text, scope) {
258
- const tokens = stripUnbalancedQuotes(text.trim())
259
- .split(/\s+/)
260
- .filter((t) => t.length > 0)
261
- .map((t) => `"${t.replace(/"/g, '""')}"`);
412
+ queryRelevant(text, scope, includeSuperseded = false) {
413
+ const tokens = tokenizeQuery(stripUnbalancedQuotes(text));
262
414
  if (tokens.length === 0)
263
415
  return [];
264
- const match = tokens.join(" OR ");
416
+ const match = toMatchClause(tokens, " OR ");
265
417
  let sql = `
266
418
  SELECT m.id, m.type, m.content, m.scope, m.relevance_score,
267
419
  m.source_tool, m.source_session, m.metadata,
268
420
  m.created_at, m.updated_at, m.expires_at,
269
421
  m.project_id, m.fingerprint, m.origin,
422
+ m.evidence_count, m.last_verified_at, m.status,
270
423
  bm25(memories_fts) AS score
271
424
  FROM memories_fts
272
425
  JOIN memories m ON m.rowid = memories_fts.rowid
273
426
  WHERE memories_fts MATCH ?
274
427
  AND (m.expires_at IS NULL OR m.expires_at > datetime('now'))`;
428
+ if (!includeSuperseded) {
429
+ sql += "\n AND m.status = 'active'";
430
+ }
275
431
  const params = [match];
276
432
  if (scope !== "all") {
277
433
  sql += " AND m.scope = ?";
@@ -287,15 +443,26 @@ export class MemoryService {
287
443
  const maxTokens = input.maxTokens ?? 2000;
288
444
  const charBudget = maxTokens * 4;
289
445
  const scope = input.scope ?? "project";
446
+ const includeSuperseded = input.includeSuperseded === true;
290
447
  let candidates;
291
448
  if (input.query && input.query.trim().length > 0) {
292
- candidates = this.queryRelevant(input.query, scope);
449
+ candidates = this.queryRelevant(input.query, scope, includeSuperseded);
293
450
  }
294
451
  else {
295
- candidates = this.loadAll(scope)
452
+ candidates = this.loadAll(scope, includeSuperseded)
296
453
  .map((r) => mapRow(r))
297
454
  .filter((m) => !isNotSearchable(m));
298
455
  }
456
+ // v0.3.0 (K3-019) — cross-project opt-in.
457
+ // When cross_project_enabled is OFF (default), exclude imported
458
+ // cross-project rows (project_id IS NULL AND origin='imported').
459
+ // OKF import is the cross-project bridge per plan §B12; imported
460
+ // memories always carry project_id NULL (bug #12 fix — the old
461
+ // filter only excluded imported RULES, leaking imported
462
+ // decisions/patterns into recall).
463
+ if (!this.isCrossProjectEnabled()) {
464
+ candidates = candidates.filter((m) => m.projectId !== null || m.origin !== "imported");
465
+ }
299
466
  // v0.2.0 (K2-023) origin-aware rank: BM25 × origin-boost × recency-decay.
300
467
  // Tie-breakers preserve the v0.1.x spirit (errors/patterns before
301
468
  // context; newer before older when nothing else decides).
@@ -309,7 +476,7 @@ export class MemoryService {
309
476
  result.push(mem);
310
477
  used += len;
311
478
  }
312
- if (result.length > 0) {
479
+ if (result.length > 0 && input.bump !== false) {
313
480
  const bump = this.store.prepare("UPDATE memories SET relevance_score = MIN(?, relevance_score + ?) WHERE id = ?");
314
481
  this.store.transaction(() => {
315
482
  for (const m of result)
@@ -318,6 +485,93 @@ export class MemoryService {
318
485
  }
319
486
  return result;
320
487
  }
488
+ /**
489
+ * v0.4.0 (BUG-016) — apply the K2-023 relevance bump to a fixed slice
490
+ * of ids, exactly once. Lets ContextInjector probe without mutating
491
+ * and still bump the slice it actually injects.
492
+ */
493
+ bumpRelevance(ids) {
494
+ if (ids.length === 0)
495
+ return;
496
+ const bump = this.store.prepare("UPDATE memories SET relevance_score = MIN(?, relevance_score + ?) WHERE id = ?");
497
+ this.store.transaction(() => {
498
+ for (const id of ids)
499
+ bump.run(RELEVANCE_MAX, RELEVANCE_BUMP, id);
500
+ });
501
+ }
502
+ /**
503
+ * v0.3.0 (K3-004) — Promote an error memory to a causal pattern.
504
+ *
505
+ * Creates a new `pattern` memory with `origin = 'causal'`, derived
506
+ * confidence, and evidence count. The original error memory is NOT
507
+ * deleted — the audit trail is preserved. Returns the new memory id,
508
+ * or null when the source error is not eligible (missing fingerprint,
509
+ * wrong type, or already promoted).
510
+ */
511
+ /**
512
+ * v0.4.0 (K4-009) — returns `{ id, created }` so callers can tell a
513
+ * newly-created pattern from an idempotent refresh.
514
+ */
515
+ promoteToPattern(errorId, evidenceCount, recurrenceCount = 0) {
516
+ const error = this.getById(errorId);
517
+ if (!error || error.type !== "error" || !error.fingerprint)
518
+ return null;
519
+ // v0.4.0 (K4-010) — two-sided confidence: recurrence demotes the
520
+ // pattern's confidence.
521
+ const confidence = computeConfidence(evidenceCount, recurrenceCount);
522
+ const now = new Date().toISOString();
523
+ const summary = error.content.split("\n")[0].slice(0, 200);
524
+ const base = `Causal pattern: ${summary}\n\nEvidence: ${evidenceCount} confirmed fix(es)\nConfidence: ${(confidence * 100).toFixed(0)}%\n\nOriginal: ${error.content.slice(0, 1000)}`;
525
+ // v0.4.0 (K4-014) — deterministic "Fixed by:" from the linked
526
+ // success call's args_summary (D4-07). The opt-in LLM phrasing
527
+ // (K4-015) runs later in CausalChain.onSessionIdle, never here and
528
+ // never on the failure hot path.
529
+ const fixLine = deterministicFixLine({
530
+ content: base,
531
+ fixArgs: error.fixArgs ?? null,
532
+ });
533
+ const content = fixLine ? `${base}\n${fixLine}` : base;
534
+ // v0.3.0 fix (bug #4) — idempotent promotion: the supersede model
535
+ // only covers decision/rule, so the old code inserted a duplicate
536
+ // pattern on every subsequent session.idle with a new fix. When an
537
+ // active causal pattern already exists for this fingerprint, refresh
538
+ // it (content, evidence_count, last_verified_at) instead. The FTS
539
+ // sync trigger (memories_au) keeps searchable content up to date.
540
+ const existing = this.store
541
+ .prepare(`SELECT id FROM memories
542
+ WHERE fingerprint = ? AND type = 'pattern'
543
+ AND origin = 'causal' AND status = 'active'
544
+ ORDER BY created_at DESC LIMIT 1`)
545
+ .get(error.fingerprint);
546
+ let patternId;
547
+ if (existing) {
548
+ this.update(existing.id, { content, evidenceCount, lastVerifiedAt: now });
549
+ patternId = existing.id;
550
+ }
551
+ else {
552
+ patternId = this.save({
553
+ type: "pattern",
554
+ content,
555
+ scope: "project",
556
+ origin: "causal",
557
+ sourceTool: error.sourceTool ?? undefined,
558
+ sourceSession: error.sourceSession ?? undefined,
559
+ fingerprint: error.fingerprint,
560
+ evidenceCount,
561
+ lastVerifiedAt: now,
562
+ status: "active",
563
+ projectId: error.projectId ?? undefined,
564
+ });
565
+ }
566
+ // v0.4.0 (K4-010) — persist the recurrence count on the pattern row
567
+ // so mapRow (and kevin_why) recompute the SAME demoted confidence.
568
+ // v0.4.0 (K4-014) — persist fix_args too: the pattern's "Fixed by:"
569
+ // raw material travels with the row for kevin_why/HITL (K4-016/020).
570
+ this.store
571
+ .prepare("UPDATE memories SET recurrence_count = ?, fix_args = ? WHERE id = ?")
572
+ .run(recurrenceCount, error.fixArgs ?? null, patternId);
573
+ return { id: patternId, created: !existing };
574
+ }
321
575
  /**
322
576
  * v0.2.0 (K2-026) — Feedback loop positive half (plan §B6.10 / D2-10).
323
577
  *
@@ -333,25 +587,33 @@ export class MemoryService {
333
587
  if (!sessionId)
334
588
  return 0;
335
589
  const lessons = this.store
336
- .prepare(`SELECT id, fingerprint, project_id
590
+ .prepare(`SELECT id, fingerprint, project_id, metadata
337
591
  FROM memories
338
592
  WHERE origin = 'reflector'
339
593
  AND type = 'error'
340
594
  AND source_session = ?
341
- AND fingerprint IS NOT NULL`)
595
+ AND fingerprint IS NOT NULL
596
+ AND status = 'active'`)
342
597
  .all(sessionId);
343
598
  if (lessons.length === 0)
344
599
  return 0;
600
+ // v0.3.0 fix — recurrence is now matched via `error_fingerprint`
601
+ // (set by Reflector.onLinkError) OR the legacy `fingerprint` column
602
+ // (preserved for tests and pre-fix tool_call rows). The original
603
+ // failing call is excluded when its id is recorded in the memory
604
+ // metadata as `origin_call_id` (set by Reflector from `callID`).
345
605
  const recurrenceCheck = this.store.prepare(`SELECT COUNT(*) AS c
346
606
  FROM tool_calls
347
- WHERE fingerprint = ?
607
+ WHERE (error_fingerprint = ? OR fingerprint = ?)
348
608
  AND success = 0
349
- AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))`);
609
+ AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))
610
+ AND (? IS NULL OR id <> ?)`);
350
611
  const bumpOne = this.store.prepare("UPDATE memories SET relevance_score = MIN(?, relevance_score + ?) WHERE id = ?");
351
612
  let boosted = 0;
352
613
  this.store.transaction(() => {
353
614
  for (const l of lessons) {
354
- const row = recurrenceCheck.get(l.fingerprint, l.project_id, l.project_id);
615
+ const originCallId = readOriginCallId(l.metadata);
616
+ const row = recurrenceCheck.get(l.fingerprint, l.fingerprint, l.project_id, l.project_id, originCallId, originCallId);
355
617
  const c = row?.c ?? 0;
356
618
  if (c === 0) {
357
619
  bumpOne.run(RELEVANCE_MAX, RELEVANCE_BUMP, l.id);
@@ -361,10 +623,112 @@ export class MemoryService {
361
623
  });
362
624
  return boosted;
363
625
  }
626
+ /**
627
+ * v0.3.0 (K3-013) — Feedback loop negative half.
628
+ *
629
+ * For each reflector-sourced error memory from this session whose
630
+ * fingerprint DID recur as a failing tool_call (the lesson didn't
631
+ * prevent the error), decrement `relevance_score` by `RELEVANCE_PENALTY`
632
+ * (down to zero) and increment `evidence_count` as a negative signal.
633
+ * Agent-saved memories are NEVER penalized.
634
+ *
635
+ * Returns the number of memories penalized.
636
+ */
637
+ /**
638
+ * v0.3.0 fix — Mirror of the free function `countSupersedeCandidates`
639
+ * exposed as an instance method so `okf-import` (which holds a
640
+ * `MemoryService` reference but not the underlying `Store`) can count
641
+ * rows that `save()` will mark as superseded.
642
+ */
643
+ countSupersedeCandidates(type, fingerprint, projectId) {
644
+ return countSupersedeCandidates(this.store, type, fingerprint, projectId);
645
+ }
646
+ penalizeRecurringReflectors(sessionId) {
647
+ if (!sessionId)
648
+ return 0;
649
+ const RELEVANCE_PENALTY = 0.05;
650
+ // v0.3.0 fix — to support cross-session feedback we drop the
651
+ // memory-side `source_session` filter: any reflector error whose
652
+ // fingerprint recurs as a failing tool_call IN THIS session is
653
+ // eligible for penalization (the lesson didn't prevent the error).
654
+ // The recurrence check narrows on `tool_calls.session_id = ?` and
655
+ // excludes the original failing call via `origin_call_id` metadata,
656
+ // matching both new `error_fingerprint` and legacy `fingerprint`.
657
+ const lessons = this.store
658
+ .prepare(`SELECT id, fingerprint, project_id, metadata
659
+ FROM memories
660
+ WHERE origin = 'reflector'
661
+ AND type = 'error'
662
+ AND fingerprint IS NOT NULL
663
+ AND status = 'active'`)
664
+ .all();
665
+ if (lessons.length === 0)
666
+ return 0;
667
+ const recurrenceCheck = this.store.prepare(`SELECT COUNT(*) AS c
668
+ FROM tool_calls
669
+ WHERE session_id = ?
670
+ AND success = 0
671
+ AND (error_fingerprint = ? OR fingerprint = ?)
672
+ AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))
673
+ AND (? IS NULL OR id <> ?)`);
674
+ const settledCheck = this.store.prepare(`SELECT 1 FROM kevin_injections
675
+ WHERE session_id = ? AND memory_id = ? AND outcome = 'ineffective'
676
+ LIMIT 1`);
677
+ const penalizeOne = this.store.prepare(`UPDATE memories
678
+ SET relevance_score = MAX(0, relevance_score - ?),
679
+ recurrence_count = recurrence_count + 1,
680
+ last_verified_at = datetime('now')
681
+ WHERE id = ?`);
682
+ const penalizeRelevanceOnly = this.store.prepare(`UPDATE memories
683
+ SET relevance_score = MAX(0, relevance_score - ?),
684
+ last_verified_at = datetime('now')
685
+ WHERE id = ?`);
686
+ let penalized = 0;
687
+ this.store.transaction(() => {
688
+ for (const l of lessons) {
689
+ const originCallId = readOriginCallId(l.metadata);
690
+ const row = recurrenceCheck.get(sessionId, l.fingerprint, l.fingerprint, l.project_id, l.project_id, originCallId, originCallId);
691
+ const c = row?.c ?? 0;
692
+ if (c > 0) {
693
+ // v0.4.0 (K4-025) — no double-charge: when the
694
+ // session's injection of this memory was already
695
+ // settled `ineffective`, `InjectionLedger.settle`
696
+ // charged recurrence_count (K4-007) and this pass
697
+ // only applies the relevance penalty. The +1 charge
698
+ // below is the pre-ledger path (K4-011) for memories
699
+ // that were never injected this session.
700
+ const settled = settledCheck.get(sessionId, l.id);
701
+ if (settled) {
702
+ penalizeRelevanceOnly.run(RELEVANCE_PENALTY, l.id);
703
+ }
704
+ else {
705
+ // v0.4.0 (K4-011) — recurrence is negative evidence:
706
+ // it bumps `recurrence_count`, NOT `evidence_count`
707
+ // (the old code counted recurrence as positive
708
+ // evidence). No `memories_superseded` increment here —
709
+ // supersede is only counted when a decision/rule is
710
+ // truly replaced (see save()).
711
+ penalizeOne.run(RELEVANCE_PENALTY, l.id);
712
+ // v0.4.0 (K4-025 / plan §5.1 rule 4, D4-06) — same
713
+ // recurrence-expels rule the settle enforces: at
714
+ // `recurrence_count >= 3` the error lesson is demoted
715
+ // to `status='stale'`.
716
+ this.store
717
+ .prepare(`UPDATE memories SET status = 'stale'
718
+ WHERE id = ? AND recurrence_count >= 3`)
719
+ .run(l.id);
720
+ }
721
+ penalized += 1;
722
+ }
723
+ }
724
+ });
725
+ return penalized;
726
+ }
364
727
  }
365
728
  function originBoost(mem) {
366
729
  switch (mem.origin ?? "agent") {
367
730
  case "reflector":
731
+ case "causal":
368
732
  return ORIGIN_BOOST_REFLECTOR;
369
733
  case "pattern":
370
734
  return ORIGIN_BOOST_PATTERN;
@@ -372,6 +736,50 @@ function originBoost(mem) {
372
736
  return ORIGIN_BOOST_AGENT;
373
737
  }
374
738
  }
739
+ /**
740
+ * v0.3.0 fix — Extract `origin_call_id` from the memory metadata blob.
741
+ *
742
+ * Reflector stores the failing tool_call id in metadata.origin_call_id
743
+ * (when available) so the feedback loop can exclude the original call
744
+ * from the recurrence count. Returns null when metadata is absent,
745
+ * malformed, or lacks the field.
746
+ */
747
+ function readOriginCallId(metadata) {
748
+ if (!metadata)
749
+ return null;
750
+ try {
751
+ const parsed = JSON.parse(metadata);
752
+ const id = parsed?.origin_call_id;
753
+ return typeof id === "string" && id.length > 0 ? id : null;
754
+ }
755
+ catch {
756
+ return null;
757
+ }
758
+ }
759
+ /**
760
+ * v0.3.0 fix — Count active memories that would be superseded by a new
761
+ * row with the given (type, fingerprint, projectId) tuple. Used by
762
+ * `okf-import` to populate `ImportResult.superseded` accurately.
763
+ *
764
+ * Matches the supersede logic in `save()`: only `decision` and `rule`
765
+ * types supersede prior rows with the same fingerprint. Returns 0 for
766
+ * any other type.
767
+ */
768
+ export function countSupersedeCandidates(store, type, fingerprint, projectId) {
769
+ if (!fingerprint)
770
+ return 0;
771
+ if (type !== "decision" && type !== "rule")
772
+ return 0;
773
+ const row = store
774
+ .prepare(`SELECT COUNT(*) AS c
775
+ FROM memories
776
+ WHERE type IN ('decision', 'rule')
777
+ AND fingerprint = ?
778
+ AND status = 'active'
779
+ AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))`)
780
+ .get(fingerprint, projectId, projectId);
781
+ return row?.c ?? 0;
782
+ }
375
783
  function rankScore(mem) {
376
784
  // FTS5 bm25 returns a negative score (more negative = better match).
377
785
  // For non-FTS rows (loadAll path), fall back to -relevance_score so