@iowarp/clio-coder 0.3.2 → 0.3.4

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 (259) hide show
  1. package/CHANGELOG.md +269 -458
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +3 -3
  4. package/dist/{acp-BIYHVZIM.js → acp-S5R4RR5B.js} +7 -6
  5. package/dist/{agents-YT6SSRIT.js → agents-P6DMMVZY.js} +24 -21
  6. package/dist/assets/codewiki.json +1 -1
  7. package/dist/{auth-5TWEIYDN.js → auth-2XCZLPKS.js} +12 -8
  8. package/dist/{chunk-GGXXDWE4.js → chunk-22NAGB7X.js} +2 -2
  9. package/dist/{chunk-WMSVI4G2.js → chunk-2LZI5CAG.js} +133 -13
  10. package/dist/{chunk-OAO4GE4M.js → chunk-2TZWSW76.js} +2 -2
  11. package/dist/{chunk-OOJYHWRB.js → chunk-34475P3I.js} +2 -2
  12. package/dist/{chunk-WVO7V2QY.js → chunk-35MKKU5R.js} +4 -4
  13. package/dist/{chunk-LBNRH5WM.js → chunk-3HZ5RWN2.js} +5 -5
  14. package/dist/{chunk-AGYYIBLL.js → chunk-3JLKSKD7.js} +2 -2
  15. package/dist/{chunk-MBS4V7ZP.js → chunk-4JUF2NNX.js} +7 -7
  16. package/dist/{chunk-ZDOOVTXZ.js → chunk-4OC57DA6.js} +27 -4
  17. package/dist/chunk-5M54SPOL.js +926 -0
  18. package/dist/{chunk-STBPMHSX.js → chunk-7RXG6QRZ.js} +51 -11
  19. package/dist/{chunk-77VKQEHF.js → chunk-A2GZF7DC.js} +5 -5
  20. package/dist/{chunk-A3CYT5EX.js → chunk-AD2SYQYC.js} +55 -2
  21. package/dist/chunk-AOCYTWAV.js +449 -0
  22. package/dist/chunk-BEY543CS.js +258 -0
  23. package/dist/{chunk-6N5PTWMY.js → chunk-BP4OYD6A.js} +32 -13
  24. package/dist/chunk-BPGS2WCQ.js +612 -0
  25. package/dist/{chunk-J5HN4RYU.js → chunk-BRXQQJFP.js} +8 -8
  26. package/dist/chunk-CFGTUFWB.js +67 -0
  27. package/dist/{chunk-CBCAPZAA.js → chunk-E25LMLRW.js} +2 -2
  28. package/dist/{chunk-G2DE3C7R.js → chunk-EDRHSCIE.js} +4 -4
  29. package/dist/{chunk-4KLWL3UC.js → chunk-EFADSJET.js} +2 -2
  30. package/dist/{chunk-M6SHUN7Q.js → chunk-FO5ZOVUY.js} +2 -2
  31. package/dist/chunk-FYYLNIL5.js +313 -0
  32. package/dist/{chunk-EPVUXGXG.js → chunk-HV5X7OR2.js} +14 -12
  33. package/dist/{chunk-TZTZS7QK.js → chunk-HXG4IURW.js} +5 -3
  34. package/dist/{chunk-IGLFWIYI.js → chunk-K6WL7QZT.js} +3 -3
  35. package/dist/chunk-K7VKOLQQ.js +15 -0
  36. package/dist/{chunk-BMEMKKIT.js → chunk-KOHPCX4K.js} +2 -2
  37. package/dist/{chunk-V4RXGQ5Q.js → chunk-KRPY7NTG.js} +10 -7
  38. package/dist/chunk-LL4KHSZI.js +22 -0
  39. package/dist/{chunk-KJ5LWLOE.js → chunk-MEQ45TQ4.js} +15 -9
  40. package/dist/{chunk-5UUP6MWO.js → chunk-MV3K5QF2.js} +5 -436
  41. package/dist/{chunk-AO4RKG4M.js → chunk-N4CZJQRK.js} +5 -5
  42. package/dist/{chunk-ARBGF5F7.js → chunk-NILBFAPG.js} +14 -8
  43. package/dist/chunk-OZNBF4L3.js +23 -0
  44. package/dist/{verify-G6V4D2G7.js → chunk-PCZJO5TI.js} +127 -42
  45. package/dist/chunk-QQK64KLB.js +1360 -0
  46. package/dist/{chunk-6EJV5X2W.js → chunk-QQL5RT5M.js} +979 -1619
  47. package/dist/{chunk-LZSJBIVT.js → chunk-QWU7ZBO7.js} +70 -720
  48. package/dist/{chunk-2EHAIA3X.js → chunk-RD5U66HV.js} +3 -3
  49. package/dist/{chunk-OKGUZO2U.js → chunk-SPULKLCF.js} +4 -3
  50. package/dist/{chunk-OQ33BKR3.js → chunk-TTNYS3EA.js} +3 -60
  51. package/dist/chunk-TW3WDMVS.js +677 -0
  52. package/dist/chunk-TZSKNMZG.js +434 -0
  53. package/dist/{chunk-7MNJORFF.js → chunk-UL3WSD3F.js} +6 -1
  54. package/dist/{chunk-7EYHLWU7.js → chunk-UZHIZC5S.js} +7 -7
  55. package/dist/{chunk-QTYWRVRA.js → chunk-VAWWTKDP.js} +8 -8
  56. package/dist/{chunk-X75S7HFS.js → chunk-VEZEGCGW.js} +214 -20
  57. package/dist/{chunk-OHHN2SO4.js → chunk-VMNQ6OZA.js} +98 -202
  58. package/dist/chunk-VSNATDE6.js +122 -0
  59. package/dist/chunk-W6GROXXM.js +69 -0
  60. package/dist/chunk-WPQLXFOZ.js +375 -0
  61. package/dist/{chunk-ORBHGJC5.js → chunk-WR67VIZY.js} +3 -3
  62. package/dist/{chunk-3ZXDFGR5.js → chunk-X6COSD2O.js} +5 -5
  63. package/dist/chunk-ZGVHUX3M.js +66 -0
  64. package/dist/{chunk-MAW544W2.js → chunk-ZWMF7253.js} +4 -4
  65. package/dist/{chunk-MQSRRFWA.js → chunk-ZYKPLLNQ.js} +563 -546
  66. package/dist/cli/index.js +27 -23
  67. package/dist/{clio-4LY5K2AC.js → clio-J5JIOIDS.js} +7 -6
  68. package/dist/{code-nav-7AX6FYE6.js → code-nav-AXCXSBHX.js} +5 -3
  69. package/dist/{config-GTLUW2PR.js → config-OEBMIN2U.js} +37 -27
  70. package/dist/{configure-R6A64DHX.js → configure-PUQOSIXQ.js} +16 -13
  71. package/dist/{context-5VKGUVJJ.js → context-EKDCKUUZ.js} +82 -7
  72. package/dist/{context-RW5HC47S.js → context-MGSE4Z2T.js} +33 -23
  73. package/dist/{context-JFZEJ7W5.js → context-URSXPBCK.js} +17 -9
  74. package/dist/{context-clear-6ZHBAZZT.js → context-clear-KDAJRNUK.js} +33 -23
  75. package/dist/context-working-set-SBKMPPI2.js +1552 -0
  76. package/dist/{dispatch-runner-VKBRCWQC.js → dispatch-runner-MSWN72NK.js} +43 -29
  77. package/dist/{doctor-KI767GSN.js → doctor-7BSE27PJ.js} +10 -10
  78. package/dist/{eval-XSSNATB4.js → eval-IZGDOO4H.js} +9 -8
  79. package/dist/{evidence-UA6AWDQQ.js → evidence-SR7WXB5B.js} +51 -23
  80. package/dist/{evolve-QNTFGV6Z.js → evolve-K7VE2CBX.js} +30 -20
  81. package/dist/{fleet-Q7UOMUSG.js → fleet-7XMJNQNF.js} +48 -38
  82. package/dist/{fleet-preflight-DDN536IT.js → fleet-preflight-AQNAH644.js} +3 -3
  83. package/dist/{init-WBB65ZHQ.js → init-JGNPAYXT.js} +41 -31
  84. package/dist/{memory-MD3O64RI.js → memory-4ALKDJ4Q.js} +32 -22
  85. package/dist/{models-BZU34YWD.js → models-ZMMLFJNN.js} +22 -19
  86. package/dist/{monitor-MEQA5C3I.js → monitor-2F3T5KHP.js} +55 -43
  87. package/dist/{orchestrator-CGFKEP27.js → orchestrator-ORHT43JB.js} +2507 -1896
  88. package/dist/{reset-L2FQEE3E.js → reset-NXGTYNUO.js} +4 -3
  89. package/dist/{run-IV4Q6RLN.js → run-RF4WJGMT.js} +51 -41
  90. package/dist/{share-S5BZQC5I.js → share-UT3W6E4M.js} +5 -4
  91. package/dist/{skills-LQEKRDTN.js → skills-PSACKC5Q.js} +2 -2
  92. package/dist/{skills-eval-3DC4HEWS.js → skills-eval-WJSI55RZ.js} +34 -24
  93. package/dist/{targets-C4SSGQOB.js → targets-PIIRAOYS.js} +23 -20
  94. package/dist/{terminal-lease-IT5JW2NR.js → terminal-lease-ULWXWNVY.js} +5 -4
  95. package/dist/{upgrade-7TT7SQ3G.js → upgrade-346TZ6AV.js} +18 -17
  96. package/dist/{usage-GV4PKT3M.js → usage-6KKXR32N.js} +34 -24
  97. package/dist/verifiers-4UUM6TEE.js +1214 -0
  98. package/dist/verify-X5HDROLA.js +25 -0
  99. package/dist/{wiki-generate-DQF6Z66B.js → wiki-generate-7STOCIFZ.js} +42 -31
  100. package/dist/worker/entry.js +33 -24
  101. package/docs/README.md +8 -7
  102. package/docs/acp.md +1 -1
  103. package/docs/alcf-provider.md +1 -1
  104. package/docs/architecture.md +2 -2
  105. package/docs/artifact-versions.md +1 -1
  106. package/docs/built-in-agents.md +1 -1
  107. package/docs/capacity-and-scheduling.md +1 -1
  108. package/docs/commands-and-modes.md +53 -21
  109. package/docs/config-knobs-audit.md +1 -2
  110. package/docs/configuration-and-targets.md +15 -1
  111. package/docs/context-engine.md +64 -12
  112. package/docs/context-working-set.md +194 -0
  113. package/docs/development-pipeline.md +1 -1
  114. package/docs/documentation-coverage.md +5 -5
  115. package/docs/documentation-guide.md +6 -5
  116. package/docs/environment-variables.md +2 -1
  117. package/docs/eval-runner.md +1 -1
  118. package/docs/evals-internal.md +14 -1
  119. package/docs/evidence-and-memory.md +74 -2
  120. package/docs/evolution.md +1 -1
  121. package/docs/exit-codes-and-output.md +1 -1
  122. package/docs/extensions-and-sharing.md +2 -2
  123. package/docs/fleet-dispatch.md +22 -7
  124. package/docs/glossary.md +21 -1
  125. package/docs/installation-and-lifecycle.md +6 -6
  126. package/docs/middleware-and-components.md +1 -1
  127. package/docs/model-catalog.md +7 -9
  128. package/docs/observability.md +4 -4
  129. package/docs/performance-methodology.md +2 -2
  130. package/docs/proactive-memory.md +1 -1
  131. package/docs/prompt-envelope-and-tools.md +4 -4
  132. package/docs/provider-adapter-cookbook.md +1 -1
  133. package/docs/release-cut-checklist.md +35 -35
  134. package/docs/safety-model.md +23 -4
  135. package/docs/scientific-validation.md +21 -3
  136. package/docs/session-lifecycle.md +3 -3
  137. package/docs/skills-marketplace.md +1 -1
  138. package/docs/tool-usage.md +79 -12
  139. package/docs/trace-store.md +1 -1
  140. package/docs/troubleshooting.md +1 -1
  141. package/docs/tui-design.md +2 -2
  142. package/docs/worker-dispatch-mechanics.md +11 -1
  143. package/package.json +8 -11
  144. package/skills/meta/clio-test/SKILL.md +20 -17
  145. package/skills/meta/clio-test/evals.md +3 -3
  146. package/skills/meta/clio-test/references/harness.md +35 -6
  147. package/skills/meta/clio-test/references/test-map.md +20 -10
  148. package/skills/registry.yaml +2 -2
  149. package/skills/skill-marketplace.json +1 -1
  150. package/src/cli/context-working-set.ts +513 -0
  151. package/src/cli/context.ts +8 -0
  152. package/src/cli/evidence.ts +20 -2
  153. package/src/cli/index.ts +4 -0
  154. package/src/cli/verifiers.ts +325 -0
  155. package/src/core/bash-exec.ts +39 -14
  156. package/src/core/bus-events.ts +19 -4
  157. package/src/core/config.ts +54 -0
  158. package/src/core/defaults.ts +50 -3
  159. package/src/core/git-commit-attribution.ts +46 -21
  160. package/src/core/verification-scripts.ts +6 -0
  161. package/src/domains/agents/builtins/verifier.md +3 -0
  162. package/src/domains/config/classify.ts +1 -0
  163. package/src/domains/config/keybindings.ts +3 -3
  164. package/src/domains/context/working-set/contract.ts +161 -0
  165. package/src/domains/context/working-set/defaults.ts +28 -0
  166. package/src/domains/context/working-set/engine.ts +203 -0
  167. package/src/domains/context/working-set/fold.ts +62 -0
  168. package/src/domains/context/working-set/horizon.ts +38 -0
  169. package/src/domains/context/working-set/marker.ts +103 -0
  170. package/src/domains/context/working-set/path-index.ts +436 -0
  171. package/src/domains/context/working-set/payload.ts +152 -0
  172. package/src/domains/context/working-set/policies/age-horizon.ts +55 -0
  173. package/src/domains/context/working-set/policies/index.ts +21 -0
  174. package/src/domains/context/working-set/policies/structural.ts +160 -0
  175. package/src/domains/context/working-set/project.ts +132 -0
  176. package/src/domains/context/working-set/protect.ts +109 -0
  177. package/src/domains/context/working-set/recall.ts +177 -0
  178. package/src/domains/context/working-set/replay/controls.ts +112 -0
  179. package/src/domains/context/working-set/replay/load-clio.ts +199 -0
  180. package/src/domains/context/working-set/replay/metrics.ts +185 -0
  181. package/src/domains/context/working-set/replay/reference-graph.ts +79 -0
  182. package/src/domains/context/working-set/replay/report.ts +139 -0
  183. package/src/domains/context/working-set/replay/runner.ts +325 -0
  184. package/src/domains/context/working-set/replay/synthetic.ts +422 -0
  185. package/src/domains/context/working-set/replay/trace.ts +21 -0
  186. package/src/domains/context/working-set/visible.ts +54 -0
  187. package/src/domains/evidence/build.ts +112 -45
  188. package/src/domains/evidence/eval.ts +24 -7
  189. package/src/domains/evidence/index.ts +53 -0
  190. package/src/domains/evidence/ordering.ts +12 -0
  191. package/src/domains/evidence/run-trust.ts +221 -0
  192. package/src/domains/evidence/store.ts +46 -6
  193. package/src/domains/evidence/trust-status.ts +854 -0
  194. package/src/domains/evidence/types.ts +26 -0
  195. package/src/domains/middleware/memory-intervention.ts +3 -0
  196. package/src/domains/middleware/stalled-turn.ts +165 -4
  197. package/src/domains/safety/autonomy.ts +1 -1
  198. package/src/domains/safety/default-path-policy.ts +8 -0
  199. package/src/domains/safety/finish-contract.ts +4 -3
  200. package/src/domains/safety/policy-engine.ts +48 -6
  201. package/src/domains/session/compaction/compact.ts +23 -1
  202. package/src/domains/session/compaction/cut-point.ts +2 -0
  203. package/src/domains/session/compaction/tokens.ts +16 -1
  204. package/src/domains/session/context-ledger.ts +2 -0
  205. package/src/domains/session/entries.ts +107 -1
  206. package/src/domains/session/manager.ts +9 -2
  207. package/src/domains/session/migrations/index.ts +22 -3
  208. package/src/engine/acp/server.ts +3 -0
  209. package/src/engine/agent.ts +18 -1
  210. package/src/engine/session.ts +9 -3
  211. package/src/entry/orchestrator.ts +16 -4
  212. package/src/interactive/chat-loop-messages.ts +18 -6
  213. package/src/interactive/chat-panel.ts +571 -244
  214. package/src/interactive/chat-renderer.ts +79 -39
  215. package/src/interactive/context-meter.ts +10 -0
  216. package/src/interactive/context-overlay.ts +81 -6
  217. package/src/interactive/context-recall-command.ts +110 -0
  218. package/src/interactive/editor-submit.ts +26 -1
  219. package/src/interactive/footer/widgets.ts +22 -20
  220. package/src/interactive/footer-panel.ts +6 -1
  221. package/src/interactive/interactive-application.ts +2 -0
  222. package/src/interactive/interactive-event-projection.ts +12 -0
  223. package/src/interactive/interactive-slash-runtime.ts +49 -8
  224. package/src/interactive/model-session-replay.ts +21 -0
  225. package/src/interactive/overlay-general-openers.ts +6 -0
  226. package/src/interactive/overlay-session-lifecycle.ts +8 -4
  227. package/src/interactive/overlays/ask-user.ts +146 -24
  228. package/src/interactive/renderers/tool-execution.ts +167 -56
  229. package/src/interactive/session-transcript.ts +2 -2
  230. package/src/interactive/slash-commands.ts +29 -2
  231. package/src/interactive/status/index.ts +12 -1
  232. package/src/interactive/status/reasoning.ts +87 -0
  233. package/src/interactive/status/summary.ts +13 -2
  234. package/src/interactive/transcript-detail.ts +120 -0
  235. package/src/interactive/turn-context.ts +238 -88
  236. package/src/interactive/turn-middleware.ts +6 -6
  237. package/src/tools/agent-tools.ts +11 -4
  238. package/src/tools/bash.ts +144 -82
  239. package/src/tools/builtin-tool-catalog.ts +18 -6
  240. package/src/tools/context/index.ts +105 -3
  241. package/src/tools/context/surface.ts +3 -2
  242. package/src/tools/core-bootstrap.ts +21 -0
  243. package/src/tools/dispatch-runner.ts +9 -7
  244. package/src/tools/monitor.ts +28 -20
  245. package/src/tools/presentation.ts +107 -0
  246. package/src/tools/registry.ts +65 -7
  247. package/src/tools/result-disposition.ts +550 -0
  248. package/src/tools/result-shaping.ts +262 -19
  249. package/src/tools/safe-exec.ts +2 -0
  250. package/src/tools/verify/authoring.ts +1119 -0
  251. package/src/tools/verify/catalog.ts +346 -0
  252. package/src/tools/verify/index.ts +13 -3
  253. package/src/tools/verify/scripts.ts +135 -37
  254. package/src/tools/verify/surface.ts +9 -5
  255. package/src/tools/worker-evidence.ts +35 -12
  256. package/dist/chunk-MNA4JGU4.js +0 -255
  257. package/dist/chunk-SRF2PJNW.js +0 -184
  258. package/dist/chunk-T6YILFSB.js +0 -80
  259. package/dist/chunk-VAKQQHWR.js +0 -434
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Exact recall by ref.
3
+ *
4
+ * A `contextEviction` entry removes a tool-result body from the projection
5
+ * and leaves a marker naming the ref. Recall is the reverse move: given a ref
6
+ * on the active path whose key the fold still lists as evicted, hand back the
7
+ * original body byte-exact and describe the `contextRecall` entry the caller
8
+ * appends. The ref stays evicted in the fold: the body rides the recall tool
9
+ * result at the tail of the working set, so the marker and the prefix cache
10
+ * are untouched and a repeat recall is the churn signal. Pure over entries:
11
+ * nothing here reads the session, writes the ledger, or calls a model.
12
+ *
13
+ * The body is read through the same `payload.ts` readers the projection and
14
+ * the marker use, so what recall returns is exactly what the model saw before
15
+ * eviction. No truncation happens here; the observation envelope applies the
16
+ * per-turn caps.
17
+ */
18
+
19
+ import { ceilChars } from "../../session/context-accounting.js";
20
+ import type { MessageEntry, SessionEntry } from "../../session/entries.js";
21
+ import { filterEntriesToActivePath } from "../../session/tree/active-path.js";
22
+ import {
23
+ type ContextRecallFields,
24
+ EMPTY_WORKING_SET_VIEW,
25
+ type RecallError,
26
+ type RecallResult,
27
+ type RecallTrigger,
28
+ type WorkingSetView,
29
+ } from "./contract.js";
30
+ import { parseRefKey, refKey } from "./fold.js";
31
+ import { callPathsByToolCallId } from "./path-index.js";
32
+ import { offloadPathOf, primaryPathOf, toolResultPayload, toolResultText } from "./payload.js";
33
+
34
+ export type RecallOutcome = { ok: true; result: RecallResult } | { ok: false; error: RecallError };
35
+
36
+ function isThinkingEntry(entry: SessionEntry): boolean {
37
+ return entry.kind === "message" && entry.role === "assistant";
38
+ }
39
+
40
+ function isToolResultEntry(entry: SessionEntry): entry is MessageEntry {
41
+ return entry.kind === "message" && entry.role === "tool_result";
42
+ }
43
+
44
+ export function resolveRecall(
45
+ entries: ReadonlyArray<SessionEntry>,
46
+ view: WorkingSetView,
47
+ ref: string,
48
+ activeLeafTurnId?: string,
49
+ ): RecallOutcome {
50
+ const parsed = parseRefKey(ref);
51
+ if (parsed === null) return { ok: false, error: { kind: "invalid_ref", ref } };
52
+ const key = refKey(parsed);
53
+ const active = filterEntriesToActivePath(entries, activeLeafTurnId);
54
+ const entry = active.find((candidate) => candidate.turnId === key);
55
+ if (entry === undefined) {
56
+ return { ok: false, error: { kind: "not_on_active_path", ref: key } };
57
+ }
58
+ // Thinking leaves the working set without a marker and is not recallable
59
+ // in this slice; `recallErrorMessage` names that case from the entry.
60
+ if (isThinkingEntry(entry) || !view.evicted.has(key) || !isToolResultEntry(entry)) {
61
+ return { ok: false, error: { kind: "not_evicted", ref: key } };
62
+ }
63
+ const payload = toolResultPayload(entry.payload);
64
+ const body = toolResultText(payload.result);
65
+ const offloadPath = offloadPathOf(payload);
66
+ return {
67
+ ok: true,
68
+ result: {
69
+ ref: parsed,
70
+ entry,
71
+ body,
72
+ tokens: ceilChars(body.length),
73
+ ...(offloadPath !== undefined ? { offloadPath } : {}),
74
+ },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * The turn a `contextRecall` entry parents onto: the newest message on the
80
+ * active path. Every recall caller needs this and they must agree, because a
81
+ * record anchored anywhere else folds onto the wrong branch and a `/tree`
82
+ * switch would then show a recall the branch never made.
83
+ */
84
+ export function recallParentTurnId(entries: ReadonlyArray<SessionEntry>, activeLeafTurnId?: string): string | null {
85
+ const active = filterEntriesToActivePath(entries, activeLeafTurnId);
86
+ for (let i = active.length - 1; i >= 0; i -= 1) {
87
+ const candidate = active[i];
88
+ if (candidate?.kind === "message") return candidate.turnId;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ export function buildRecallFields(
94
+ result: RecallResult,
95
+ meta: { trigger: RecallTrigger; toolCallId?: string },
96
+ ): ContextRecallFields {
97
+ return {
98
+ kind: "contextRecall",
99
+ ref: { entry: result.ref.entry },
100
+ trigger: meta.trigger,
101
+ tokensReadmitted: result.tokens,
102
+ ...(meta.toolCallId !== undefined ? { toolCallId: meta.toolCallId } : {}),
103
+ };
104
+ }
105
+
106
+ /** Refs listed in a recall failure before the list is cut with an ellipsis. */
107
+ const MAX_LISTED_REFS = 8;
108
+
109
+ export interface RecallableRefListing {
110
+ /** Already-rendered `ref (tool path)` rows, bounded for prompt use. */
111
+ refs: string[];
112
+ /** Recallable refs omitted after the bounded prefix. */
113
+ remaining: number;
114
+ }
115
+
116
+ /**
117
+ * The refs a recall can actually bring back, so the next call can name one of
118
+ * them. Thinking refs are evicted too but are not recallable, so listing them
119
+ * would hand the caller a ref that fails for a different reason. A guessed
120
+ * "nearest" ref was tried first and dropped: over time-ordered ids a prefix
121
+ * match names an unrelated result, and the listing is what helps.
122
+ */
123
+ export function recallableRefListing(entries: ReadonlyArray<SessionEntry>, view: WorkingSetView): RecallableRefListing {
124
+ const byTurnId = new Map<string, SessionEntry>();
125
+ for (const entry of entries) byTurnId.set(entry.turnId, entry);
126
+ const callPaths = callPathsByToolCallId(entries);
127
+ const refs: string[] = [];
128
+ for (const key of view.evicted.keys()) {
129
+ const entry = byTurnId.get(key);
130
+ if (entry === undefined || !isToolResultEntry(entry)) continue;
131
+ // After a summary compaction the markers before the cut are gone from
132
+ // the working set, so the listing is the only place the caller learns
133
+ // what a ref was. Tool and path are what it needs to pick one.
134
+ const payload = toolResultPayload(entry.payload);
135
+ const toolCallId = typeof payload.obj.toolCallId === "string" ? payload.obj.toolCallId : undefined;
136
+ const path = primaryPathOf(payload) ?? (toolCallId === undefined ? undefined : callPaths.get(toolCallId));
137
+ refs.push(`${key} (${payload.toolName}${path === undefined ? "" : ` ${path}`})`);
138
+ }
139
+ return {
140
+ refs: refs.slice(0, MAX_LISTED_REFS),
141
+ remaining: Math.max(0, refs.length - MAX_LISTED_REFS),
142
+ };
143
+ }
144
+
145
+ function recallableRefMessage(entries: ReadonlyArray<SessionEntry>, view: WorkingSetView): string {
146
+ const listing = recallableRefListing(entries, view);
147
+ if (listing.refs.length === 0) return "No recallable refs on the active path.";
148
+ const more = listing.remaining > 0 ? `, and ${listing.remaining} more` : "";
149
+ return `Recallable refs on the active path: ${listing.refs.join(", ")}${more}.`;
150
+ }
151
+
152
+ /**
153
+ * One-line operator/model-facing message for a recall failure. Says why an
154
+ * assistant turn is refused instead of calling it "not evicted", and ends with
155
+ * the refs that can be recalled. `entries` is the active path the view was
156
+ * folded over; without it the listing is empty.
157
+ */
158
+ export function recallErrorMessage(
159
+ error: RecallError,
160
+ entries: ReadonlyArray<SessionEntry> = [],
161
+ view: WorkingSetView = EMPTY_WORKING_SET_VIEW,
162
+ ): string {
163
+ const listing = ` ${recallableRefMessage(entries, view)}`;
164
+ switch (error.kind) {
165
+ case "invalid_ref":
166
+ return `recall ref must be a single turnId without whitespace; got '${error.ref}'.`;
167
+ case "not_on_active_path":
168
+ return `ref ${error.ref} is not on the active path of this session (unknown or on an abandoned branch).${listing}`;
169
+ case "not_evicted": {
170
+ const entry = entries.find((candidate) => candidate.turnId === error.ref);
171
+ if (entry !== undefined && isThinkingEntry(entry)) {
172
+ return `ref ${error.ref} is an assistant turn; thinking is not recallable.${listing}`;
173
+ }
174
+ return `ref ${error.ref} is not evicted; its content is already in context.${listing}`;
175
+ }
176
+ }
177
+ }
@@ -0,0 +1,112 @@
1
+ import type { SessionEntry } from "../../../session/entries.js";
2
+ import type { EvictionCandidate, PolicyInput, WorkingSetPolicy, WorkingSetPolicyId } from "../contract.js";
3
+ import { tokensFreedByEviction } from "../engine.js";
4
+ import { protectionCutoffIndex } from "../horizon.js";
5
+ import { buildPathIndex, callPathsByToolCallId } from "../path-index.js";
6
+ import { isProtected } from "../protect.js";
7
+ import type { ReferenceGraph } from "./reference-graph.js";
8
+ import { countReplayTurns } from "./trace.js";
9
+
10
+ /** Replay-only diagnostic surface; it does not widen the live policy contract. */
11
+ export interface ReplayCandidatePoolPolicy extends WorkingSetPolicy {
12
+ /** Number of currently usable candidates before target-based truncation. */
13
+ replayCandidateCount(input: PolicyInput): number;
14
+ }
15
+
16
+ function controlId(id: string): WorkingSetPolicyId {
17
+ return id as WorkingSetPolicyId;
18
+ }
19
+
20
+ function eligibleToolResults(input: PolicyInput): SessionEntry[] {
21
+ const cutoff = protectionCutoffIndex(input.entries, input.settings.protectLastTurns);
22
+ const index = buildPathIndex(input.entries, { cwd: input.cwd });
23
+ const out: SessionEntry[] = [];
24
+ for (let entryIndex = cutoff - 1; entryIndex >= 0; entryIndex -= 1) {
25
+ const entry = input.entries[entryIndex];
26
+ if (entry?.kind !== "message" || entry.role !== "tool_result") continue;
27
+ if (input.view.evicted.has(entry.turnId)) continue;
28
+ if (isProtected(entry, { entryIndex, cutoffIndex: cutoff, input, index })) continue;
29
+ out.push(entry);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function takeToTarget(input: PolicyInput, entries: ReadonlyArray<SessionEntry>): EvictionCandidate[] {
35
+ let tokensNeeded = Math.max(0, input.pressure.tokens - input.pressure.target * input.pressure.contextWindow);
36
+ if (tokensNeeded <= 0) return [];
37
+ const selected: EvictionCandidate[] = [];
38
+ const callPaths = callPathsByToolCallId(input.entries);
39
+ for (const entry of entries) {
40
+ const candidate: EvictionCandidate = { ref: { entry: entry.turnId }, reason: "age_horizon" };
41
+ selected.push(candidate);
42
+ tokensNeeded -= tokensFreedByEviction(input.estimateTokens, entry, candidate, callPaths);
43
+ if (tokensNeeded <= 0) break;
44
+ }
45
+ return selected;
46
+ }
47
+
48
+ export function makeOraclePolicy(graph: ReferenceGraph): ReplayCandidatePoolPolicy {
49
+ let lastInput: PolicyInput | null = null;
50
+ let lastCandidateCount = 0;
51
+ const safeEntries = (input: PolicyInput): SessionEntry[] => {
52
+ const currentTurn = countReplayTurns(input.entries) + 1;
53
+ return eligibleToolResults(input).filter((entry) => {
54
+ const futureTurns = graph.futureTurnsOf.get(entry.turnId) ?? [];
55
+ return futureTurns.every((turn) => turn < currentTurn);
56
+ });
57
+ };
58
+ return {
59
+ id: controlId("oracle"),
60
+ select(input): ReadonlyArray<EvictionCandidate> {
61
+ // Replay calls before the next turn-start entry is appended. A reference
62
+ // in that next turn is therefore still future from the model's view.
63
+ const safe = safeEntries(input);
64
+ lastInput = input;
65
+ lastCandidateCount = safe.length;
66
+ return takeToTarget(input, safe);
67
+ },
68
+ replayCandidateCount(input): number {
69
+ return input === lastInput ? lastCandidateCount : safeEntries(input).length;
70
+ },
71
+ };
72
+ }
73
+
74
+ function mulberry32(seed: number): () => number {
75
+ let value = seed >>> 0;
76
+ return () => {
77
+ value = (value + 0x6d2b79f5) >>> 0;
78
+ let next = value;
79
+ next = Math.imul(next ^ (next >>> 15), next | 1);
80
+ next ^= next + Math.imul(next ^ (next >>> 7), next | 61);
81
+ return ((next ^ (next >>> 14)) >>> 0) / 4_294_967_296;
82
+ };
83
+ }
84
+
85
+ export function makeRandomPolicy(seed: number): ReplayCandidatePoolPolicy {
86
+ let lastInput: PolicyInput | null = null;
87
+ let lastCandidateCount = 0;
88
+ return {
89
+ id: controlId("random"),
90
+ select(input): ReadonlyArray<EvictionCandidate> {
91
+ const entries = [...eligibleToolResults(input)];
92
+ lastInput = input;
93
+ lastCandidateCount = entries.length;
94
+ const random = mulberry32(seed);
95
+ for (let index = entries.length - 1; index > 0; index -= 1) {
96
+ const swap = Math.floor(random() * (index + 1));
97
+ const value = entries[index];
98
+ entries[index] = entries[swap] as SessionEntry;
99
+ entries[swap] = value as SessionEntry;
100
+ }
101
+ return takeToTarget(input, entries);
102
+ },
103
+ replayCandidateCount(input): number {
104
+ return input === lastInput ? lastCandidateCount : eligibleToolResults(input).length;
105
+ },
106
+ };
107
+ }
108
+
109
+ export const nonePolicy: WorkingSetPolicy = {
110
+ id: controlId("none"),
111
+ select: () => [],
112
+ };
@@ -0,0 +1,199 @@
1
+ import type { Dirent } from "node:fs";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { parseSessionEntries } from "../../../session/archive-readers.js";
5
+ import { isSessionHeader, type SessionEntry } from "../../../session/entries.js";
6
+ import { filterEntriesToActivePath } from "../../../session/tree/active-path.js";
7
+ import { buildPathIndex } from "../path-index.js";
8
+ import { buildReferenceGraph } from "./reference-graph.js";
9
+ import { countReplayTurns, type Trace } from "./trace.js";
10
+
11
+ export interface ReplayFilterOptions {
12
+ minTurns?: number;
13
+ minToolResults?: number;
14
+ requireFileReread?: boolean;
15
+ }
16
+
17
+ export interface LoadClioTraceOptions {
18
+ /** False is the CLI's --no-filter behavior. */
19
+ filter?: false | ReplayFilterOptions;
20
+ }
21
+
22
+ export interface ReplayLoadCascade {
23
+ found: number;
24
+ unreadable: number;
25
+ filtered: Record<string, number>;
26
+ kept: number;
27
+ }
28
+
29
+ const DEFAULT_FILTER = {
30
+ minTurns: 8,
31
+ minToolResults: 8,
32
+ requireFileReread: true,
33
+ } as const;
34
+
35
+ async function collectLedgerFiles(input: string, out: Set<string>): Promise<void> {
36
+ const path = resolve(input);
37
+ let facts: Awaited<ReturnType<typeof stat>>;
38
+ try {
39
+ facts = await stat(path);
40
+ } catch {
41
+ return;
42
+ }
43
+ if (facts.isFile()) {
44
+ // Directory discovery is intentionally limited to current.jsonl below;
45
+ // an explicit JSONL path may also be a frozen synthetic fixture.
46
+ if (path.endsWith(".jsonl")) out.add(path);
47
+ return;
48
+ }
49
+ if (!facts.isDirectory()) return;
50
+
51
+ const direct = join(path, "current.jsonl");
52
+ try {
53
+ if ((await stat(direct)).isFile()) {
54
+ out.add(direct);
55
+ return;
56
+ }
57
+ } catch {
58
+ // A sessions root or cwd-hash directory has no direct ledger.
59
+ }
60
+
61
+ let children: Dirent[];
62
+ try {
63
+ children = await readdir(path, { withFileTypes: true });
64
+ } catch {
65
+ return;
66
+ }
67
+ for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) {
68
+ if (!child.isDirectory()) continue;
69
+ await collectLedgerFiles(join(path, child.name), out);
70
+ }
71
+ }
72
+
73
+ function sessionFacts(raw: string, source: string): { id: string; cwd: string | null } {
74
+ const fallback = { id: basename(dirname(source)), cwd: null };
75
+ for (const line of raw.split("\n")) {
76
+ if (line.trim().length === 0) continue;
77
+ try {
78
+ const value = JSON.parse(line) as unknown;
79
+ if (isSessionHeader(value)) return { id: value.id, cwd: value.cwd.length > 0 ? value.cwd : null };
80
+ } catch {
81
+ return fallback;
82
+ }
83
+ }
84
+ return fallback;
85
+ }
86
+
87
+ async function pinnedLeafTurnId(source: string): Promise<string | undefined> {
88
+ try {
89
+ const raw = await readFile(join(dirname(source), "meta.json"), "utf8");
90
+ const value = JSON.parse(raw) as unknown;
91
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
92
+ const pinned = (value as Record<string, unknown>).pinnedLeafTurnId;
93
+ return typeof pinned === "string" && pinned.length > 0 ? pinned : undefined;
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ function cleanActiveEntries(entries: ReadonlyArray<SessionEntry>, activeLeafTurnId?: string): SessionEntry[] {
100
+ return filterEntriesToActivePath(entries, activeLeafTurnId).filter(
101
+ (entry) => entry.kind !== "contextEviction" && entry.kind !== "contextRecall",
102
+ );
103
+ }
104
+
105
+ function toolResultCount(entries: ReadonlyArray<SessionEntry>): number {
106
+ let count = 0;
107
+ for (const entry of entries) {
108
+ if (entry.kind === "message" && entry.role === "tool_result") count += 1;
109
+ }
110
+ return count;
111
+ }
112
+
113
+ /**
114
+ * Load only Clio ledgers. Inputs may be a current.jsonl file, one session
115
+ * directory, a cwd-hash directory, or the sessions root.
116
+ */
117
+ export async function loadClioTraces(
118
+ paths: ReadonlyArray<string>,
119
+ options: LoadClioTraceOptions = {},
120
+ ): Promise<{ traces: Trace[]; cascade: ReplayLoadCascade }> {
121
+ const discovered = new Set<string>();
122
+ let missingInputs = 0;
123
+ for (const path of paths) {
124
+ const before = discovered.size;
125
+ await collectLedgerFiles(path, discovered);
126
+ if (discovered.size === before) {
127
+ try {
128
+ await stat(resolve(path));
129
+ } catch {
130
+ missingInputs += 1;
131
+ }
132
+ }
133
+ }
134
+
135
+ const filtered: Record<string, number> = {
136
+ turns_lt_8: 0,
137
+ tool_results_lt_8: 0,
138
+ no_file_reread: 0,
139
+ };
140
+ const cascade: ReplayLoadCascade = {
141
+ found: discovered.size,
142
+ unreadable: missingInputs,
143
+ filtered,
144
+ kept: 0,
145
+ };
146
+ const traces: Trace[] = [];
147
+ const filter =
148
+ options.filter === false
149
+ ? null
150
+ : {
151
+ minTurns: options.filter?.minTurns ?? DEFAULT_FILTER.minTurns,
152
+ minToolResults: options.filter?.minToolResults ?? DEFAULT_FILTER.minToolResults,
153
+ requireFileReread: options.filter?.requireFileReread ?? DEFAULT_FILTER.requireFileReread,
154
+ };
155
+
156
+ for (const source of [...discovered].sort((a, b) => a.localeCompare(b))) {
157
+ let raw: string;
158
+ try {
159
+ raw = await readFile(source, "utf8");
160
+ } catch {
161
+ cascade.unreadable += 1;
162
+ continue;
163
+ }
164
+ const parsed = parseSessionEntries(raw, source);
165
+ if (parsed.errors.length > 0) {
166
+ cascade.unreadable += 1;
167
+ continue;
168
+ }
169
+ const entries = cleanActiveEntries(parsed.entries, await pinnedLeafTurnId(source));
170
+ const facts = sessionFacts(raw, source);
171
+ const trace: Trace = {
172
+ id: facts.id,
173
+ source,
174
+ cwd: facts.cwd,
175
+ entries,
176
+ turnCount: countReplayTurns(entries),
177
+ };
178
+ if (filter !== null) {
179
+ if (trace.turnCount < filter.minTurns) {
180
+ filtered.turns_lt_8 = (filtered.turns_lt_8 ?? 0) + 1;
181
+ continue;
182
+ }
183
+ if (toolResultCount(entries) < filter.minToolResults) {
184
+ filtered.tool_results_lt_8 = (filtered.tool_results_lt_8 ?? 0) + 1;
185
+ continue;
186
+ }
187
+ if (filter.requireFileReread) {
188
+ const graph = buildReferenceGraph(trace, buildPathIndex(entries, { cwd: trace.cwd }));
189
+ if (!graph.edges.some((edge) => edge.kind === "file_reread")) {
190
+ filtered.no_file_reread = (filtered.no_file_reread ?? 0) + 1;
191
+ continue;
192
+ }
193
+ }
194
+ }
195
+ traces.push(trace);
196
+ }
197
+ cascade.kept = traces.length;
198
+ return { traces, cascade };
199
+ }
@@ -0,0 +1,185 @@
1
+ import { covers, type PathIndex, type PathObservation } from "../path-index.js";
2
+ import type { ReferenceGraph } from "./reference-graph.js";
3
+ import type { ReplayTraceResult } from "./runner.js";
4
+ import type { Trace } from "./trace.js";
5
+
6
+ export interface ReplayMetrics {
7
+ traces: number;
8
+ retention: number;
9
+ /** Retention after crediting a surviving newer read that covers the original range. */
10
+ retentionCovered: number;
11
+ retentionAt10: number;
12
+ evictionPrecision: number;
13
+ tokensEvicted: number;
14
+ /** Tokens freed by evicting items a later turn referenced again: the re-discovery bill a perfect recall would pay. */
15
+ recallTokens: number;
16
+ /** Sum over events of the projected tokens after the earliest evicted position: exact-prefix cache re-prefill cost. */
17
+ coldPrefixTokens: number;
18
+ evictionEvents: number;
19
+ /** Fraction of applied events that exhausted the policy's usable candidates. */
20
+ saturatedEvents: number;
21
+ turnsToFirstSummary: number | null;
22
+ /** Summary compactions the modeled summary stage applied; what a policy exists to make rare. */
23
+ summaries: number;
24
+ }
25
+
26
+ export interface ReplayMeasurement {
27
+ trace: Trace;
28
+ index: PathIndex;
29
+ graph: ReferenceGraph;
30
+ replay: ReplayTraceResult;
31
+ }
32
+
33
+ export interface ReplayMetricAggregate {
34
+ /** Arithmetic mean of the per-trace metrics; `traces` is the sample size. */
35
+ mean: ReplayMetrics;
36
+ /** Number of traces contributing to the nullable `turnsToFirstSummary` mean. */
37
+ turnsToFirstSummaryCount: number;
38
+ /** Headline pair-level retention pooled across every critical future reference. */
39
+ pooledRetention: number;
40
+ pooledRetentionCovered: number;
41
+ pooledRetentionAt10: number;
42
+ }
43
+
44
+ interface MeasuredTrace {
45
+ metrics: ReplayMetrics;
46
+ pairs: number;
47
+ retainedPairs: number;
48
+ coveredRetainedPairs: number;
49
+ pairsAt10: number;
50
+ retainedPairsAt10: number;
51
+ saturatedEventCount: number;
52
+ }
53
+
54
+ function safeFraction(numerator: number, denominator: number, empty: number): number {
55
+ return denominator === 0 ? empty : numerator / denominator;
56
+ }
57
+
58
+ function survivesThrough(
59
+ observation: PathObservation,
60
+ referenceTurn: number,
61
+ evictedAtTurn: ReadonlyMap<string, number>,
62
+ ): boolean {
63
+ const evictedAt = evictedAtTurn.get(observation.ref.entry);
64
+ return evictedAt === undefined || evictedAt > referenceTurn;
65
+ }
66
+
67
+ function hasSurvivingCoveringRead(input: ReplayMeasurement, ref: string, referenceTurn: number): boolean {
68
+ const original = input.index.byRef.get(ref);
69
+ if (original?.op !== "read" || original.path.length === 0) return false;
70
+ return (input.index.byPath.get(original.path) ?? []).some(
71
+ (later) =>
72
+ later.op === "read" &&
73
+ !later.isError &&
74
+ later.entryIndex > original.entryIndex &&
75
+ later.turnIndex < referenceTurn &&
76
+ covers(later.range, original.range) &&
77
+ survivesThrough(later, referenceTurn, input.replay.evictedAtTurn),
78
+ );
79
+ }
80
+
81
+ function measure(input: ReplayMeasurement): MeasuredTrace {
82
+ let pairs = 0;
83
+ let retainedPairs = 0;
84
+ let coveredRetainedPairs = 0;
85
+ let pairsAt10 = 0;
86
+ let retainedPairsAt10 = 0;
87
+ for (const [ref, futureTurns] of input.graph.futureTurnsOf) {
88
+ const observationTurn = input.index.byRef.get(ref)?.turnIndex;
89
+ const evictedAt = input.replay.evictedAtTurn.get(ref);
90
+ for (const referenceTurn of futureTurns) {
91
+ pairs += 1;
92
+ const retained = evictedAt === undefined || evictedAt > referenceTurn;
93
+ if (retained) retainedPairs += 1;
94
+ if (retained || hasSurvivingCoveringRead(input, ref, referenceTurn)) coveredRetainedPairs += 1;
95
+ if (observationTurn !== undefined && referenceTurn - observationTurn <= 10) {
96
+ pairsAt10 += 1;
97
+ if (retained) retainedPairsAt10 += 1;
98
+ }
99
+ }
100
+ }
101
+
102
+ // Precision is the share of evicted items never referenced again; the
103
+ // complement (items the session came back to) is what live churn would
104
+ // count as recalls, so it is not reported as a second column.
105
+ let evictedItems = 0;
106
+ let safelyEvictedItems = 0;
107
+ let tokensEvicted = 0;
108
+ let recallTokens = 0;
109
+ let coldPrefixTokens = 0;
110
+ let saturatedEventCount = 0;
111
+ for (const event of input.replay.events) {
112
+ if (event.saturated) saturatedEventCount += 1;
113
+ coldPrefixTokens += event.coldPrefixTokens;
114
+ for (const item of event.items) {
115
+ evictedItems += 1;
116
+ tokensEvicted += item.tokensFreed;
117
+ const future = input.graph.futureTurnsOf.get(item.ref.entry) ?? [];
118
+ if (!future.some((turn) => turn > event.turnIndex)) safelyEvictedItems += 1;
119
+ else recallTokens += item.tokensFreed;
120
+ }
121
+ }
122
+
123
+ return {
124
+ metrics: {
125
+ traces: 1,
126
+ retention: safeFraction(retainedPairs, pairs, 1),
127
+ retentionCovered: safeFraction(coveredRetainedPairs, pairs, 1),
128
+ retentionAt10: safeFraction(retainedPairsAt10, pairsAt10, 1),
129
+ evictionPrecision: safeFraction(safelyEvictedItems, evictedItems, 1),
130
+ tokensEvicted,
131
+ recallTokens,
132
+ coldPrefixTokens,
133
+ evictionEvents: input.replay.events.length,
134
+ saturatedEvents: safeFraction(saturatedEventCount, input.replay.events.length, 0),
135
+ turnsToFirstSummary: input.replay.turnsToFirstSummary,
136
+ summaries: input.replay.summaries,
137
+ },
138
+ pairs,
139
+ retainedPairs,
140
+ coveredRetainedPairs,
141
+ pairsAt10,
142
+ retainedPairsAt10,
143
+ saturatedEventCount,
144
+ };
145
+ }
146
+
147
+ export function measureReplayTrace(input: ReplayMeasurement): ReplayMetrics {
148
+ return measure(input).metrics;
149
+ }
150
+
151
+ function mean(values: ReadonlyArray<number>): number {
152
+ return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
153
+ }
154
+
155
+ export function aggregateReplayMetrics(inputs: ReadonlyArray<ReplayMeasurement>): ReplayMetricAggregate {
156
+ const measured = inputs.map(measure);
157
+ const firstSummaries = measured
158
+ .map((entry) => entry.metrics.turnsToFirstSummary)
159
+ .filter((value): value is number => value !== null);
160
+ const sum = (field: "pairs" | "retainedPairs" | "coveredRetainedPairs" | "pairsAt10" | "retainedPairsAt10"): number =>
161
+ measured.reduce((total, entry) => total + entry[field], 0);
162
+ const totalEvents = measured.reduce((total, entry) => total + entry.metrics.evictionEvents, 0);
163
+ const saturatedEvents = measured.reduce((total, entry) => total + entry.saturatedEventCount, 0);
164
+ return {
165
+ mean: {
166
+ traces: measured.length,
167
+ retention: measured.length === 0 ? 1 : mean(measured.map((entry) => entry.metrics.retention)),
168
+ retentionCovered: measured.length === 0 ? 1 : mean(measured.map((entry) => entry.metrics.retentionCovered)),
169
+ retentionAt10: measured.length === 0 ? 1 : mean(measured.map((entry) => entry.metrics.retentionAt10)),
170
+ evictionPrecision: measured.length === 0 ? 1 : mean(measured.map((entry) => entry.metrics.evictionPrecision)),
171
+ tokensEvicted: mean(measured.map((entry) => entry.metrics.tokensEvicted)),
172
+ recallTokens: mean(measured.map((entry) => entry.metrics.recallTokens)),
173
+ coldPrefixTokens: mean(measured.map((entry) => entry.metrics.coldPrefixTokens)),
174
+ evictionEvents: mean(measured.map((entry) => entry.metrics.evictionEvents)),
175
+ // Event-pooled: zero-event traces must not dilute the saturation rate.
176
+ saturatedEvents: safeFraction(saturatedEvents, totalEvents, 0),
177
+ turnsToFirstSummary: firstSummaries.length === 0 ? null : mean(firstSummaries),
178
+ summaries: mean(measured.map((entry) => entry.metrics.summaries)),
179
+ },
180
+ turnsToFirstSummaryCount: firstSummaries.length,
181
+ pooledRetention: safeFraction(sum("retainedPairs"), sum("pairs"), 1),
182
+ pooledRetentionCovered: safeFraction(sum("coveredRetainedPairs"), sum("pairs"), 1),
183
+ pooledRetentionAt10: safeFraction(sum("retainedPairsAt10"), sum("pairsAt10"), 1),
184
+ };
185
+ }