@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
@@ -3,8 +3,11 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { tool } from "@opencode-ai/plugin";
6
+ import { Archiver } from "./Archiver.js";
6
7
  import { CausalChain } from "./CausalChain.js";
7
8
  import { ContextInjector } from "./ContextInjector.js";
9
+ import { Feedback } from "./Feedback.js";
10
+ import { InjectionLedger } from "./InjectionLedger.js";
8
11
  import { MemoryService, } from "./MemoryService.js";
9
12
  import { Migrate } from "./Migrate.js";
10
13
  import { PatternMiner } from "./PatternMiner.js";
@@ -12,13 +15,30 @@ import { ERROR_LINE_RE, Reflector, STRONG_ERROR_RE } from "./Reflector.js";
12
15
  import { Retrospective } from "./Retrospective.js";
13
16
  import { Store } from "./Store.js";
14
17
  import { ToolCallObserver } from "./ToolCallObserver.js";
18
+ import { fingerprint } from "./fingerprint.js";
19
+ import { buildAudit } from "./kevin_audit.js";
15
20
  import { kevinWhy } from "./kevin_why.js";
16
- import { formatMemories } from "./memory-format.js";
17
- import { Metrics, estimateTokens } from "./metrics.js";
21
+ import { Metrics } from "./metrics.js";
18
22
  import { exportMarkdown, exportOkf } from "./okf-export.js";
19
23
  import { importOkf } from "./okf-import.js";
20
- const SYSTEM_TRANSFORM_TOKENS = 1500;
21
- const COMPACTING_TOKENS = 2000;
24
+ /**
25
+ * v0.4.0 (K4-021) — settings surfaced by `kevin_config` (plan §8.8).
26
+ * Unknown keys are rejected on `set` unless `strict: false`.
27
+ */
28
+ export const KEVIN_CONFIG_KEYS = [
29
+ "quality_gate_enabled",
30
+ "lesson_snippet_injection",
31
+ "patternminer_enabled",
32
+ "cross_project_enabled",
33
+ "llm_reflection_enabled",
34
+ "tool_calls_dedup_enabled",
35
+ // v0.5.0 (K5-003 / plan §8.13) — omitting these makes `kevin_config set`
36
+ // return { error: "unknown_key" } while `kevin_config list` still shows
37
+ // the keys seeded by migration 006.
38
+ "deterministic_retrieval",
39
+ "pre_prompt_budget_tokens",
40
+ "archive_after_days",
41
+ ];
22
42
  function resolveMigrationsDir() {
23
43
  const here = dirname(fileURLToPath(import.meta.url));
24
44
  return join(here, "..", "migrations");
@@ -35,6 +55,10 @@ export const KevinPlugin = async (input, options) => {
35
55
  const migrationsDir = opts.migrationsDir ?? resolveMigrationsDir();
36
56
  await new Migrate(store, migrationsDir).run();
37
57
  const metrics = new Metrics(store);
58
+ // v0.4.0 (K4-019): the plugin hooks expose no project field, so the
59
+ // project id is derived once from the plugin host's working directory
60
+ // (plan §5.7 fallback; D2-11 project scoping wired into the live path).
61
+ const projectId = fingerprint(process.cwd());
38
62
  const memoryService = new MemoryService(store, metrics);
39
63
  const observer = new ToolCallObserver(store, metrics);
40
64
  // v0.3.0 fix — Prepared UPDATE used by Reflector.onLinkError to stamp
@@ -53,14 +77,31 @@ export const KevinPlugin = async (input, options) => {
53
77
  linkErrorStmt.run(fp, callID);
54
78
  },
55
79
  }, metrics);
56
- const injector = new ContextInjector(memoryService, metrics);
80
+ // v0.4.0 (K4-017): the single injection path. The inline transform/
81
+ // compacting logic was removed; the ContextInjector now owns the whole
82
+ // pipeline (QualityGate admission + snippet payload + ledger rows),
83
+ // so ledger and injector share the same store.
84
+ const ledger = new InjectionLedger(store, metrics);
85
+ const injector = new ContextInjector(memoryService, metrics, ledger);
86
+ // v0.5.0 (K5-009/011 / plan §5.3) — human feedback component behind the
87
+ // `kevin_feedback` tool; 'ignore' verdicts also stamp memories.ignored.
88
+ const feedback = new Feedback(store, metrics);
89
+ // v0.5.0 (K5-012 / plan §5.4) — retires stale memories at session.idle.
90
+ const archiver = new Archiver(store, memoryService, metrics);
57
91
  const retrospective = new Retrospective(store, memoryService, {
58
92
  dir: opts.retrospectivesDir,
59
93
  }, metrics);
60
94
  const patternMiner = new PatternMiner(store, memoryService, metrics);
61
95
  const causalChain = new CausalChain(store, memoryService, metrics);
62
96
  let currentSessionId = null;
97
+ // BUG-011 — process-global last derived query. Cleared on
98
+ // `session.idle`; the per-session map below is the preferred source.
63
99
  let lastUserQuery = null;
100
+ // v0.4.0 (K4-018) — compaction may fire without a recent chat.message
101
+ // (auto-compact after a long tool turn, resumed sessions). Keep the
102
+ // last derived query PER SESSION so the compacting hook can always
103
+ // resolve a query for the session it runs in.
104
+ const lastUserQueryBySession = new Map();
64
105
  const pending = new Set();
65
106
  const toolCache = new Map();
66
107
  const TOOL_CACHE_MAX = 500;
@@ -105,6 +146,7 @@ export const KevinPlugin = async (input, options) => {
105
146
  errorType,
106
147
  sessionId: sessionID,
107
148
  callID,
149
+ projectId,
108
150
  }));
109
151
  }
110
152
  return {
@@ -172,6 +214,10 @@ export const KevinPlugin = async (input, options) => {
172
214
  type: args.type,
173
215
  limit: args.limit,
174
216
  full: args.full === true,
217
+ // BUG-001 — pass the flag through so the slim mapper
218
+ // can include the evidence fields (the old code cast
219
+ // SlimMemory rows to Memory and read undefined fields).
220
+ evidence: args.evidence === true,
175
221
  });
176
222
  const rows = args.full === true
177
223
  ? memories.map((m) => ({
@@ -180,7 +226,7 @@ export const KevinPlugin = async (input, options) => {
180
226
  content: m.content,
181
227
  scope: m.scope,
182
228
  }))
183
- : memories.map((m, i) => {
229
+ : memories.map((m) => {
184
230
  const base = {
185
231
  id: m.id,
186
232
  type: m.type,
@@ -189,12 +235,14 @@ export const KevinPlugin = async (input, options) => {
189
235
  snippet: m.snippet,
190
236
  };
191
237
  if (args.evidence === true) {
192
- const full = memories[i];
238
+ // SlimMemoryWithEvidence carried by the
239
+ // mapper, no per-row getById needed.
240
+ const ev = m;
193
241
  return {
194
242
  ...base,
195
- confidence: full.confidence ?? null,
196
- evidence_count: full.evidenceCount ?? null,
197
- last_verified_at: full.lastVerifiedAt ?? null,
243
+ confidence: ev.confidence ?? null,
244
+ evidence_count: ev.evidence_count ?? null,
245
+ last_verified_at: ev.last_verified_at ?? null,
198
246
  };
199
247
  }
200
248
  return base;
@@ -238,6 +286,15 @@ export const KevinPlugin = async (input, options) => {
238
286
  fingerprint: mem.fingerprint ?? null,
239
287
  origin: mem.origin ?? null,
240
288
  metadata: mem.metadata ?? null,
289
+ // BUG-010 — v0.3/v0.4 evidence fields so kevin_get
290
+ // is the full-fidelity read path (mapRow already
291
+ // computes confidence via computeConfidence).
292
+ confidence: mem.confidence ?? null,
293
+ evidence_count: mem.evidenceCount ?? null,
294
+ recurrence_count: mem.recurrenceCount ?? null,
295
+ last_verified_at: mem.lastVerifiedAt ?? null,
296
+ status: mem.status ?? "active",
297
+ fix_args: mem.fixArgs ?? null,
241
298
  }),
242
299
  };
243
300
  },
@@ -296,6 +353,45 @@ export const KevinPlugin = async (input, options) => {
296
353
  const memoriesAgent = byOrigin.agent ?? 0;
297
354
  const memoriesPattern = byOrigin.pattern ?? 0;
298
355
  const memoriesCausal = byOrigin.causal ?? 0;
356
+ // v0.4.0 (K4-024): per-origin recurrence_count totals
357
+ // (plan §8.8 — honest negative-evidence breakdown).
358
+ // Best-effort: DBs pre-005 lack the column; the rest of
359
+ // the block must still work (same pattern as
360
+ // ledger.settle in session.idle).
361
+ let recurrenceByOrigin = {};
362
+ try {
363
+ const recRows = store
364
+ .prepare(`SELECT origin, SUM(recurrence_count) as c
365
+ FROM memories
366
+ GROUP BY origin`)
367
+ .all();
368
+ for (const r of recRows) {
369
+ recurrenceByOrigin[r.origin ?? "agent"] = r.c;
370
+ }
371
+ }
372
+ catch {
373
+ recurrenceByOrigin = {};
374
+ }
375
+ // v0.5.0 (K5-021 / plan §5.7) — lifecycle counts; best-effort
376
+ // on pre-006 DBs (columns may be missing).
377
+ let memoriesIgnored = 0;
378
+ let memoriesArchived = 0;
379
+ try {
380
+ memoriesIgnored = store
381
+ .prepare("SELECT COUNT(*) as c FROM memories WHERE ignored = 1")
382
+ .get().c;
383
+ }
384
+ catch {
385
+ memoriesIgnored = 0;
386
+ }
387
+ try {
388
+ memoriesArchived = store
389
+ .prepare("SELECT COUNT(*) as c FROM memories WHERE status = 'archived'")
390
+ .get().c;
391
+ }
392
+ catch {
393
+ memoriesArchived = 0;
394
+ }
299
395
  return {
300
396
  title: "Estado de Kevin",
301
397
  output: JSON.stringify({
@@ -307,10 +403,48 @@ export const KevinPlugin = async (input, options) => {
307
403
  tool_calls: toolCallCount.c,
308
404
  retrospectives: retroCount.c,
309
405
  metrics: metrics.snapshot(),
406
+ // v0.4.0 (K4-008): precision block. The
407
+ // human-facing "patterns promoted" reading is
408
+ // `patterns_promoted_new` (K4-009 corrected the
409
+ // inflated `patterns_causal` counter; its key
410
+ // stays frozen in metrics for compatibility).
411
+ precision_rate: metrics.precisionRate(),
412
+ injections_total: metrics.get("injections_total"),
413
+ injections_effective: metrics.get("injections_effective"),
414
+ injections_ineffective: metrics.get("injections_ineffective"),
415
+ patterns_promoted_new: metrics.get("patterns_promoted_new"),
416
+ // v0.4.0 (K4-024): per-origin recurrence totals.
417
+ recurrence_by_origin: recurrenceByOrigin,
418
+ // v0.5.0 (K5-021 / plan §5.7) — glassbox fields.
419
+ injections_inconclusive: metrics.get("injections_inconclusive"),
420
+ coverage_rate: metrics.coverageRate(),
421
+ blocked: metrics.blockedSnapshot(),
422
+ memories_ignored: memoriesIgnored,
423
+ memories_archived: memoriesArchived,
424
+ feedback: {
425
+ positive: metrics.get("feedback_positive_total"),
426
+ negative: metrics.get("feedback_negative_total"),
427
+ },
310
428
  }),
311
429
  };
312
430
  },
313
431
  }),
432
+ kevin_audit: tool({
433
+ description: 'Auditoria de solo lectura de la DB (v0.5.0, glassbox): memorias por status/origin/type, salud de inyecciones (precision/coverage), contadores bloqueados, feedback por verdicto, tokens inyectados. Sin writes, sin LLM. En DBs pre-006 devuelve los bloques computables con "partial": true. verbose añade el bloque settings.',
434
+ args: {
435
+ verbose: tool.schema.boolean().default(false),
436
+ },
437
+ async execute(args) {
438
+ const report = buildAudit(store, metrics);
439
+ const payload = args.verbose
440
+ ? report
441
+ : { ...report, settings: undefined };
442
+ return {
443
+ title: "Auditoria de Kevin",
444
+ output: JSON.stringify(payload),
445
+ };
446
+ },
447
+ }),
314
448
  kevin_retrospective: tool({
315
449
  description: "Genera un retrospective markdown para una sesion (resume tools que fallaron y lecciones aprendidas).",
316
450
  args: {
@@ -361,6 +495,90 @@ export const KevinPlugin = async (input, options) => {
361
495
  };
362
496
  },
363
497
  }),
498
+ kevin_feedback: tool({
499
+ description: "Reporta juicio humano sobre una memoria (v0.5.0). verdict: useful | wrong | outdated | ignore. 'ignore' excluye la memoria de retrieval e inyeccion (D5-07); los demas ajustan confidence via computeConfidence (D5-02).",
500
+ args: {
501
+ memory_id: tool.schema.string().min(1),
502
+ verdict: tool.schema
503
+ .enum(["useful", "wrong", "outdated", "ignore"])
504
+ .describe("useful: la memoria ayudo. wrong: era incorrecta. outdated: ya no aplica. ignore: no volver a mostrarla."),
505
+ note: tool.schema.string().optional(),
506
+ session_id: tool.schema.string().optional(),
507
+ },
508
+ async execute(args) {
509
+ const memory = memoryService.getById(args.memory_id);
510
+ if (!memory) {
511
+ return {
512
+ title: "Feedback no registrado",
513
+ output: JSON.stringify({
514
+ message: `No memory found for id "${args.memory_id}".`,
515
+ }),
516
+ };
517
+ }
518
+ try {
519
+ const feedbackId = feedback.record({
520
+ memoryId: args.memory_id,
521
+ verdict: args.verdict,
522
+ sessionId: args.session_id ?? currentSessionId ?? null,
523
+ note: args.note,
524
+ });
525
+ const counts = feedback.countsFor(args.memory_id);
526
+ return {
527
+ title: "Feedback registrado",
528
+ output: JSON.stringify({
529
+ feedback_id: feedbackId,
530
+ memory_id: args.memory_id,
531
+ verdict: args.verdict,
532
+ counters: counts,
533
+ ignored: args.verdict === "ignore" ? true : memory.ignored === true,
534
+ }),
535
+ };
536
+ }
537
+ catch (err) {
538
+ return {
539
+ title: "Feedback no registrado",
540
+ output: JSON.stringify({
541
+ message: err instanceof Error ? err.message : String(err),
542
+ }),
543
+ };
544
+ }
545
+ },
546
+ }),
547
+ kevin_trace: tool({
548
+ description: "Predice que memorias se inyectarian en el prompt para una query (v0.5.0, dry-run estricto). Retorna el plan: items admitidos/bloqueados con su razon, tokens totales estimados. NO inyecta, NO mueve contadores, NO muta el seen-set, NO hace bump de relevancia (D5-08). Si omites query, usa la ultima query derivada de la sesion.",
549
+ args: {
550
+ query: tool.schema.string().optional(),
551
+ session_id: tool.schema.string().optional(),
552
+ tag: tool.schema
553
+ .enum(["context", "memory"])
554
+ .default("context")
555
+ .describe("context = pre-prompt (cap por setting pre_prompt_budget_tokens, default 900), memory = compacting (cap 2000)"),
556
+ cap: tool.schema.number().int().positive().optional(),
557
+ },
558
+ async execute(args) {
559
+ const sid = args.session_id ?? currentSessionId ?? "";
560
+ const query = args.query ??
561
+ (sid ? (lastUserQueryBySession.get(sid) ?? null) : null) ??
562
+ lastUserQuery;
563
+ if (!query) {
564
+ return {
565
+ title: "Trace sin query",
566
+ output: JSON.stringify({
567
+ message: "No query derivada disponible. Pasa query explicitamente.",
568
+ }),
569
+ };
570
+ }
571
+ const plan = injector.plan(query, {
572
+ tag: args.tag,
573
+ cap: args.cap,
574
+ sessionId: sid,
575
+ });
576
+ return {
577
+ title: "Plan de inyeccion (dry-run)",
578
+ output: JSON.stringify(plan),
579
+ };
580
+ },
581
+ }),
364
582
  kevin_export: tool({
365
583
  description: "Exporta memorias curadas (decision, rule, pattern) como markdown o formato OKF. v0.3.0.",
366
584
  args: {
@@ -392,6 +610,68 @@ export const KevinPlugin = async (input, options) => {
392
610
  };
393
611
  },
394
612
  }),
613
+ kevin_config: tool({
614
+ description: "Lee o modifica settings de Kevin sin SQL (v0.4.0). list: todas las settings de kevin_settings. set: upsert de una key conocida (rechaza keys desconocidas salvo strict:false).",
615
+ args: {
616
+ action: tool.schema.enum(["list", "set"]).describe("list | set"),
617
+ key: tool.schema
618
+ .string()
619
+ .optional()
620
+ .describe("Clave a modificar (action=set)."),
621
+ value: tool.schema
622
+ .string()
623
+ .optional()
624
+ .describe("Nuevo valor (action=set). Default '1'."),
625
+ strict: tool.schema
626
+ .boolean()
627
+ .default(true)
628
+ .optional()
629
+ .describe("Con strict=true (default) se rechazan keys desconocidas."),
630
+ },
631
+ async execute(args) {
632
+ if (args.action === "list") {
633
+ const rows = store
634
+ .prepare("SELECT key, value FROM kevin_settings ORDER BY key")
635
+ .all();
636
+ const settings = {};
637
+ for (const r of rows)
638
+ settings[r.key] = r.value;
639
+ return {
640
+ title: "Configuracion de Kevin",
641
+ output: JSON.stringify(settings),
642
+ };
643
+ }
644
+ if (!args.key) {
645
+ return {
646
+ title: "kevin_config",
647
+ output: JSON.stringify({
648
+ error: "missing_key",
649
+ action: "set",
650
+ }),
651
+ };
652
+ }
653
+ const known = KEVIN_CONFIG_KEYS.includes(args.key);
654
+ if (!known && args.strict !== false) {
655
+ return {
656
+ title: "kevin_config",
657
+ output: JSON.stringify({
658
+ error: "unknown_key",
659
+ key: args.key,
660
+ known_keys: [...KEVIN_CONFIG_KEYS],
661
+ }),
662
+ };
663
+ }
664
+ const value = args.value ?? "1";
665
+ store
666
+ .prepare(`INSERT INTO kevin_settings (key, value) VALUES (?, ?)
667
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
668
+ .run(args.key, value);
669
+ return {
670
+ title: "kevin_config",
671
+ output: JSON.stringify({ ok: true, key: args.key, value }),
672
+ };
673
+ },
674
+ }),
395
675
  },
396
676
  "tool.execute.before": async (hookInput, output) => {
397
677
  rememberToolCall(hookInput.callID, hookInput.tool, output.args);
@@ -400,6 +680,7 @@ export const KevinPlugin = async (input, options) => {
400
680
  args: output.args,
401
681
  sessionId: hookInput.sessionID,
402
682
  callID: hookInput.callID,
683
+ projectId,
403
684
  }, {});
404
685
  },
405
686
  "tool.execute.after": async (hookInput, output) => {
@@ -427,6 +708,7 @@ export const KevinPlugin = async (input, options) => {
427
708
  args: hookInput.args,
428
709
  sessionId: hookInput.sessionID,
429
710
  callID: hookInput.callID,
711
+ projectId,
430
712
  }, { success, stdout, stderr, exitCode });
431
713
  if (!success) {
432
714
  const errorType = observer.inferErrorType(stderr, stdout, exitCode);
@@ -439,13 +721,14 @@ export const KevinPlugin = async (input, options) => {
439
721
  errorType,
440
722
  sessionId: hookInput.sessionID,
441
723
  callID: hookInput.callID,
724
+ projectId,
442
725
  }));
443
726
  }
444
727
  else {
445
- causalChain.onSuccess(hookInput.tool, hookInput.args, null, hookInput.sessionID);
728
+ causalChain.onSuccess(hookInput.tool, hookInput.args, projectId, hookInput.sessionID);
446
729
  }
447
730
  },
448
- "chat.message": async (_hookInput, output) => {
731
+ "chat.message": async (hookInput, output) => {
449
732
  const text = output.parts
450
733
  .map((p) => p)
451
734
  .filter((p) => p.type === "text")
@@ -454,63 +737,104 @@ export const KevinPlugin = async (input, options) => {
454
737
  if (text.trim()) {
455
738
  const derived = injector.deriveQuery([{ role: "user", content: text }]);
456
739
  lastUserQuery = derived.length > 0 ? derived : null;
740
+ if (hookInput.sessionID && lastUserQuery) {
741
+ lastUserQueryBySession.set(hookInput.sessionID, lastUserQuery);
742
+ }
457
743
  }
458
744
  },
459
- "experimental.chat.system.transform": async (_hookInput, output) => {
460
- if (!lastUserQuery)
745
+ "experimental.chat.system.transform": async (hookInput, output) => {
746
+ // BUG-011 — prefer the per-session query so a new session whose
747
+ // first transform fires before any chat.message cannot reuse the
748
+ // previous session's query (the global is cleared on idle).
749
+ const query = lastUserQueryBySession.get(hookInput.sessionID ?? "") ?? lastUserQuery;
750
+ if (!query)
461
751
  return;
462
752
  const suggestion = injector.generateSuggestion();
463
753
  if (suggestion)
464
754
  output.system.push(suggestion);
465
- const memories = memoryService.getRelevant({
466
- query: lastUserQuery,
467
- maxTokens: SYSTEM_TRANSFORM_TOKENS,
468
- scope: "project",
469
- });
470
- const block = formatMemories(memories, "context");
471
- if (block) {
472
- output.system.push(block);
473
- metrics.incr("tokens_injected_pre_prompt", estimateTokens(block));
474
- }
755
+ injector.onSystemTransform({
756
+ sessionID: hookInput.sessionID ?? undefined,
757
+ messages: [{ role: "user", content: query }],
758
+ }, output);
475
759
  },
476
- "experimental.session.compacting": async (_hookInput, output) => {
477
- const query = lastUserQuery;
478
- if (!query)
479
- return;
760
+ "experimental.session.compacting": async (hookInput, output) => {
761
+ // v0.4.0 (K4-018) — plan §5.6: compaction often fires with no
762
+ // recent chat.message (auto-compact after a long tool turn,
763
+ // resumed sessions). Resolve a query per session first, then
764
+ // the global fallback, then any messages the runtime may
765
+ // provide (defensive — the current SDK contract only exposes
766
+ // sessionID).
767
+ // BUG-012 — the HITL suggestion fires AT MOST ONCE per session:
768
+ // whichever hook (transform or compacting) runs first consumes
769
+ // the pending recurrence signal (generateSuggestion resets it).
480
770
  const suggestion = injector.generateSuggestion();
481
771
  if (suggestion)
482
772
  output.context.push(suggestion);
483
- const memories = memoryService.getRelevant({
484
- query,
485
- maxTokens: COMPACTING_TOKENS,
486
- scope: "project",
487
- });
488
- const block = formatMemories(memories, "memory");
489
- if (block) {
490
- output.context.push(block);
491
- metrics.incr("tokens_injected_compacting", estimateTokens(block));
492
- }
773
+ const sid = hookInput.sessionID;
774
+ const sessionQuery = lastUserQueryBySession.get(sid) ?? lastUserQuery;
775
+ const runtimeMessages = hookInput
776
+ .messages;
777
+ const messages = sessionQuery != null
778
+ ? [{ role: "user", content: sessionQuery }]
779
+ : (runtimeMessages ?? []);
780
+ injector.onCompacting({
781
+ sessionID: sid,
782
+ messages,
783
+ }, output);
493
784
  },
494
785
  event: async ({ event }) => {
495
786
  const type = event.type;
496
787
  const props = event.properties ?? {};
497
788
  if (type === "session.created") {
498
789
  const info = props.info;
499
- if (info?.id)
790
+ if (info?.id) {
500
791
  currentSessionId = info.id;
792
+ // BUG-011 — a fresh session must not inherit the
793
+ // previous session's derived query (the global may
794
+ // still hold it if no idle fired).
795
+ lastUserQueryBySession.delete(info.id);
796
+ // v0.4.0 (K4-017) — plan §5.1 rule 3: the per-session
797
+ // seen-set resets when a session is created.
798
+ injector.onSessionCreated(info.id);
799
+ }
501
800
  }
502
801
  else if (type === "session.idle") {
503
802
  const sid = props.sessionID;
504
803
  if (sid) {
505
804
  toolCache.clear();
805
+ // BUG-011 — the session is done: drop the global query
806
+ // so the next session cannot reuse it.
807
+ lastUserQuery = null;
808
+ // v0.4.0 (K4-024) — plan §5.2: settle the session's
809
+ // unmeasured injections (effective/ineffective +
810
+ // recurrence_count charges) at idle. Best-effort: a
811
+ // legacy DB without migration 005 has no ledger table.
812
+ try {
813
+ ledger.settle(sid);
814
+ }
815
+ catch {
816
+ // best-effort: a legacy DB without the ledger
817
+ // table must not break the idle path
818
+ }
819
+ // v0.5.0 (K5-012 / plan §5.4) — retire stale lessons at
820
+ // idle; pre-006 DBs degrade to a no-op.
821
+ try {
822
+ archiver.run();
823
+ }
824
+ catch {
825
+ // best-effort, same pattern as ledger.settle
826
+ }
506
827
  fireAndForget(retrospective.generate(sid));
507
828
  memoryService.boostPositiveReflectors(sid);
508
829
  const recurred = memoryService.penalizeRecurringReflectors(sid);
509
- injector.setRecurrences(recurred);
510
- patternMiner.mine();
511
- fireAndForget(Promise.resolve().then(() => {
512
- causalChain.onSessionIdle(sid);
513
- }));
830
+ injector.setRecurrences(recurred, sid);
831
+ patternMiner.mine(projectId);
832
+ fireAndForget(Promise.resolve()
833
+ .then(() => causalChain.onSessionIdle(sid))
834
+ // non-blocking — promote is a best-effort pass
835
+ // (legacy DBs pre-005 lack the recurrence_count
836
+ // column)
837
+ .catch(() => { }));
514
838
  }
515
839
  metrics.flush();
516
840
  }