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