@jmtrin/opencode-kevin 0.3.0 → 0.5.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 (72) hide show
  1. package/README.md +117 -9
  2. package/dist/migrations/005_v04_signal.sql +57 -0
  3. package/dist/migrations/006_v05_glassbox.sql +118 -0
  4. package/dist/plugin/Archiver.d.ts +42 -0
  5. package/dist/plugin/Archiver.js +98 -0
  6. package/dist/plugin/Archiver.js.map +1 -0
  7. package/dist/plugin/CausalChain.d.ts +13 -2
  8. package/dist/plugin/CausalChain.js +83 -13
  9. package/dist/plugin/CausalChain.js.map +1 -1
  10. package/dist/plugin/ContextInjector.d.ts +152 -4
  11. package/dist/plugin/ContextInjector.js +400 -93
  12. package/dist/plugin/ContextInjector.js.map +1 -1
  13. package/dist/plugin/Feedback.d.ts +67 -0
  14. package/dist/plugin/Feedback.js +137 -0
  15. package/dist/plugin/Feedback.js.map +1 -0
  16. package/dist/plugin/InjectionLedger.d.ts +92 -0
  17. package/dist/plugin/InjectionLedger.js +243 -0
  18. package/dist/plugin/InjectionLedger.js.map +1 -0
  19. package/dist/plugin/LessonFixer.d.ts +44 -0
  20. package/dist/plugin/LessonFixer.js +46 -0
  21. package/dist/plugin/LessonFixer.js.map +1 -0
  22. package/dist/plugin/MemoryService.d.ts +81 -1
  23. package/dist/plugin/MemoryService.js +321 -54
  24. package/dist/plugin/MemoryService.js.map +1 -1
  25. package/dist/plugin/Migrate.js +31 -0
  26. package/dist/plugin/Migrate.js.map +1 -1
  27. package/dist/plugin/QualityGate.d.ts +110 -0
  28. package/dist/plugin/QualityGate.js +108 -0
  29. package/dist/plugin/QualityGate.js.map +1 -0
  30. package/dist/plugin/Reflector.d.ts +17 -0
  31. package/dist/plugin/Reflector.js +81 -26
  32. package/dist/plugin/Reflector.js.map +1 -1
  33. package/dist/plugin/Retrospective.js +22 -1
  34. package/dist/plugin/Retrospective.js.map +1 -1
  35. package/dist/plugin/ToolCallObserver.d.ts +1 -0
  36. package/dist/plugin/ToolCallObserver.js +15 -8
  37. package/dist/plugin/ToolCallObserver.js.map +1 -1
  38. package/dist/plugin/confidence.d.ts +8 -0
  39. package/dist/plugin/confidence.js +35 -0
  40. package/dist/plugin/confidence.js.map +1 -0
  41. package/dist/plugin/index.d.ts +5 -0
  42. package/dist/plugin/index.js +368 -44
  43. package/dist/plugin/index.js.map +1 -1
  44. package/dist/plugin/kevin_audit.d.ts +49 -0
  45. package/dist/plugin/kevin_audit.js +141 -0
  46. package/dist/plugin/kevin_audit.js.map +1 -0
  47. package/dist/plugin/kevin_why.d.ts +4 -0
  48. package/dist/plugin/kevin_why.js +72 -35
  49. package/dist/plugin/kevin_why.js.map +1 -1
  50. package/dist/plugin/memory-format.d.ts +12 -0
  51. package/dist/plugin/memory-format.js +45 -5
  52. package/dist/plugin/memory-format.js.map +1 -1
  53. package/dist/plugin/metrics.d.ts +27 -1
  54. package/dist/plugin/metrics.js +61 -0
  55. package/dist/plugin/metrics.js.map +1 -1
  56. package/dist/plugin/okf-export.js +63 -22
  57. package/dist/plugin/okf-export.js.map +1 -1
  58. package/dist/plugin/okf-import.d.ts +4 -1
  59. package/dist/plugin/okf-import.js +45 -12
  60. package/dist/plugin/okf-import.js.map +1 -1
  61. package/dist/plugin/query-tokenizer.d.ts +13 -0
  62. package/dist/plugin/query-tokenizer.js +86 -0
  63. package/dist/plugin/query-tokenizer.js.map +1 -0
  64. package/dist/plugin/replay-types.d.ts +327 -0
  65. package/dist/plugin/replay-types.js +65 -0
  66. package/dist/plugin/replay-types.js.map +1 -0
  67. package/dist/plugin/replay.d.ts +36 -0
  68. package/dist/plugin/replay.js +193 -0
  69. package/dist/plugin/replay.js.map +1 -0
  70. package/migrations/005_v04_signal.sql +57 -0
  71. package/migrations/006_v05_glassbox.sql +118 -0
  72. package/package.json +3 -2
@@ -1,15 +1,28 @@
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,
@@ -31,6 +44,10 @@ const ORIGIN_BOOST_PATTERN = 1.5;
31
44
  const ORIGIN_BOOST_CASUAL = 2;
32
45
  const ORIGIN_BOOST_AGENT = 1;
33
46
  const RECENCY_DECAY_PER_DAY = 0.95; // newer = closer to 1 (less penalty)
47
+ // v0.5.0 (K5-008 / plan §5.6, D5-10) — DATE_NOW sentinel: deterministic
48
+ // retrieval reads this fixed future instant instead of the wall clock,
49
+ // making ordering a pure function of database content. Export for tests.
50
+ export const DATE_NOW = "2099-01-01T00:00:00.000Z";
34
51
  function sqliteUtcToMs(createdAt) {
35
52
  // SQLite `datetime('now')` returns 'YYYY-MM-DD HH:MM:SS' in UTC.
36
53
  // JS Date can parse ISO 8601 with 'T' and 'Z'.
@@ -48,6 +65,41 @@ function sqliteUtcNowPlusHours(hours) {
48
65
  const pad = (n) => String(n).padStart(2, "0");
49
66
  return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
50
67
  }
68
+ /** Shared column list for row reads (must stay in sync with MemoryRow). */
69
+ const MEMORY_ROW_SELECT = `id, type, content, scope, relevance_score, source_tool, source_session,
70
+ metadata, created_at, updated_at, expires_at,
71
+ project_id, fingerprint, origin,
72
+ evidence_count, recurrence_count, last_verified_at, status, fix_args`;
73
+ /**
74
+ * v0.5.0 (K5-008/009 / plan §5.6) — cached per-store probe for the 006-only
75
+ * `ignored` column (pre-006 DBs must not reference it). Shared by the
76
+ * retrieval filter, the row SELECT, and the save() ignored stamp.
77
+ */
78
+ const ignoredColumnCache = new WeakMap();
79
+ function hasIgnoredColumn(store) {
80
+ const cached = ignoredColumnCache.get(store);
81
+ if (cached !== undefined)
82
+ return cached;
83
+ try {
84
+ store.prepare("SELECT ignored FROM memories LIMIT 1").get();
85
+ ignoredColumnCache.set(store, true);
86
+ return true;
87
+ }
88
+ catch {
89
+ ignoredColumnCache.set(store, false);
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * v0.5.0 (K5-009 / plan §5.3) — the 006-only columns are appended when the
95
+ * migration has run (same probe as the `ignored = 0` retrieval filter).
96
+ */
97
+ function rowSelect(store) {
98
+ return hasIgnoredColumn(store)
99
+ ? `${MEMORY_ROW_SELECT}, ignored, superseded_by,
100
+ feedback_positive, feedback_negative`
101
+ : MEMORY_ROW_SELECT;
102
+ }
51
103
  function mapRow(row, score) {
52
104
  const mem = {
53
105
  id: row.id,
@@ -67,11 +119,20 @@ function mapRow(row, score) {
67
119
  fingerprint: row.fingerprint ?? null,
68
120
  origin: row.origin ?? null,
69
121
  confidence: typeof row.evidence_count === "number"
70
- ? Math.min(1, 0.5 + 0.1 * (row.evidence_count ?? 0))
122
+ ? computeConfidence(row.evidence_count ?? 0, row.recurrence_count ?? 0, row.feedback_positive ?? 0, row.feedback_negative ?? 0)
71
123
  : null,
72
124
  evidenceCount: row.evidence_count ?? null,
73
125
  lastVerifiedAt: row.last_verified_at ?? null,
74
126
  status: row.status ?? "active",
127
+ fixArgs: row.fix_args ?? null,
128
+ recurrenceCount: row.recurrence_count ?? null,
129
+ // v0.5.0 (K5-009 / plan §5.3, D5-07) — the human-verdict and
130
+ // supersession fields; absent on pre-006 rows.
131
+ ignored: row.ignored === undefined ? undefined : Boolean(row.ignored),
132
+ supersedes: row.superseded_by ?? null,
133
+ // v0.5.0 (K5-010 / plan §5.3) — human judgement counters.
134
+ feedbackPositive: row.feedback_positive ?? 0,
135
+ feedbackNegative: row.feedback_negative ?? 0,
75
136
  };
76
137
  if (score !== undefined) {
77
138
  if (!mem.metadata)
@@ -106,6 +167,29 @@ export class MemoryService {
106
167
  // so that Metrics can be added without changing the parameter order callers
107
168
  // have been using since v0.1.0.
108
169
  store;
170
+ // v0.4.0 (BUG-008) — cached column probe for pre-005 DBs (which lack
171
+ // `recurrence_count`); save() must not reference the column there.
172
+ hasRecurrenceColumn() {
173
+ if (this._hasRecurrenceColumn === undefined) {
174
+ try {
175
+ this.store
176
+ .prepare("SELECT recurrence_count FROM memories LIMIT 1")
177
+ .get();
178
+ this._hasRecurrenceColumn = true;
179
+ }
180
+ catch {
181
+ this._hasRecurrenceColumn = false;
182
+ }
183
+ }
184
+ return this._hasRecurrenceColumn;
185
+ }
186
+ _hasRecurrenceColumn;
187
+ // v0.5.0 (K5-008 / plan §5.6) — cached column probe for pre-006 DBs
188
+ // (which lack `ignored`); the retrieval filter must not reference the
189
+ // column there.
190
+ hasIgnoredColumn() {
191
+ return hasIgnoredColumn(this.store);
192
+ }
109
193
  save(input) {
110
194
  const scope = input.scope ?? "project";
111
195
  const relevanceScore = input.relevanceScore ?? 0.5;
@@ -131,29 +215,56 @@ export class MemoryService {
131
215
  if (scope === "session" && !input.expiresAt) {
132
216
  expiresAt = sqliteUtcNowPlusHours(SESSION_DEFAULT_TTL_HOURS);
133
217
  }
134
- const id = uuidv7();
218
+ const id = input.id ?? uuidv7();
135
219
  // v0.3.0 (K3-014) — supersede model: when saving a decision/rule with
136
220
  // the same fingerprint as an existing active row, mark the old as
137
221
  // superseded and insert the fresh version as active.
222
+ // v0.4.0 (K4-011) — `memories_superseded` is only counted here,
223
+ // where a row is truly replaced; penalization of recurring
224
+ // reflectors no longer increments it.
225
+ // v0.5.0 (K5-013 / plan §5.5, D5-06) — the old row also records WHO
226
+ // superseded it (`superseded_by = <new id>`), giving status-based
227
+ // supersession a navigable audit trail. Guarded: pre-006 DBs lack
228
+ // the column (same migration as `ignored`).
138
229
  const supersedableTypes = ["decision", "rule"];
139
230
  if (fp !== null && supersedableTypes.includes(input.type)) {
231
+ const withSupersededBy = this.hasIgnoredColumn();
232
+ const setClause = withSupersededBy
233
+ ? "SET status = 'superseded', superseded_by = ?, updated_at = datetime('now')"
234
+ : "SET status = 'superseded', updated_at = datetime('now')";
140
235
  this.store
141
236
  .prepare(`UPDATE memories
142
- SET status = 'superseded', updated_at = datetime('now')
237
+ ${setClause}
143
238
  WHERE fingerprint = ?
144
239
  AND type = ?
145
240
  AND status = 'active'
146
241
  AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))`)
147
- .run(fp, input.type, projectId, projectId);
242
+ .run(...(withSupersededBy
243
+ ? [id, fp, input.type, projectId, projectId]
244
+ : [fp, input.type, projectId, projectId]));
245
+ const after = this.store.prepare("SELECT changes() AS n").get();
246
+ if (after.n > 0) {
247
+ this.metrics?.incr("memories_superseded", 1);
248
+ }
148
249
  }
149
250
  try {
150
- this.store
151
- .prepare(`INSERT INTO memories
251
+ // v0.4.0 (BUG-008) — recurrence_count is only persisted when the
252
+ // column exists (migration 005); pre-005 DBs get the legacy shape.
253
+ const withRecurrence = this.hasRecurrenceColumn();
254
+ const insert = withRecurrence
255
+ ? `INSERT INTO memories
256
+ (id, type, content, scope, relevance_score, source_tool, source_session,
257
+ metadata, expires_at, project_id, fingerprint, origin,
258
+ evidence_count, last_verified_at, status, recurrence_count)
259
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
260
+ : `INSERT INTO memories
152
261
  (id, type, content, scope, relevance_score, source_tool, source_session,
153
262
  metadata, expires_at, project_id, fingerprint, origin,
154
263
  evidence_count, last_verified_at, status)
155
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
156
- .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);
264
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
265
+ this.store
266
+ .prepare(insert)
267
+ .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] : []));
157
268
  return id;
158
269
  }
159
270
  catch (err) {
@@ -185,14 +296,26 @@ export class MemoryService {
185
296
  }
186
297
  getById(id) {
187
298
  const row = this.store
188
- .prepare(`SELECT id, type, content, scope, relevance_score, source_tool, source_session,
189
- metadata, created_at, updated_at, expires_at,
190
- project_id, fingerprint, origin,
191
- evidence_count, last_verified_at, status
299
+ .prepare(`SELECT ${rowSelect(this.store)}
192
300
  FROM memories WHERE id = ?`)
193
301
  .get(id);
194
302
  return row ? mapRow(row) : null;
195
303
  }
304
+ /**
305
+ * v0.4.0 (K4-016) — most recent ACTIVE memory for a fingerprint,
306
+ * optionally filtered by type. Feeds the HITL suggestion lookup
307
+ * (most-recurred fingerprint → its pattern memory).
308
+ */
309
+ getByFingerprint(fingerprint, type) {
310
+ const row = this.store
311
+ .prepare(`SELECT ${rowSelect(this.store)}
312
+ FROM memories
313
+ WHERE fingerprint = ? AND status = 'active'
314
+ ${type ? "AND type = ?" : ""}
315
+ ORDER BY created_at DESC LIMIT 1`)
316
+ .get(...(type ? [fingerprint, type] : [fingerprint]));
317
+ return row ? mapRow(row) : null;
318
+ }
196
319
  update(id, fields) {
197
320
  const cols = [];
198
321
  const vals = [];
@@ -232,6 +355,13 @@ export class MemoryService {
232
355
  cols.push("status = ?");
233
356
  vals.push(fields.status);
234
357
  }
358
+ // v0.4.0 (BUG-008) — recurrence_count is writable so okf-import can
359
+ // restore negative evidence across a round-trip. Guarded by the
360
+ // caller (pre-005 DBs lack the column; update() throws).
361
+ if (fields.recurrenceCount !== undefined) {
362
+ cols.push("recurrence_count = ?");
363
+ vals.push(fields.recurrenceCount);
364
+ }
235
365
  if (cols.length === 0)
236
366
  return;
237
367
  cols.push("updated_at = datetime('now')");
@@ -261,6 +391,11 @@ export class MemoryService {
261
391
  JOIN memories m ON m.rowid = memories_fts.rowid
262
392
  WHERE memories_fts MATCH ?
263
393
  AND (m.expires_at IS NULL OR m.expires_at > datetime('now'))`;
394
+ // v0.5.0 (K5-011 / plan §5.6, D5-07) — ignored memories are hidden
395
+ // from kevin_query too (guarded for pre-006 DBs).
396
+ if (this.hasIgnoredColumn()) {
397
+ sql += "\n AND m.ignored = 0";
398
+ }
264
399
  const params = [match];
265
400
  if (!input.includeSuperseded) {
266
401
  sql += "\n AND m.status = 'active'";
@@ -284,27 +419,57 @@ export class MemoryService {
284
419
  .map((r) => mapRow(r, r.score))
285
420
  .filter((m) => !isNotSearchable(m))
286
421
  .filter((m) => crossProjectOn || m.projectId !== null || m.origin !== "imported");
287
- return input.full === true ? memories : memories.map(toSlim);
422
+ return input.full === true
423
+ ? memories
424
+ : memories.map((m) => toSlim(m, input.evidence === true));
288
425
  }
289
426
  isCrossProjectEnabled() {
290
427
  try {
291
428
  const row = this.store
292
429
  .prepare("SELECT value FROM kevin_settings WHERE key = 'cross_project_enabled'")
293
430
  .get();
294
- return (row?.value ?? 0) === 1;
431
+ // BUG-002 the column stores TEXT ('0'/'1'); the old numeric
432
+ // comparison `=== 1` could never match '1'.
433
+ return (row?.value ?? "0") === "1";
295
434
  }
296
435
  catch {
297
436
  return false;
298
437
  }
299
438
  }
439
+ /**
440
+ * v0.4.0 (K4-012) — read a kevin_settings flag by key. Falls back to
441
+ * the caller-provided default when the key is missing or the table is
442
+ * unavailable (legacy DB without the settings table).
443
+ */
444
+ getSetting(key, fallback = "0") {
445
+ try {
446
+ const row = this.store
447
+ .prepare("SELECT value FROM kevin_settings WHERE key = ?")
448
+ .get(key);
449
+ return row?.value ?? fallback;
450
+ }
451
+ catch {
452
+ return fallback;
453
+ }
454
+ }
300
455
  loadAll(scope, includeSuperseded = false) {
301
456
  let sql = `
302
457
  SELECT id, type, content, scope, relevance_score, source_tool, source_session,
303
458
  metadata, created_at, updated_at, expires_at,
304
459
  project_id, fingerprint, origin,
305
- evidence_count, last_verified_at, status
460
+ evidence_count, last_verified_at, status`;
461
+ // v0.5.0 (K5-009/010) — 006-only columns, appended when present.
462
+ if (this.hasIgnoredColumn()) {
463
+ sql += ", ignored, superseded_by, feedback_positive, feedback_negative";
464
+ }
465
+ sql += `
306
466
  FROM memories
307
467
  WHERE (expires_at IS NULL OR expires_at > datetime('now'))`;
468
+ // v0.5.0 (K5-008 / plan §5.6) — ignored memories are excluded from
469
+ // retrieval (human verdict, D5-07). Guarded for pre-006 DBs.
470
+ if (this.hasIgnoredColumn()) {
471
+ sql += "\n AND ignored = 0";
472
+ }
308
473
  if (!includeSuperseded) {
309
474
  sql += "\n AND status = 'active'";
310
475
  }
@@ -317,24 +482,32 @@ export class MemoryService {
317
482
  return this.store.prepare(sql).all(...params);
318
483
  }
319
484
  queryRelevant(text, scope, includeSuperseded = false) {
320
- const tokens = stripUnbalancedQuotes(text.trim())
321
- .split(/\s+/)
322
- .filter((t) => t.length > 0)
323
- .map((t) => `"${t.replace(/"/g, '""')}"`);
485
+ const tokens = tokenizeQuery(stripUnbalancedQuotes(text));
324
486
  if (tokens.length === 0)
325
487
  return [];
326
- const match = tokens.join(" OR ");
488
+ const match = toMatchClause(tokens, " OR ");
327
489
  let sql = `
328
490
  SELECT m.id, m.type, m.content, m.scope, m.relevance_score,
329
491
  m.source_tool, m.source_session, m.metadata,
330
492
  m.created_at, m.updated_at, m.expires_at,
331
493
  m.project_id, m.fingerprint, m.origin,
332
- m.evidence_count, m.last_verified_at, m.status,
494
+ m.evidence_count, m.last_verified_at, m.status`;
495
+ // v0.5.0 (K5-009/010) — 006-only columns, appended when present.
496
+ if (this.hasIgnoredColumn()) {
497
+ sql +=
498
+ ", m.ignored, m.superseded_by, m.feedback_positive, m.feedback_negative";
499
+ }
500
+ sql += `,
333
501
  bm25(memories_fts) AS score
334
502
  FROM memories_fts
335
503
  JOIN memories m ON m.rowid = memories_fts.rowid
336
504
  WHERE memories_fts MATCH ?
337
505
  AND (m.expires_at IS NULL OR m.expires_at > datetime('now'))`;
506
+ // v0.5.0 (K5-008 / plan §5.6) — ignored memories are excluded from
507
+ // retrieval (human verdict, D5-07). Guarded for pre-006 DBs.
508
+ if (this.hasIgnoredColumn()) {
509
+ sql += "\n AND m.ignored = 0";
510
+ }
338
511
  if (!includeSuperseded) {
339
512
  sql += "\n AND m.status = 'active'";
340
513
  }
@@ -354,6 +527,20 @@ export class MemoryService {
354
527
  const charBudget = maxTokens * 4;
355
528
  const scope = input.scope ?? "project";
356
529
  const includeSuperseded = input.includeSuperseded === true;
530
+ // v0.5.0 (K5-008 / plan §5.6, D5-10) — one clock per call and one
531
+ // read of the opt-in determinism flag. Retrieval then becomes a
532
+ // pure function of database state: recency decay is frozen at 1.0
533
+ // and the relevance bump is skipped regardless of the `bump`
534
+ // argument. The column is TEXT; compare against the string.
535
+ const now = input.now ?? new Date();
536
+ const deterministic = this.getSetting("deterministic_retrieval", "0") === "1";
537
+ // v0.5.0 (K5-008 / plan §5.6, D5-10) — DATE_NOW sentinel: in
538
+ // deterministic mode the wall clock is never read; every query sees
539
+ // the same fixed future instant, so ordering is a pure function of
540
+ // database content.
541
+ const clockMs = deterministic
542
+ ? new Date(DATE_NOW).getTime()
543
+ : now.getTime();
357
544
  let candidates;
358
545
  if (input.query && input.query.trim().length > 0) {
359
546
  candidates = this.queryRelevant(input.query, scope, includeSuperseded);
@@ -376,7 +563,7 @@ export class MemoryService {
376
563
  // v0.2.0 (K2-023) origin-aware rank: BM25 × origin-boost × recency-decay.
377
564
  // Tie-breakers preserve the v0.1.x spirit (errors/patterns before
378
565
  // context; newer before older when nothing else decides).
379
- candidates.sort((a, b) => rankCompare(a, b));
566
+ candidates.sort((a, b) => rankCompare(a, b, clockMs, deterministic));
380
567
  const result = [];
381
568
  let used = 0;
382
569
  for (const mem of candidates) {
@@ -386,7 +573,11 @@ export class MemoryService {
386
573
  result.push(mem);
387
574
  used += len;
388
575
  }
389
- if (result.length > 0) {
576
+ // v0.5.0 (K5-008 / D5-10) — the bump is part of the non-determinism
577
+ // this release makes optional: in deterministic mode it is skipped
578
+ // entirely so repeated queries return identical ranks and leave
579
+ // every relevance_score untouched.
580
+ if (result.length > 0 && !deterministic && input.bump !== false) {
390
581
  const bump = this.store.prepare("UPDATE memories SET relevance_score = MIN(?, relevance_score + ?) WHERE id = ?");
391
582
  this.store.transaction(() => {
392
583
  for (const m of result)
@@ -395,6 +586,20 @@ export class MemoryService {
395
586
  }
396
587
  return result;
397
588
  }
589
+ /**
590
+ * v0.4.0 (BUG-016) — apply the K2-023 relevance bump to a fixed slice
591
+ * of ids, exactly once. Lets ContextInjector probe without mutating
592
+ * and still bump the slice it actually injects.
593
+ */
594
+ bumpRelevance(ids) {
595
+ if (ids.length === 0)
596
+ return;
597
+ const bump = this.store.prepare("UPDATE memories SET relevance_score = MIN(?, relevance_score + ?) WHERE id = ?");
598
+ this.store.transaction(() => {
599
+ for (const id of ids)
600
+ bump.run(RELEVANCE_MAX, RELEVANCE_BUMP, id);
601
+ });
602
+ }
398
603
  /**
399
604
  * v0.3.0 (K3-004) — Promote an error memory to a causal pattern.
400
605
  *
@@ -404,14 +609,29 @@ export class MemoryService {
404
609
  * or null when the source error is not eligible (missing fingerprint,
405
610
  * wrong type, or already promoted).
406
611
  */
407
- promoteToPattern(errorId, evidenceCount) {
612
+ /**
613
+ * v0.4.0 (K4-009) — returns `{ id, created }` so callers can tell a
614
+ * newly-created pattern from an idempotent refresh.
615
+ */
616
+ promoteToPattern(errorId, evidenceCount, recurrenceCount = 0) {
408
617
  const error = this.getById(errorId);
409
618
  if (!error || error.type !== "error" || !error.fingerprint)
410
619
  return null;
411
- const confidence = Math.min(1.0, 0.5 + 0.1 * evidenceCount);
620
+ // v0.4.0 (K4-010) two-sided confidence: recurrence demotes the
621
+ // pattern's confidence.
622
+ const confidence = computeConfidence(evidenceCount, recurrenceCount);
412
623
  const now = new Date().toISOString();
413
624
  const summary = error.content.split("\n")[0].slice(0, 200);
414
- const content = `Causal pattern: ${summary}\n\nEvidence: ${evidenceCount} confirmed fix(es)\nConfidence: ${(confidence * 100).toFixed(0)}%\n\nOriginal: ${error.content.slice(0, 1000)}`;
625
+ const base = `Causal pattern: ${summary}\n\nEvidence: ${evidenceCount} confirmed fix(es)\nConfidence: ${(confidence * 100).toFixed(0)}%\n\nOriginal: ${error.content.slice(0, 1000)}`;
626
+ // v0.4.0 (K4-014) — deterministic "Fixed by:" from the linked
627
+ // success call's args_summary (D4-07). The opt-in LLM phrasing
628
+ // (K4-015) runs later in CausalChain.onSessionIdle, never here and
629
+ // never on the failure hot path.
630
+ const fixLine = deterministicFixLine({
631
+ content: base,
632
+ fixArgs: error.fixArgs ?? null,
633
+ });
634
+ const content = fixLine ? `${base}\n${fixLine}` : base;
415
635
  // v0.3.0 fix (bug #4) — idempotent promotion: the supersede model
416
636
  // only covers decision/rule, so the old code inserted a duplicate
417
637
  // pattern on every subsequent session.idle with a new fix. When an
@@ -424,23 +644,34 @@ export class MemoryService {
424
644
  AND origin = 'causal' AND status = 'active'
425
645
  ORDER BY created_at DESC LIMIT 1`)
426
646
  .get(error.fingerprint);
647
+ let patternId;
427
648
  if (existing) {
428
649
  this.update(existing.id, { content, evidenceCount, lastVerifiedAt: now });
429
- return existing.id;
430
- }
431
- return this.save({
432
- type: "pattern",
433
- content,
434
- scope: "project",
435
- origin: "causal",
436
- sourceTool: error.sourceTool ?? undefined,
437
- sourceSession: error.sourceSession ?? undefined,
438
- fingerprint: error.fingerprint,
439
- evidenceCount,
440
- lastVerifiedAt: now,
441
- status: "active",
442
- projectId: error.projectId ?? undefined,
443
- });
650
+ patternId = existing.id;
651
+ }
652
+ else {
653
+ patternId = this.save({
654
+ type: "pattern",
655
+ content,
656
+ scope: "project",
657
+ origin: "causal",
658
+ sourceTool: error.sourceTool ?? undefined,
659
+ sourceSession: error.sourceSession ?? undefined,
660
+ fingerprint: error.fingerprint,
661
+ evidenceCount,
662
+ lastVerifiedAt: now,
663
+ status: "active",
664
+ projectId: error.projectId ?? undefined,
665
+ });
666
+ }
667
+ // v0.4.0 (K4-010) — persist the recurrence count on the pattern row
668
+ // so mapRow (and kevin_why) recompute the SAME demoted confidence.
669
+ // v0.4.0 (K4-014) — persist fix_args too: the pattern's "Fixed by:"
670
+ // raw material travels with the row for kevin_why/HITL (K4-016/020).
671
+ this.store
672
+ .prepare("UPDATE memories SET recurrence_count = ?, fix_args = ? WHERE id = ?")
673
+ .run(recurrenceCount, error.fixArgs ?? null, patternId);
674
+ return { id: patternId, created: !existing };
444
675
  }
445
676
  /**
446
677
  * v0.2.0 (K2-026) — Feedback loop positive half (plan §B6.10 / D2-10).
@@ -541,9 +772,16 @@ export class MemoryService {
541
772
  AND (error_fingerprint = ? OR fingerprint = ?)
542
773
  AND (project_id IS ? OR (project_id IS NULL AND ? IS NULL))
543
774
  AND (? IS NULL OR id <> ?)`);
775
+ const settledCheck = this.store.prepare(`SELECT 1 FROM kevin_injections
776
+ WHERE session_id = ? AND memory_id = ? AND outcome = 'ineffective'
777
+ LIMIT 1`);
544
778
  const penalizeOne = this.store.prepare(`UPDATE memories
545
779
  SET relevance_score = MAX(0, relevance_score - ?),
546
- evidence_count = evidence_count + 1,
780
+ recurrence_count = recurrence_count + 1,
781
+ last_verified_at = datetime('now')
782
+ WHERE id = ?`);
783
+ const penalizeRelevanceOnly = this.store.prepare(`UPDATE memories
784
+ SET relevance_score = MAX(0, relevance_score - ?),
547
785
  last_verified_at = datetime('now')
548
786
  WHERE id = ?`);
549
787
  let penalized = 0;
@@ -553,9 +791,35 @@ export class MemoryService {
553
791
  const row = recurrenceCheck.get(sessionId, l.fingerprint, l.fingerprint, l.project_id, l.project_id, originCallId, originCallId);
554
792
  const c = row?.c ?? 0;
555
793
  if (c > 0) {
556
- penalizeOne.run(RELEVANCE_PENALTY, l.id);
794
+ // v0.4.0 (K4-025) — no double-charge: when the
795
+ // session's injection of this memory was already
796
+ // settled `ineffective`, `InjectionLedger.settle`
797
+ // charged recurrence_count (K4-007) and this pass
798
+ // only applies the relevance penalty. The +1 charge
799
+ // below is the pre-ledger path (K4-011) for memories
800
+ // that were never injected this session.
801
+ const settled = settledCheck.get(sessionId, l.id);
802
+ if (settled) {
803
+ penalizeRelevanceOnly.run(RELEVANCE_PENALTY, l.id);
804
+ }
805
+ else {
806
+ // v0.4.0 (K4-011) — recurrence is negative evidence:
807
+ // it bumps `recurrence_count`, NOT `evidence_count`
808
+ // (the old code counted recurrence as positive
809
+ // evidence). No `memories_superseded` increment here —
810
+ // supersede is only counted when a decision/rule is
811
+ // truly replaced (see save()).
812
+ penalizeOne.run(RELEVANCE_PENALTY, l.id);
813
+ // v0.4.0 (K4-025 / plan §5.1 rule 4, D4-06) — same
814
+ // recurrence-expels rule the settle enforces: at
815
+ // `recurrence_count >= 3` the error lesson is demoted
816
+ // to `status='stale'`.
817
+ this.store
818
+ .prepare(`UPDATE memories SET status = 'stale'
819
+ WHERE id = ? AND recurrence_count >= 3`)
820
+ .run(l.id);
821
+ }
557
822
  penalized += 1;
558
- this.metrics?.incr("memories_superseded", 1);
559
823
  }
560
824
  }
561
825
  });
@@ -617,19 +881,22 @@ export function countSupersedeCandidates(store, type, fingerprint, projectId) {
617
881
  .get(fingerprint, projectId, projectId);
618
882
  return row?.c ?? 0;
619
883
  }
620
- function rankScore(mem) {
884
+ function rankScore(mem, nowMs, deterministic) {
621
885
  // FTS5 bm25 returns a negative score (more negative = better match).
622
886
  // For non-FTS rows (loadAll path), fall back to -relevance_score so
623
887
  // higher-relevance memories also come first under the same sign convention.
624
888
  const rawScore = mem.metadata?.score;
625
889
  const base = typeof rawScore === "number" ? rawScore : -mem.relevanceScore;
626
- const ageDays = Math.max(0, (Date.now() - sqliteUtcToMs(mem.createdAt)) / 86_400_000);
627
- const recencyDecay = RECENCY_DECAY_PER_DAY ** ageDays;
890
+ const ageDays = Math.max(0, (nowMs - sqliteUtcToMs(mem.createdAt)) / 86_400_000);
891
+ // v0.5.0 (K5-008 / plan §5.6, D5-10) — deterministic retrieval freezes
892
+ // the recency factor at 1.0 so ordering depends only on content
893
+ // relevance and origin boost, never on the wall clock.
894
+ const recencyDecay = deterministic ? 1 : RECENCY_DECAY_PER_DAY ** ageDays;
628
895
  return base * originBoost(mem) * recencyDecay;
629
896
  }
630
- function rankCompare(a, b) {
631
- const ra = rankScore(a);
632
- const rb = rankScore(b);
897
+ function rankCompare(a, b, nowMs, deterministic) {
898
+ const ra = rankScore(a, nowMs, deterministic);
899
+ const rb = rankScore(b, nowMs, deterministic);
633
900
  if (ra !== rb)
634
901
  return ra - rb; // ascending: most negative (best) first
635
902
  if (TYPE_PRIORITY[a.type] !== TYPE_PRIORITY[b.type]) {