@jmtrin/opencode-kevin 0.9.0 → 1.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.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +575 -823
  3. package/dist/migrations/001_initial.sql +91 -91
  4. package/dist/migrations/002_indexes.sql +13 -13
  5. package/dist/migrations/003_v02_signal.sql +57 -57
  6. package/dist/migrations/004_v03_knowledge.sql +138 -138
  7. package/dist/migrations/005_v04_signal.sql +57 -57
  8. package/dist/migrations/006_v05_glassbox.sql +118 -118
  9. package/dist/migrations/007_v06_pull.sql +144 -144
  10. package/dist/migrations/011_v10_proven.sql +69 -0
  11. package/dist/migrations/012_v11_drift.sql +24 -0
  12. package/dist/plugin/Archiver.js +2 -18
  13. package/dist/plugin/ArtifactWriter.d.ts +25 -10
  14. package/dist/plugin/ArtifactWriter.js +43 -25
  15. package/dist/plugin/CausalChain.js +32 -14
  16. package/dist/plugin/ConflictDetector.js +7 -30
  17. package/dist/plugin/ContextInjector.js +0 -1
  18. package/dist/plugin/ConventionMiner.js +0 -1
  19. package/dist/plugin/Curator.js +0 -1
  20. package/dist/plugin/Feedback.js +2 -20
  21. package/dist/plugin/HookLiveness.d.ts +7 -0
  22. package/dist/plugin/HookLiveness.js +26 -28
  23. package/dist/plugin/InjectionLedger.js +104 -58
  24. package/dist/plugin/LessonFixer.js +0 -1
  25. package/dist/plugin/Materializer.js +1 -89
  26. package/dist/plugin/MemoryService.d.ts +59 -1
  27. package/dist/plugin/MemoryService.js +13 -107
  28. package/dist/plugin/Migrate.d.ts +1 -1
  29. package/dist/plugin/Migrate.js +30 -3
  30. package/dist/plugin/PatternMiner.js +0 -1
  31. package/dist/plugin/QualityGate.js +0 -1
  32. package/dist/plugin/Reflector.js +0 -1
  33. package/dist/plugin/RepoIdentity.js +0 -1
  34. package/dist/plugin/RepoTruth.js +0 -1
  35. package/dist/plugin/Retrospective.js +11 -1
  36. package/dist/plugin/SharedLayer.js +0 -1
  37. package/dist/plugin/Store.js +0 -1
  38. package/dist/plugin/ToolCallObserver.js +18 -6
  39. package/dist/plugin/capabilities.js +0 -1
  40. package/dist/plugin/columns.d.ts +11 -0
  41. package/dist/plugin/columns.js +54 -0
  42. package/dist/plugin/confidence.js +0 -1
  43. package/dist/plugin/contract.d.ts +54 -0
  44. package/dist/plugin/contract.js +383 -0
  45. package/dist/plugin/diff.js +0 -1
  46. package/dist/plugin/escape.d.ts +39 -0
  47. package/dist/plugin/escape.js +60 -0
  48. package/dist/plugin/fingerprint.js +0 -1
  49. package/dist/plugin/host.js +0 -1
  50. package/dist/plugin/index.d.ts +2 -2
  51. package/dist/plugin/index.js +448 -229
  52. package/dist/plugin/inferability.js +0 -1
  53. package/dist/plugin/kevin_approve.js +0 -1
  54. package/dist/plugin/kevin_audit.d.ts +31 -0
  55. package/dist/plugin/kevin_audit.js +60 -1
  56. package/dist/plugin/kevin_bench.d.ts +10 -0
  57. package/dist/plugin/kevin_bench.js +82 -0
  58. package/dist/plugin/kevin_conflicts.js +0 -1
  59. package/dist/plugin/kevin_contract.d.ts +8 -0
  60. package/dist/plugin/kevin_contract.js +59 -0
  61. package/dist/plugin/kevin_doctor.d.ts +5 -0
  62. package/dist/plugin/kevin_doctor.js +30 -2
  63. package/dist/plugin/kevin_facts.js +0 -1
  64. package/dist/plugin/kevin_forget.d.ts +33 -0
  65. package/dist/plugin/kevin_forget.js +260 -0
  66. package/dist/plugin/kevin_native.js +0 -1
  67. package/dist/plugin/kevin_propose.js +0 -1
  68. package/dist/plugin/kevin_publish.js +0 -1
  69. package/dist/plugin/kevin_why.js +1 -19
  70. package/dist/plugin/memory-format.js +0 -1
  71. package/dist/plugin/metrics.d.ts +1 -1
  72. package/dist/plugin/metrics.js +4 -1
  73. package/dist/plugin/native.js +0 -1
  74. package/dist/plugin/okf-export.js +0 -1
  75. package/dist/plugin/okf-import.js +0 -1
  76. package/dist/plugin/okf.js +0 -1
  77. package/dist/plugin/perf.d.ts +35 -0
  78. package/dist/plugin/perf.js +174 -0
  79. package/dist/plugin/query-tokenizer.js +56 -9
  80. package/dist/plugin/redact.js +0 -1
  81. package/dist/plugin/replay-types.js +0 -1
  82. package/dist/plugin/replay.js +0 -1
  83. package/dist/plugin/sqlite-adapter.js +0 -1
  84. package/dist/plugin/time-ms.d.ts +1 -0
  85. package/dist/plugin/time-ms.js +16 -0
  86. package/dist/plugin/uuid.js +0 -1
  87. package/package.json +22 -6
  88. package/dist/plugin/Archiver.js.map +0 -1
  89. package/dist/plugin/ArtifactWriter.js.map +0 -1
  90. package/dist/plugin/CausalChain.js.map +0 -1
  91. package/dist/plugin/ConflictDetector.js.map +0 -1
  92. package/dist/plugin/ContextInjector.js.map +0 -1
  93. package/dist/plugin/ConventionMiner.js.map +0 -1
  94. package/dist/plugin/Curator.js.map +0 -1
  95. package/dist/plugin/Feedback.js.map +0 -1
  96. package/dist/plugin/HookLiveness.js.map +0 -1
  97. package/dist/plugin/InjectionLedger.js.map +0 -1
  98. package/dist/plugin/LessonFixer.js.map +0 -1
  99. package/dist/plugin/Materializer.js.map +0 -1
  100. package/dist/plugin/MemoryService.js.map +0 -1
  101. package/dist/plugin/Migrate.js.map +0 -1
  102. package/dist/plugin/PatternMiner.js.map +0 -1
  103. package/dist/plugin/QualityGate.js.map +0 -1
  104. package/dist/plugin/Reflector.js.map +0 -1
  105. package/dist/plugin/RepoIdentity.js.map +0 -1
  106. package/dist/plugin/RepoTruth.js.map +0 -1
  107. package/dist/plugin/Retrospective.js.map +0 -1
  108. package/dist/plugin/SharedLayer.js.map +0 -1
  109. package/dist/plugin/Store.js.map +0 -1
  110. package/dist/plugin/ToolCallObserver.js.map +0 -1
  111. package/dist/plugin/capabilities.js.map +0 -1
  112. package/dist/plugin/confidence.js.map +0 -1
  113. package/dist/plugin/diff.js.map +0 -1
  114. package/dist/plugin/fingerprint.js.map +0 -1
  115. package/dist/plugin/host.js.map +0 -1
  116. package/dist/plugin/index.js.map +0 -1
  117. package/dist/plugin/inferability.js.map +0 -1
  118. package/dist/plugin/kevin_approve.js.map +0 -1
  119. package/dist/plugin/kevin_audit.js.map +0 -1
  120. package/dist/plugin/kevin_conflicts.js.map +0 -1
  121. package/dist/plugin/kevin_doctor.js.map +0 -1
  122. package/dist/plugin/kevin_facts.js.map +0 -1
  123. package/dist/plugin/kevin_native.js.map +0 -1
  124. package/dist/plugin/kevin_propose.js.map +0 -1
  125. package/dist/plugin/kevin_publish.js.map +0 -1
  126. package/dist/plugin/kevin_why.js.map +0 -1
  127. package/dist/plugin/memory-format.js.map +0 -1
  128. package/dist/plugin/metrics.js.map +0 -1
  129. package/dist/plugin/native.js.map +0 -1
  130. package/dist/plugin/okf-export.js.map +0 -1
  131. package/dist/plugin/okf-import.js.map +0 -1
  132. package/dist/plugin/okf.js.map +0 -1
  133. package/dist/plugin/query-tokenizer.js.map +0 -1
  134. package/dist/plugin/redact.js.map +0 -1
  135. package/dist/plugin/replay-types.js.map +0 -1
  136. package/dist/plugin/replay.js.map +0 -1
  137. package/dist/plugin/sqlite-adapter.js.map +0 -1
  138. package/dist/plugin/uuid.js.map +0 -1
  139. package/migrations/001_initial.sql +0 -92
  140. package/migrations/002_indexes.sql +0 -14
  141. package/migrations/003_v02_signal.sql +0 -58
  142. package/migrations/004_v03_knowledge.sql +0 -138
  143. package/migrations/005_v04_signal.sql +0 -57
  144. package/migrations/006_v05_glassbox.sql +0 -118
  145. package/migrations/007_v06_pull.sql +0 -145
  146. package/migrations/008_v07_truth.sql +0 -124
  147. package/migrations/009_v08_team.sql +0 -100
  148. package/migrations/010_v09_native.sql +0 -78
@@ -29,9 +29,12 @@ import { computeConfidence } from "./confidence.js";
29
29
  import { probeHost, summarize } from "./host.js";
30
30
  import { kevinApprove } from "./kevin_approve.js";
31
31
  import { buildAudit } from "./kevin_audit.js";
32
+ import { buildKevinBench } from "./kevin_bench.js";
32
33
  import { executeKevinConflicts } from "./kevin_conflicts.js";
34
+ import { buildKevinContract } from "./kevin_contract.js";
33
35
  import { buildDoctor } from "./kevin_doctor.js";
34
36
  import { buildKevinFacts } from "./kevin_facts.js";
37
+ import { handleForget } from "./kevin_forget.js";
35
38
  import { handleNative } from "./kevin_native.js";
36
39
  import { kevinPropose } from "./kevin_propose.js";
37
40
  import { kevinPublish } from "./kevin_publish.js";
@@ -40,6 +43,7 @@ import { Metrics } from "./metrics.js";
40
43
  import { attachNative } from "./native.js";
41
44
  import { exportMarkdown, exportOkf } from "./okf-export.js";
42
45
  import { importOkf } from "./okf-import.js";
46
+ import { Perf } from "./perf.js";
43
47
  import { uuidv7 } from "./uuid.js";
44
48
  // v0.6.0 (K6-019) — process-global set of reference topics already
45
49
  // registered with the host this process. Keeps re-registration idempotent
@@ -101,7 +105,15 @@ export const KEVIN_CONFIG_KEYS = [
101
105
  "native_registration_enabled",
102
106
  "host_probe_history_enabled",
103
107
  "dead_hook_report_threshold",
108
+ // v1.0.0 (K10-005 / plan §6) — the four keys seeded by migration 011
109
+ // section 4. Omitting these makes `kevin_config set` return
110
+ // { error: "unknown_key" } while `kevin_config list` still shows them.
111
+ "perf_enabled",
112
+ "perf_ring_capacity",
113
+ "perf_flush_on_idle",
114
+ "contract_report_enabled",
104
115
  ];
116
+ // v1.1.0 (K11-007 / D11-08) — no new settings in 1.1.0; thresholds are constants (D11-03)
105
117
  // v0.7.0 (K7-003 / plan §5.6, D7-12) — the explicit VALUE domain for
106
118
  // `error_lesson_mode`. The setting is TEXT and must be compared with
107
119
  // `=== "triage_only"`, never by truthiness; the domain here is enforced by
@@ -110,7 +122,7 @@ export const KEVIN_CONFIG_KEYS = [
110
122
  // behaviour on the next reflection.
111
123
  export const ERROR_LESSON_MODE_VALUES = ["all", "triage_only"];
112
124
  /** Plugin release version — stamped into generated files (K8-021/027). */
113
- export const KEVIN_VERSION = "0.9.0";
125
+ export const KEVIN_VERSION = "1.1.0";
114
126
  function resolveMigrationsDir() {
115
127
  const here = dirname(fileURLToPath(import.meta.url));
116
128
  return join(here, "..", "migrations");
@@ -292,6 +304,15 @@ export const KevinPlugin = async (input, options) => {
292
304
  thresholdText: memoryService.getSetting("dead_hook_report_threshold", "3"),
293
305
  pluginVersion: host.pluginVersion,
294
306
  });
307
+ // v1.0.0 (K10-012 / plan §5.2) — the performance instrument. Measures
308
+ // how long each hook holds the host into per-scope ring buffers that
309
+ // only reach the store at session.idle (D10-11: no perf_samples write
310
+ // anywhere else). perf_enabled uses the explicit === "1" TEXT
311
+ // comparison; the capacity string goes through the parse guard/clamp.
312
+ const perf = new Perf({
313
+ enabled: memoryService.getSetting("perf_enabled", "1") === "1",
314
+ capacity: memoryService.getSetting("perf_ring_capacity", "") ?? "",
315
+ });
295
316
  // v0.7.0 (K7-009 / plan §5.1, D7-13) — the repository truth scanner reads
296
317
  // the JSON project files. Runs once at init, gated by repo_truth_enabled.
297
318
  const projectRoot = opts.projectRoot ?? process.cwd();
@@ -451,6 +472,9 @@ export const KevinPlugin = async (input, options) => {
451
472
  }
452
473
  }
453
474
  let currentSessionId = null;
475
+ // v1.0.0 (K10-013 / D10-08) — set on the first tool completion of a
476
+ // session; consumed at idle to arm the deferred-dispose marker.
477
+ let sessionRecordedWork = false;
454
478
  // BUG-011 — process-global last derived query. Cleared on
455
479
  // `session.idle`; the per-session map below is the preferred source.
456
480
  let lastUserQuery = null;
@@ -848,8 +872,9 @@ export const KevinPlugin = async (input, options) => {
848
872
  // monotone across releases; K6-024 extends this
849
873
  // block with the remaining v0.6 fields. v0.8.0
850
874
  // (K8-025) — 18 v0.7 + kevin_project +
851
- // kevin_native = 23.
852
- tool_count: 23,
875
+ // kevin_native = 23. v1.0.0 (K10-018) — +kevin_contract +kevin_bench = 25.
876
+ // v1.1.0 (K11-007) — +kevin_forget = 26.
877
+ tool_count: 26,
853
878
  v07,
854
879
  memories_reflector: memoriesReflector,
855
880
  memories_agent: memoriesAgent,
@@ -1079,6 +1104,49 @@ export const KevinPlugin = async (input, options) => {
1079
1104
  };
1080
1105
  },
1081
1106
  }),
1107
+ // v1.0.0 (K10-018 / plan §5.6) — kevin_contract: the frozen
1108
+ // surface, inspectable at runtime rather than only at test
1109
+ // time. Read-only; summary by default, one clause's full value
1110
+ // with clause+format:'full'. Unknown clause ids are a
1111
+ // structured error, never a throw.
1112
+ kevin_contract: tool({
1113
+ description: "Contrato publico de Kevin (v1.0.0): version, digest y por clausula id/titulo/estabilidad/since/deprecacion. clause:'C-0N' con format:'full' retorna el valor completo de esa clausula (marcadores, tool names, settings keys, metric keys, entry points, schema, invariantes). Solo lectura, sin LLM ni red.",
1114
+ args: {
1115
+ clause: tool.schema
1116
+ .string()
1117
+ .optional()
1118
+ .describe("Id de clausula (ej. 'C-01'). Sin clause: resumen."),
1119
+ format: tool.schema
1120
+ .enum(["summary", "full"])
1121
+ .optional()
1122
+ .describe("summary (default): una linea por clausula. full: valores completos (requiere clause para una clausula; solo devuelve resumen enriquecido si se omite)."),
1123
+ },
1124
+ async execute(args) {
1125
+ const result = buildKevinContract({ packageVersion: KEVIN_VERSION }, args);
1126
+ return {
1127
+ title: "Contrato de Kevin",
1128
+ output: JSON.stringify(result),
1129
+ };
1130
+ },
1131
+ }),
1132
+ // v1.0.0 (K10-019 / plan §5.6) — kevin_bench: reports what
1133
+ // `npm run bench` recorded in bench_runs. It NEVER runs the
1134
+ // benchmark from inside a live session.
1135
+ kevin_bench: tool({
1136
+ description: "Resultados del benchmark de retrieval (v1.0.0). status: si hay corridas, digest del corpus mas reciente y si coincide con el corpus en disco. last: los cuatro brazos (none/recent-k/random-k/kevin) con precision@5, recall@5 y MRR de la ultima corrida. NUNCA ejecuta el benchmark — corre 'npm run bench' fuera de la sesion.",
1137
+ args: {
1138
+ action: tool.schema
1139
+ .enum(["status", "last"])
1140
+ .describe("status | last"),
1141
+ },
1142
+ async execute(args) {
1143
+ const result = buildKevinBench({ store }, args);
1144
+ return {
1145
+ title: "Benchmark de Kevin",
1146
+ output: JSON.stringify(result),
1147
+ };
1148
+ },
1149
+ }),
1082
1150
  kevin_retrospective: tool({
1083
1151
  description: "Genera un retrospective markdown para una sesion (resume tools que fallaron y lecciones aprendidas).",
1084
1152
  args: {
@@ -1396,6 +1464,24 @@ export const KevinPlugin = async (input, options) => {
1396
1464
  };
1397
1465
  },
1398
1466
  }),
1467
+ // v1.1.0 (K11-007 / plan §5.1, D11-02) — kevin_forget: closes the sharing lifecycle.
1468
+ kevin_forget: tool({
1469
+ description: "Olvida memorias y publica tombstones en la capa compartida (v1.1.0, K11-005/006 / plan §5.1): dry-run por defecto — sin confirm no muta nada y devuelve el plan (archived, tombstone planned); con confirm:true archiva localmente (status='archived') y, cuando la memoria proyecta a la capa compartida (layer='shared' o shared_entry_id), publica un tombstone via el unico write path (SharedLayer.applyExport, D8-08). Segunda invocacion identica reporta noop.",
1470
+ args: {
1471
+ ids: tool.schema.array(tool.schema.string()).min(1),
1472
+ confirm: tool.schema.boolean().optional(),
1473
+ },
1474
+ async execute(args) {
1475
+ const okfPath = join(projectRoot, memoryService.getSetting("okf_path", ".kevin/knowledge.okf"));
1476
+ const result = handleForget({ ids: args.ids, confirm: args.confirm }, { store, memoryService, sharedLayer, okfPath, metrics });
1477
+ return {
1478
+ title: result.dry_run
1479
+ ? "Plan de olvido (dry-run)"
1480
+ : "Olvido aplicado",
1481
+ output: JSON.stringify(result),
1482
+ };
1483
+ },
1484
+ }),
1399
1485
  kevin_approve: tool({
1400
1486
  description: "Aprueba o rechaza una propuesta de curacion (v0.6.0). reject: marca rejected, nada toca disco. approve: aplica el diff (unico call site de ArtifactWriter.apply, D6-01), marca applied y cura las memorias contribuyentes. Solo acepta propuestas pending.",
1401
1487
  args: {
@@ -1519,267 +1605,400 @@ export const KevinPlugin = async (input, options) => {
1519
1605
  }),
1520
1606
  },
1521
1607
  "tool.execute.before": async (hookInput, output) => {
1522
- rememberToolCall(hookInput.callID, hookInput.tool, output.args);
1523
- observer.onBefore({
1524
- tool: hookInput.tool,
1525
- args: output.args,
1526
- sessionId: hookInput.sessionID,
1527
- callID: hookInput.callID,
1528
- projectId,
1529
- }, {});
1608
+ // v1.0.0 (K10-012) — perf measures the synchronous hold time;
1609
+ // HookLiveness wraps the outside of this handler (compose,
1610
+ // never merge — D10-09).
1611
+ perf.measure("tool.execute.before", () => {
1612
+ rememberToolCall(hookInput.callID, hookInput.tool, output.args);
1613
+ observer.onBefore({
1614
+ tool: hookInput.tool,
1615
+ args: output.args,
1616
+ sessionId: hookInput.sessionID,
1617
+ callID: hookInput.callID,
1618
+ projectId,
1619
+ }, {});
1620
+ });
1530
1621
  },
1531
1622
  "tool.execute.after": async (hookInput, output) => {
1532
- // v0.9.0 (K9-010 / plan §5.3) checkpoint: the session reached a
1533
- // model turn, so the system prompt was assembled and
1534
- // `experimental.chat.system.transform` MUST have been offered.
1535
- // Deduped per session inside HookLiveness.expect.
1536
- if (hookInput.sessionID) {
1537
- liveness.expect("experimental.chat.system.transform", hookInput.sessionID);
1538
- }
1539
- const meta = (output.metadata ?? {});
1540
- const outputText = String(output.output ?? "");
1541
- const stderr = String(meta.stderr ?? "");
1542
- const stdout = String(meta.stdout ?? outputText);
1543
- const exitCode = pickExitCode(meta);
1544
- let success;
1545
- if (meta.success === false) {
1546
- success = false;
1547
- }
1548
- else if (exitCode !== undefined) {
1549
- success = exitCode === 0;
1550
- }
1551
- else if (stderr.length > 0 && ERROR_LINE_RE.test(stderr)) {
1552
- success = false;
1553
- }
1554
- else {
1555
- const stream = stdout.length > 0 ? stdout : outputText;
1556
- success = !(stream.length > 0 && STRONG_ERROR_RE.test(stream));
1557
- }
1558
- observer.onAfter({
1559
- tool: hookInput.tool,
1560
- args: hookInput.args,
1561
- sessionId: hookInput.sessionID,
1562
- callID: hookInput.callID,
1563
- projectId,
1564
- }, { success, stdout, stderr, exitCode });
1565
- if (!success) {
1566
- const errorType = observer.inferErrorType(stderr, stdout, exitCode);
1567
- fireAndForget(reflector.invoke({
1568
- toolName: hookInput.tool,
1569
- argsSummary: observer.summarizeArgs(hookInput.args),
1570
- stderr,
1571
- stdout,
1572
- exitCode,
1573
- errorType,
1623
+ perf.measure("tool.execute.after", () => {
1624
+ sessionRecordedWork = true;
1625
+ // v0.9.0 (K9-010 / plan §5.3) checkpoint: the session reached a
1626
+ // model turn, so the system prompt was assembled and
1627
+ // `experimental.chat.system.transform` MUST have been offered.
1628
+ // Deduped per session inside HookLiveness.expect.
1629
+ if (hookInput.sessionID) {
1630
+ liveness.expect("experimental.chat.system.transform", hookInput.sessionID);
1631
+ }
1632
+ const meta = (output.metadata ?? {});
1633
+ const outputText = String(output.output ?? "");
1634
+ const stderr = String(meta.stderr ?? "");
1635
+ const stdout = String(meta.stdout ?? outputText);
1636
+ const exitCode = pickExitCode(meta);
1637
+ let success;
1638
+ if (meta.success === false) {
1639
+ success = false;
1640
+ }
1641
+ else if (exitCode !== undefined) {
1642
+ success = exitCode === 0;
1643
+ }
1644
+ else if (stderr.length > 0 && ERROR_LINE_RE.test(stderr)) {
1645
+ success = false;
1646
+ }
1647
+ else {
1648
+ const stream = stdout.length > 0 ? stdout : outputText;
1649
+ success = !(stream.length > 0 && STRONG_ERROR_RE.test(stream));
1650
+ }
1651
+ observer.onAfter({
1652
+ tool: hookInput.tool,
1653
+ args: hookInput.args,
1574
1654
  sessionId: hookInput.sessionID,
1575
1655
  callID: hookInput.callID,
1576
1656
  projectId,
1577
- }));
1578
- }
1579
- else {
1580
- causalChain.onSuccess(hookInput.tool, hookInput.args, projectId, hookInput.sessionID);
1581
- }
1657
+ }, { success, stdout, stderr, exitCode });
1658
+ if (!success) {
1659
+ const errorType = observer.inferErrorType(stderr, stdout, exitCode);
1660
+ fireAndForget(reflector.invoke({
1661
+ toolName: hookInput.tool,
1662
+ argsSummary: observer.summarizeArgs(hookInput.args),
1663
+ stderr,
1664
+ stdout,
1665
+ exitCode,
1666
+ errorType,
1667
+ sessionId: hookInput.sessionID,
1668
+ callID: hookInput.callID,
1669
+ projectId,
1670
+ }));
1671
+ }
1672
+ else {
1673
+ causalChain.onSuccess(hookInput.tool, hookInput.args, projectId, hookInput.sessionID);
1674
+ }
1675
+ });
1582
1676
  },
1583
1677
  "chat.message": async (hookInput, output) => {
1584
- const text = output.parts
1585
- .map((p) => p)
1586
- .filter((p) => p.type === "text")
1587
- .map((p) => p.text ?? "")
1588
- .join(" ");
1589
- if (text.trim()) {
1590
- const derived = injector.deriveQuery([{ role: "user", content: text }]);
1591
- lastUserQuery = derived.length > 0 ? derived : null;
1592
- if (hookInput.sessionID && lastUserQuery) {
1593
- lastUserQueryBySession.set(hookInput.sessionID, lastUserQuery);
1678
+ perf.measure("chat.message", () => {
1679
+ const text = output.parts
1680
+ .map((p) => p)
1681
+ .filter((p) => p.type === "text")
1682
+ .map((p) => p.text ?? "")
1683
+ .join(" ");
1684
+ if (text.trim()) {
1685
+ const derived = injector.deriveQuery([
1686
+ { role: "user", content: text },
1687
+ ]);
1688
+ lastUserQuery = derived.length > 0 ? derived : null;
1689
+ if (hookInput.sessionID && lastUserQuery) {
1690
+ lastUserQueryBySession.set(hookInput.sessionID, lastUserQuery);
1691
+ }
1594
1692
  }
1595
- }
1693
+ });
1596
1694
  },
1597
1695
  "experimental.chat.system.transform": async (hookInput, output) => {
1598
- // BUG-011 prefer the per-session query so a new session whose
1599
- // first transform fires before any chat.message cannot reuse the
1600
- // previous session's query (the global is cleared on idle).
1601
- const query = lastUserQueryBySession.get(hookInput.sessionID ?? "") ?? lastUserQuery;
1602
- if (!query)
1603
- return;
1604
- const suggestion = injector.generateSuggestion();
1605
- if (suggestion)
1606
- output.system.push(suggestion);
1607
- injector.onSystemTransform({
1608
- sessionID: hookInput.sessionID ?? undefined,
1609
- messages: [{ role: "user", content: query }],
1610
- }, output);
1696
+ perf.measure("chat.system.transform", () => {
1697
+ // BUG-011 prefer the per-session query so a new session whose
1698
+ // first transform fires before any chat.message cannot reuse the
1699
+ // previous session's query (the global is cleared on idle).
1700
+ const query = lastUserQueryBySession.get(hookInput.sessionID ?? "") ??
1701
+ lastUserQuery;
1702
+ if (!query)
1703
+ return;
1704
+ const suggestion = injector.generateSuggestion();
1705
+ if (suggestion)
1706
+ output.system.push(suggestion);
1707
+ injector.onSystemTransform({
1708
+ sessionID: hookInput.sessionID ?? undefined,
1709
+ messages: [{ role: "user", content: query }],
1710
+ }, output);
1711
+ });
1611
1712
  },
1612
1713
  "experimental.session.compacting": async (hookInput, output) => {
1613
- // v0.4.0 (K4-018) plan §5.6: compaction often fires with no
1614
- // recent chat.message (auto-compact after a long tool turn,
1615
- // resumed sessions). Resolve a query per session first, then
1616
- // the global fallback, then any messages the runtime may
1617
- // provide (defensive the current SDK contract only exposes
1618
- // sessionID).
1619
- // BUG-012 — the HITL suggestion fires AT MOST ONCE per session:
1620
- // whichever hook (transform or compacting) runs first consumes
1621
- // the pending recurrence signal (generateSuggestion resets it).
1622
- const suggestion = injector.generateSuggestion();
1623
- if (suggestion)
1624
- output.context.push(suggestion);
1625
- const sid = hookInput.sessionID;
1626
- const sessionQuery = lastUserQueryBySession.get(sid) ?? lastUserQuery;
1627
- const runtimeMessages = hookInput
1628
- .messages;
1629
- const messages = sessionQuery != null
1630
- ? [{ role: "user", content: sessionQuery }]
1631
- : (runtimeMessages ?? []);
1632
- injector.onCompacting({
1633
- sessionID: sid,
1634
- messages,
1635
- }, output);
1714
+ perf.measure("session.compacting", () => {
1715
+ // v0.4.0 (K4-018) plan §5.6: compaction often fires with no
1716
+ // recent chat.message (auto-compact after a long tool turn,
1717
+ // resumed sessions). Resolve a query per session first, then
1718
+ // the global fallback, then any messages the runtime may
1719
+ // provide (defensive — the current SDK contract only exposes
1720
+ // sessionID).
1721
+ // BUG-012 the HITL suggestion fires AT MOST ONCE per session:
1722
+ // whichever hook (transform or compacting) runs first consumes
1723
+ // the pending recurrence signal (generateSuggestion resets it).
1724
+ const suggestion = injector.generateSuggestion();
1725
+ if (suggestion)
1726
+ output.context.push(suggestion);
1727
+ const sid = hookInput.sessionID;
1728
+ const sessionQuery = lastUserQueryBySession.get(sid) ?? lastUserQuery;
1729
+ const runtimeMessages = hookInput
1730
+ .messages;
1731
+ const messages = sessionQuery != null
1732
+ ? [{ role: "user", content: sessionQuery }]
1733
+ : (runtimeMessages ?? []);
1734
+ injector.onCompacting({
1735
+ sessionID: sid,
1736
+ messages,
1737
+ }, output);
1738
+ });
1636
1739
  },
1637
1740
  event: async ({ event }) => {
1638
1741
  const type = event.type;
1639
1742
  const props = event.properties ?? {};
1640
- if (type === "session.created") {
1641
- const info = props.info;
1642
- if (info?.id) {
1643
- currentSessionId = info.id;
1644
- // BUG-011 a fresh session must not inherit the
1645
- // previous session's derived query (the global may
1646
- // still hold it if no idle fired).
1647
- lastUserQueryBySession.delete(info.id);
1648
- // v0.4.0 (K4-017) — plan §5.1 rule 3: the per-session
1649
- // seen-set resets when a session is created.
1650
- injector.onSessionCreated(info.id);
1651
- }
1652
- }
1653
- else if (type === "session.idle") {
1743
+ if (type === "session.idle") {
1744
+ // v1.0.0 (K10-012) — the idle branch measures under
1745
+ // "session.idle" (150/600 ms budget); every other branch
1746
+ // measures under "event" (5/25 ms). Recording idle's ~150 ms
1747
+ // under "event" would make that budget permanently breached
1748
+ // and therefore ignored.
1654
1749
  const sid = props.sessionID;
1655
- if (sid) {
1656
- toolCache.clear();
1657
- // BUG-011 — the session is done: drop the global query
1658
- // so the next session cannot reuse it.
1659
- lastUserQuery = null;
1660
- // v0.4.0 (K4-024) — plan §5.2: settle the session's
1661
- // unmeasured injections (effective/ineffective +
1662
- // recurrence_count charges) at idle. Best-effort: a
1663
- // legacy DB without migration 005 has no ledger table.
1664
- try {
1665
- ledger.settle(sid);
1666
- }
1667
- catch {
1668
- // best-effort: a legacy DB without the ledger
1669
- // table must not break the idle path
1670
- }
1671
- // v0.5.0 (K5-012 / plan §5.4) — retire stale lessons at
1672
- // idle; pre-006 DBs degrade to a no-op.
1673
- try {
1674
- archiver.run();
1675
- }
1676
- catch {
1677
- // best-effort, same pattern as ledger.settle
1678
- }
1679
- fireAndForget(retrospective.generate(sid));
1680
- memoryService.boostPositiveReflectors(sid);
1681
- const recurred = memoryService.penalizeRecurringReflectors(sid);
1682
- injector.setRecurrences(recurred, sid);
1683
- patternMiner.mine(projectId);
1684
- // v0.7.0 (K7-012 / plan §5.4, D7-10) — convention mining is
1685
- // session.idle-only, default-off, and isolated from the rest of
1686
- // the idle chain. Mined rules still enter the ordinary Curator
1687
- // approval path; this step never writes to the repository.
1688
- try {
1689
- if (memoryService.getSetting("convention_mining_enabled", "0") === "1") {
1690
- const conventions = conventionMiner.mine();
1691
- conventionMiner.emit(conventions);
1750
+ await perf.measureAsync("session.idle", async () => {
1751
+ if (sid) {
1752
+ toolCache.clear();
1753
+ // BUG-011 the session is done: drop the global query
1754
+ // so the next session cannot reuse it.
1755
+ lastUserQuery = null;
1756
+ // v0.4.0 (K4-024) — plan §5.2: settle the session's
1757
+ // unmeasured injections (effective/ineffective +
1758
+ // recurrence_count charges) at idle. Best-effort: a
1759
+ // legacy DB without migration 005 has no ledger table.
1760
+ try {
1761
+ ledger.settle(sid);
1762
+ }
1763
+ catch {
1764
+ // best-effort: a legacy DB without the ledger
1765
+ // table must not break the idle path
1766
+ }
1767
+ // v0.5.0 (K5-012 / plan §5.4) retire stale lessons at
1768
+ // idle; pre-006 DBs degrade to a no-op.
1769
+ try {
1770
+ archiver.run();
1771
+ }
1772
+ catch {
1773
+ // best-effort, same pattern as ledger.settle
1774
+ }
1775
+ fireAndForget(retrospective.generate(sid));
1776
+ memoryService.boostPositiveReflectors(sid);
1777
+ const recurred = memoryService.penalizeRecurringReflectors(sid);
1778
+ injector.setRecurrences(recurred, sid);
1779
+ patternMiner.mine(projectId);
1780
+ // v0.7.0 (K7-012 / plan §5.4, D7-10) convention mining is
1781
+ // session.idle-only, default-off, and isolated from the rest of
1782
+ // the idle chain. Mined rules still enter the ordinary Curator
1783
+ // approval path; this step never writes to the repository.
1784
+ try {
1785
+ if (memoryService.getSetting("convention_mining_enabled", "0") ===
1786
+ "1") {
1787
+ const conventions = conventionMiner.mine();
1788
+ conventionMiner.emit(conventions);
1789
+ }
1790
+ }
1791
+ catch {
1792
+ // A throwing miner must not reject or truncate the idle chain.
1793
+ }
1794
+ // v0.7.0 (K7-016 / plan §5.5, D7-06) — conflict detection is
1795
+ // surfacing-only on idle. It may create/open conflict rows, but
1796
+ // no idle path can acknowledge or resolve one.
1797
+ try {
1798
+ if (memoryService.getSetting("conflict_detection_enabled", "0") ===
1799
+ "1") {
1800
+ conflictDetector.detect();
1801
+ }
1802
+ }
1803
+ catch {
1804
+ // Conflict surfacing is best-effort and must not reject idle.
1805
+ }
1806
+ fireAndForget(Promise.resolve()
1807
+ .then(() => causalChain.onSessionIdle(sid))
1808
+ // non-blocking — promote is a best-effort pass
1809
+ // (legacy DBs pre-005 lack the recurrence_count
1810
+ // column)
1811
+ .catch(() => { }));
1812
+ // v0.6.0 (K6-015 / plan §8.14) — session-idle curation
1813
+ // generation. Dry-run only: `propose()` calls `plan()`
1814
+ // and never `apply()`, so nothing here can touch disk
1815
+ // (D6-01). Guarded by curation_enabled (TEXT compare)
1816
+ // and a 1-hour throttle persisted in kevin_settings.
1817
+ try {
1818
+ if (memoryService.getSetting("curation_enabled", "1") === "1") {
1819
+ const CURATION_THROTTLE_MS = 3_600_000;
1820
+ const last = memoryService.getSetting("last_curation_at", "");
1821
+ if (last === "" ||
1822
+ Date.now() - Date.parse(last) > CURATION_THROTTLE_MS) {
1823
+ curator.propose("agents_md", writer);
1824
+ store
1825
+ .prepare(`INSERT INTO kevin_settings (key, value) VALUES (?, ?)
1826
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
1827
+ .run("last_curation_at", new Date().toISOString());
1828
+ }
1829
+ }
1830
+ }
1831
+ catch {
1832
+ // best-effort, same pattern as ledger.settle — a
1833
+ // curation failure must not break the idle path
1834
+ }
1835
+ // v0.8.0 (K8-022 / plan §5.5) — the shared layer
1836
+ // re-read at idle. Gated by shared_layer_enabled ===
1837
+ // "1", a TEXT comparison: '0' is a truthy string, so a
1838
+ // truthiness check would turn the release on for every
1839
+ // installation that upgrades. Never wired into
1840
+ // tool.execute.*, chat.message, system.transform or
1841
+ // session.compacting — the hot-path rule is absolute;
1842
+ // the file-hash skip is what makes the idle cost one
1843
+ // read plus one hash on an unchanged repository.
1844
+ try {
1845
+ if (memoryService.getSetting("shared_layer_enabled", "0") === "1") {
1846
+ syncSharedLayer();
1847
+ }
1848
+ }
1849
+ catch {
1850
+ // best-effort, same pattern as ledger.settle — a
1851
+ // sync failure must not break the idle path
1852
+ }
1853
+ // v1.0.0 (K10-013 / D10-08) — the session recorded work:
1854
+ // arm the deferred dispose settlement. The ISO timestamp
1855
+ // lets the next session.start compare it against
1856
+ // hook_liveness.last_seen_at for dispose.
1857
+ if (sessionRecordedWork) {
1858
+ try {
1859
+ store
1860
+ .prepare("INSERT INTO kevin_settings (key, value) VALUES ('last_session_recorded_work', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
1861
+ .run(new Date().toISOString());
1862
+ }
1863
+ catch {
1864
+ // best-effort: arming must not break idle
1865
+ }
1866
+ sessionRecordedWork = false;
1692
1867
  }
1693
1868
  }
1694
- catch {
1695
- // A throwing miner must not reject or truncate the idle chain.
1696
- }
1697
- // v0.7.0 (K7-016 / plan §5.5, D7-06) conflict detection is
1698
- // surfacing-only on idle. It may create/open conflict rows, but
1699
- // no idle path can acknowledge or resolve one.
1869
+ });
1870
+ metrics.flush();
1871
+ // v1.0.0 (K10-012 / plan §5.2, D10-11) — the idle perf_samples
1872
+ // write (the dispose hook holds the other one), gated by
1873
+ // perf_flush_on_idle (TEXT compare; the fallback matches the
1874
+ // migration-seeded default '1').
1875
+ if (memoryService.getSetting("perf_flush_on_idle", "1") === "1") {
1700
1876
  try {
1701
- if (memoryService.getSetting("conflict_detection_enabled", "0") ===
1702
- "1") {
1703
- conflictDetector.detect();
1704
- }
1877
+ perf.flush(store);
1705
1878
  }
1706
1879
  catch {
1707
- // Conflict surfacing is best-effort and must not reject idle.
1880
+ // best-effort: pre-011 DBs have no perf_samples table
1708
1881
  }
1709
- fireAndForget(Promise.resolve()
1710
- .then(() => causalChain.onSessionIdle(sid))
1711
- // non-blocking — promote is a best-effort pass
1712
- // (legacy DBs pre-005 lack the recurrence_count
1713
- // column)
1714
- .catch(() => { }));
1715
- // v0.6.0 (K6-015 / plan §8.14) — session-idle curation
1716
- // generation. Dry-run only: `propose()` calls `plan()`
1717
- // and never `apply()`, so nothing here can touch disk
1718
- // (D6-01). Guarded by curation_enabled (TEXT compare)
1719
- // and a 1-hour throttle persisted in kevin_settings.
1720
- try {
1721
- if (memoryService.getSetting("curation_enabled", "1") === "1") {
1722
- const CURATION_THROTTLE_MS = 3_600_000;
1723
- const last = memoryService.getSetting("last_curation_at", "");
1724
- if (last === "" ||
1725
- Date.now() - Date.parse(last) > CURATION_THROTTLE_MS) {
1726
- curator.propose("agents_md", writer);
1882
+ }
1883
+ liveness.flush();
1884
+ return;
1885
+ }
1886
+ perf.measure("event", () => {
1887
+ if (type === "session.created") {
1888
+ const info = props.info;
1889
+ if (info?.id) {
1890
+ currentSessionId = info.id;
1891
+ // BUG-011 a fresh session must not inherit the
1892
+ // previous session's derived query (the global may
1893
+ // still hold it if no idle fired).
1894
+ lastUserQueryBySession.delete(info.id);
1895
+ // v0.4.0 (K4-017) — plan §5.1 rule 3: the per-session
1896
+ // seen-set resets when a session is created.
1897
+ injector.onSessionCreated(info.id);
1898
+ // v1.0.0 (K10-013 / plan §5.3, D10-08) deferred
1899
+ // dispose settlement. The previous session recorded
1900
+ // work but the process never came back through
1901
+ // `dispose` (crash or hard kill): settle it HERE,
1902
+ // at the start of the next session, because dispose
1903
+ // cannot be settled within the session that observes
1904
+ // it. One settlement per work marker; a first-ever
1905
+ // run has no marker and never reports dead. The
1906
+ // threshold semantics stay with expect(): unknown
1907
+ // until expected_count crosses the threshold.
1908
+ try {
1909
+ const marker = memoryService.getSetting("last_session_recorded_work", "");
1910
+ if (marker !== "") {
1911
+ let fired = true;
1912
+ try {
1913
+ const row = store
1914
+ .prepare("SELECT last_seen_at FROM hook_liveness WHERE hook = 'dispose'")
1915
+ .get();
1916
+ const last = row?.last_seen_at ?? null;
1917
+ fired = last !== null && last > marker;
1918
+ }
1919
+ catch {
1920
+ // pre-010 DB without hook_liveness: treat as
1921
+ // no evidence of a fire, same as a crash.
1922
+ fired = false;
1923
+ }
1924
+ if (!fired) {
1925
+ liveness.expect("dispose", `miss:${marker}`);
1926
+ store
1927
+ .prepare("UPDATE kevin_metrics SET value = value + 1 WHERE key = 'dispose_misses_total'")
1928
+ .run();
1929
+ }
1727
1930
  store
1728
- .prepare(`INSERT INTO kevin_settings (key, value) VALUES (?, ?)
1729
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
1730
- .run("last_curation_at", new Date().toISOString());
1931
+ .prepare("UPDATE kevin_settings SET value = '' WHERE key = 'last_session_recorded_work'")
1932
+ .run();
1731
1933
  }
1732
1934
  }
1935
+ catch {
1936
+ // best-effort: settlement must not break session start
1937
+ }
1733
1938
  }
1734
- catch {
1735
- // best-effort, same pattern as ledger.settle — a
1736
- // curation failure must not break the idle path
1939
+ }
1940
+ else if (type === "session.next.tool.failed") {
1941
+ const callID = props.callID;
1942
+ const sessionID = props.sessionID;
1943
+ const error = props.error;
1944
+ if (callID && sessionID && error?.message) {
1945
+ handleToolFailed(callID, sessionID, error.message);
1737
1946
  }
1738
- // v0.8.0 (K8-022 / plan §5.5) — the shared layer
1739
- // re-read at idle. Gated by shared_layer_enabled ===
1740
- // "1", a TEXT comparison: '0' is a truthy string, so a
1741
- // truthiness check would turn the release on for every
1742
- // installation that upgrades. Never wired into
1743
- // tool.execute.*, chat.message, system.transform or
1744
- // session.compacting — the hot-path rule is absolute;
1745
- // the file-hash skip is what makes the idle cost one
1746
- // read plus one hash on an unchanged repository.
1947
+ }
1948
+ else if (type === "session.next.tool.success") {
1949
+ const callID = props.callID;
1950
+ if (callID)
1951
+ toolCache.delete(callID);
1952
+ }
1953
+ });
1954
+ },
1955
+ dispose: async () => {
1956
+ // v1.0.0 (K10-013 / plan §5.3) — the seventh instrumented hook.
1957
+ // HookLiveness.wrap (outside) records the fire only if the
1958
+ // delegate completes; Perf.measureAsync (inside) times it even
1959
+ // on throw.
1960
+ try {
1961
+ await perf.measureAsync("dispose", async () => {
1747
1962
  try {
1748
- if (memoryService.getSetting("shared_layer_enabled", "0") === "1") {
1749
- syncSharedLayer();
1750
- }
1963
+ await Promise.allSettled([...pending]);
1751
1964
  }
1752
- catch {
1753
- // best-effort, same pattern as ledger.settle a
1754
- // sync failure must not break the idle path
1965
+ finally {
1966
+ // v1.0.0 (K10-013) record the fire and persist it now:
1967
+ // this is the last write of the process.
1968
+ liveness.recordDispose();
1969
+ // v1.0.0 (K10-013) — record the successful fire and
1970
+ // disarm any pending deferred-settlement marker: a
1971
+ // clean dispose is exactly what the next session's
1972
+ // settlement check looks for.
1973
+ store
1974
+ .prepare("UPDATE kevin_metrics SET value = value + 1 WHERE key = 'dispose_fires_total'")
1975
+ .run();
1976
+ try {
1977
+ store
1978
+ .prepare("UPDATE kevin_settings SET value = '' WHERE key = 'last_session_recorded_work'")
1979
+ .run();
1980
+ }
1981
+ catch {
1982
+ // best-effort: legacy DBs without kevin_settings
1983
+ }
1755
1984
  }
1756
- }
1757
- metrics.flush();
1758
- // v0.9.0 (K9-009 / plan §5.3) — liveness counters persist on
1759
- // the same cadence as the metrics.
1760
- liveness.flush();
1985
+ });
1761
1986
  }
1762
- else if (type === "session.next.tool.failed") {
1763
- const callID = props.callID;
1764
- const sessionID = props.sessionID;
1765
- const error = props.error;
1766
- if (callID && sessionID && error?.message) {
1767
- handleToolFailed(callID, sessionID, error.message);
1987
+ finally {
1988
+ // v1.0.0 review fix — persist the final period (including this
1989
+ // dispose sample, recorded by measureAsync above) BEFORE the
1990
+ // closes; otherwise the dispose budget could never be verified
1991
+ // by bench:check because its samples died with the ring.
1992
+ try {
1993
+ perf.flush(store);
1768
1994
  }
1995
+ catch {
1996
+ // best-effort: pre-011 DBs have no perf_samples table
1997
+ }
1998
+ metrics.close();
1999
+ store.close();
1769
2000
  }
1770
- else if (type === "session.next.tool.success") {
1771
- const callID = props.callID;
1772
- if (callID)
1773
- toolCache.delete(callID);
1774
- }
1775
- },
1776
- dispose: async () => {
1777
- await Promise.allSettled([...pending]);
1778
- liveness.flush();
1779
- metrics.close();
1780
- store.close();
1781
2001
  },
1782
2002
  });
1783
2003
  };
1784
2004
  export default KevinPlugin;
1785
- //# sourceMappingURL=index.js.map