@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,152 @@
1
+ /**
2
+ * Readers for the two message payload shapes the working set acts on:
3
+ * `tool_result` (whose body is replaced by a marker) and `assistant` (whose
4
+ * thinking blocks are dropped).
5
+ *
6
+ * These deliberately mirror the private helpers in
7
+ * `src/domains/session/compaction/mask-observations.ts`. That module is the
8
+ * destructive stage this layer replaces and it goes away once the legacy path
9
+ * is retired, so the working set carries its own copy rather than importing
10
+ * from a module scheduled for deletion. The shapes themselves are not this
11
+ * layer's invention: `turn-persistence.ts` writes
12
+ * `{ toolCallId, toolName, result, isError, resultSummary }`, and `result` is
13
+ * whatever the tool returned, usually `{ content: [...], details: {...} }`.
14
+ */
15
+
16
+ export function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return !!value && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+
20
+ export interface ToolResultPayload {
21
+ /** The payload object itself, or a synthetic wrapper when the payload is a bare value. */
22
+ obj: Record<string, unknown>;
23
+ /** The tool's own result value, wherever the payload put it. */
24
+ result: unknown;
25
+ toolName: string;
26
+ }
27
+
28
+ export function toolResultPayload(payload: unknown): ToolResultPayload {
29
+ const obj = isRecord(payload) ? payload : { result: payload };
30
+ const result = obj.result ?? obj.output ?? obj.out ?? obj.content ?? payload;
31
+ const toolName =
32
+ (typeof obj.toolName === "string" && obj.toolName) ||
33
+ (typeof obj.name === "string" && obj.name) ||
34
+ (typeof obj.tool === "string" && obj.tool) ||
35
+ "tool";
36
+ return { obj, result, toolName };
37
+ }
38
+
39
+ function textFromContent(content: unknown): string {
40
+ if (!Array.isArray(content)) return "";
41
+ const parts: string[] = [];
42
+ for (const block of content) {
43
+ if (!isRecord(block)) continue;
44
+ if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
45
+ }
46
+ return parts.join("");
47
+ }
48
+
49
+ function stringifyBody(value: unknown): string {
50
+ if (value === undefined || value === null) return "";
51
+ if (typeof value === "string") return value;
52
+ try {
53
+ return JSON.stringify(value) ?? "";
54
+ } catch {
55
+ return String(value);
56
+ }
57
+ }
58
+
59
+ /** The displayed body of a tool result: content text first, then the legacy single-field shapes. */
60
+ export function toolResultText(result: unknown): string {
61
+ if (typeof result === "string") return result;
62
+ if (!isRecord(result)) return stringifyBody(result);
63
+ const contentText = textFromContent(result.content);
64
+ if (contentText.length > 0) return contentText;
65
+ if (typeof result.text === "string") return result.text;
66
+ if (typeof result.output === "string") return result.output;
67
+ if (typeof result.message === "string") return result.message;
68
+ return stringifyBody(result);
69
+ }
70
+
71
+ /**
72
+ * Estimated tokens of the body the marker would replace: the text the model
73
+ * reads, not the payload JSON. `details`, `resultSummary`, and the observation
74
+ * envelope never reach the model, so a floor measured on the whole payload
75
+ * would let a two-byte result with a fat envelope through and buy a marker
76
+ * longer than the body it replaced.
77
+ */
78
+ export function toolResultBodyTokens(payload: unknown): number {
79
+ return Math.ceil(toolResultText(toolResultPayload(payload).result).length / 4);
80
+ }
81
+
82
+ function nestedRecord(parent: Record<string, unknown> | null, key: string): Record<string, unknown> | null {
83
+ if (parent === null) return null;
84
+ const value = parent[key];
85
+ return isRecord(value) ? value : null;
86
+ }
87
+
88
+ function stringField(record: Record<string, unknown> | null, key: string): string | undefined {
89
+ if (record === null) return undefined;
90
+ const value = record[key];
91
+ return typeof value === "string" && value.length > 0 ? value : undefined;
92
+ }
93
+
94
+ /**
95
+ * Scratch path holding the complete output, when the tool spilled one. Checked
96
+ * in the same order `toolResultSummary` writes it: the OBSERVE envelope, then
97
+ * bash-style result shaping, then the persisted summary that mirrors both.
98
+ */
99
+ export function offloadPathOf(payload: ToolResultPayload): string | undefined {
100
+ const details = nestedRecord(isRecord(payload.result) ? payload.result : null, "details");
101
+ return (
102
+ stringField(nestedRecord(details, "resultDisposition"), "offloadPath") ??
103
+ stringField(nestedRecord(details, "observation"), "offloadPath") ??
104
+ stringField(nestedRecord(details, "resultSize"), "offloadPath") ??
105
+ stringField(nestedRecord(payload.obj, "resultSummary"), "offloadPath")
106
+ );
107
+ }
108
+
109
+ /**
110
+ * The one file this result was about, when the tool named exactly one.
111
+ * `edit`, `write`, and `artifact` record `details.paths`; a result naming
112
+ * several is left without a path rather than picking one arbitrarily. Read
113
+ * results carry no `paths`, so the marker falls back to the call's own
114
+ * argument (`callPathsByToolCallId` in path-index.ts).
115
+ */
116
+ export function primaryPathOf(payload: ToolResultPayload): string | undefined {
117
+ const details = nestedRecord(isRecord(payload.result) ? payload.result : null, "details");
118
+ const paths = details?.paths;
119
+ if (!Array.isArray(paths) || paths.length !== 1) return undefined;
120
+ const first = paths[0];
121
+ return typeof first === "string" && first.length > 0 ? first : undefined;
122
+ }
123
+
124
+ function isThinkingBlock(block: unknown): boolean {
125
+ return isRecord(block) && block.type === "thinking";
126
+ }
127
+
128
+ /** Both shapes the ledger holds reasoning in: `thinking` content blocks and the payload-level string. */
129
+ export function hasThinking(payload: unknown): boolean {
130
+ const obj = isRecord(payload) ? payload : null;
131
+ if (obj === null) return false;
132
+ if (Array.isArray(obj.content) && obj.content.some(isThinkingBlock)) return true;
133
+ return typeof obj.thinking === "string" && obj.thinking.length > 0;
134
+ }
135
+
136
+ export function withoutThinkingBlocks(content: unknown): unknown[] | undefined {
137
+ if (!Array.isArray(content)) return undefined;
138
+ return content.filter((block) => !isThinkingBlock(block));
139
+ }
140
+
141
+ /**
142
+ * True when an earlier destructive compaction run already replaced this body.
143
+ * Such a result has no body left to evict, and re-marking it would spend a
144
+ * marker on a marker. Mirrors `alreadyCompacted()` in mask-observations.ts.
145
+ */
146
+ export function hasLegacyCompactionMarker(payload: unknown): boolean {
147
+ if (!isRecord(payload)) return false;
148
+ if (isRecord(payload.contextCompaction)) return true;
149
+ if (isRecord(nestedRecord(payload, "resultSummary")?.contextCompaction)) return true;
150
+ const result = payload.result ?? payload.output ?? payload.out;
151
+ return isRecord(nestedRecord(isRecord(result) ? result : null, "details")?.contextCompaction);
152
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `age-horizon`: today's selection, recorded instead of destroyed.
3
+ *
4
+ * The rule is exactly what `maskStaleObservations` applied before this layer
5
+ * existed. Every tool-result body older than the protected recent-turn horizon
6
+ * leaves the working set, and every assistant message older than the horizon
7
+ * loses its thinking blocks. Same turn-start definition, same cutoff, same
8
+ * skip conditions. The difference is that the bodies stay in the ledger and
9
+ * come back with `context(scope="recall", ref=...)`.
10
+ *
11
+ * It shipped as the default for one slice so the ledger could stop being
12
+ * rewritten without changing what the model saw; `structural-v1` is the default
13
+ * now and this policy stays as the recorded form of the old selection. It has
14
+ * no target stop on purpose: everything beyond the horizon leaves in one event,
15
+ * as the mask did, and `pressure.target` is ignored.
16
+ *
17
+ * Age is not a quality signal, which is the whole reason `structural-v1`
18
+ * exists: a file read twenty turns ago and never touched since is more useful
19
+ * than a directory listing from two turns ago. Nothing here scores candidates
20
+ * by size or recency beyond that ordering; the only token input is the
21
+ * `minEvictableTokens` floor, which protects low-yield bodies from churn.
22
+ */
23
+
24
+ import type { EvictionCandidate, PolicyInput, WorkingSetPolicy } from "../contract.js";
25
+ import { protectionCutoffIndex } from "../horizon.js";
26
+ import { hasLegacyCompactionMarker, hasThinking, toolResultBodyTokens } from "../payload.js";
27
+
28
+ export const ageHorizonPolicy: WorkingSetPolicy = {
29
+ id: "age-horizon",
30
+ select(input: PolicyInput): ReadonlyArray<EvictionCandidate> {
31
+ const { entries, view, settings } = input;
32
+ const cutoff = protectionCutoffIndex(entries, settings.protectLastTurns);
33
+ const candidates: EvictionCandidate[] = [];
34
+ // Newest-safe-first: the entry closest to the protection horizon is the
35
+ // least likely to be re-read, and a caller that stops early has then
36
+ // evicted the oldest nothing and the newest something.
37
+ for (let i = cutoff - 1; i >= 0; i -= 1) {
38
+ const entry = entries[i];
39
+ if (entry === undefined || entry.kind !== "message") continue;
40
+ if (view.evicted.has(entry.turnId)) continue;
41
+ if (entry.role === "tool_result") {
42
+ if (hasLegacyCompactionMarker(entry.payload)) continue;
43
+ if (toolResultBodyTokens(entry.payload) < settings.minEvictableTokens) continue;
44
+ candidates.push({ ref: { entry: entry.turnId }, reason: "age_horizon" });
45
+ continue;
46
+ }
47
+ // Thinking has no size floor: dropping it costs no marker, so even a
48
+ // short stretch of reasoning is free to remove.
49
+ if (entry.role === "assistant" && hasThinking(entry.payload)) {
50
+ candidates.push({ ref: { entry: entry.turnId }, reason: "thinking_turn_closed" });
51
+ }
52
+ }
53
+ return candidates;
54
+ },
55
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Policy registry. One id in, one pure policy out, so the live engine and the
3
+ * replay-lite runner resolve the same object from the same settings value and
4
+ * cannot drift into running different selections.
5
+ *
6
+ * `structural-v1` is the default; the replay tables under
7
+ * benchmarks/results/context-replay/ put it ahead of `age-horizon` on
8
+ * retention and ahead of the random control on precision. `age-horizon` stays
9
+ * resolvable as the exact pre-layer selection.
10
+ */
11
+
12
+ import type { WorkingSetPolicy, WorkingSetPolicyId } from "../contract.js";
13
+ import { ageHorizonPolicy } from "./age-horizon.js";
14
+ import { structuralPolicy } from "./structural.js";
15
+
16
+ export { ageHorizonPolicy, structuralPolicy };
17
+
18
+ export function resolveWorkingSetPolicy(id: WorkingSetPolicyId): WorkingSetPolicy {
19
+ if (id === "age-horizon") return ageHorizonPolicy;
20
+ return structuralPolicy;
21
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `structural-v1`: evict what the session has structurally finished with.
3
+ *
4
+ * The age rule asks how old a result is. This one asks what happened to it
5
+ * since: was the file rewritten, did a later read cover the same lines, did the
6
+ * failure get resolved, has the listing been walked. Those are facts the ledger
7
+ * already records, and they are the facts a human would use to decide what is
8
+ * still worth carrying. Age is the last rung, not the first, and it only runs
9
+ * when the structural rungs did not free enough.
10
+ *
11
+ * Rule order is the policy. Each rung emits candidates newest-first, every
12
+ * candidate passes `isProtected`, and no unit is claimed twice, so a read that
13
+ * is both stale and superseded is evicted for the reason that came first and
14
+ * carries the ref that explains it. Rungs 1 to 5 are unconditional: redundant
15
+ * content is free to drop, whatever the pressure. Rung 6 is the only one that
16
+ * looks at token counts, and it stops the moment the projection reaches
17
+ * `target`.
18
+ *
19
+ * Deterministic by construction: the index is a pure function of the entries,
20
+ * every loop runs in index order, and nothing here reads a clock, a size
21
+ * ranking, or a recency score.
22
+ */
23
+
24
+ import type { EvictionCandidate, EvictionReason, PolicyInput, WorkingSetPolicy } from "../contract.js";
25
+ import { tokensFreedByEviction } from "../engine.js";
26
+ import { protectionCutoffIndex } from "../horizon.js";
27
+ import { buildPathIndex, callPathsByToolCallId, covers, type PathIndex, type PathObservation } from "../path-index.js";
28
+ import { hasThinking } from "../payload.js";
29
+ import { findLaterSuccess, isProtected } from "../protect.js";
30
+
31
+ /** Ops that observe content rather than change it. */
32
+ const READ_CLASS = new Set<PathObservation["op"]>(["read", "grep", "find", "ls", "code_nav"]);
33
+ const MUTATING = new Set<PathObservation["op"]>(["write", "edit"]);
34
+
35
+ /**
36
+ * The mutation that invalidated this observation: the first successful one
37
+ * after it. A failed edit (`oldText not found`, permission denied) changed
38
+ * nothing, and the read it was aimed at is exactly what the model needs to fix
39
+ * the edit.
40
+ */
41
+ function firstMutationAfter(observation: PathObservation, index: PathIndex): PathObservation | null {
42
+ for (const other of index.byPath.get(observation.path) ?? []) {
43
+ if (other.entryIndex > observation.entryIndex && MUTATING.has(other.op) && !other.isError) return other;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /** The most recent later read of the same file that covers this one's lines. */
49
+ function lastCoveringRead(observation: PathObservation, index: PathIndex): PathObservation | null {
50
+ let found: PathObservation | null = null;
51
+ for (const other of index.byPath.get(observation.path) ?? []) {
52
+ if (other.entryIndex <= observation.entryIndex || other.op !== "read" || other.isError) continue;
53
+ if (covers(other.range, observation.range)) found = other;
54
+ }
55
+ return found;
56
+ }
57
+
58
+ /** Every surfaced path went on to be read. A path nobody read is an unread path. */
59
+ function isListingConsumed(observation: PathObservation, index: PathIndex): boolean {
60
+ if (observation.surfaced.length === 0) return false;
61
+ for (const path of observation.surfaced) {
62
+ const readLater = (index.byPath.get(path) ?? []).some(
63
+ (other) => other.op === "read" && !other.isError && other.entryIndex > observation.entryIndex,
64
+ );
65
+ if (!readLater) return false;
66
+ }
67
+ return true;
68
+ }
69
+
70
+ export const structuralPolicy: WorkingSetPolicy = {
71
+ id: "structural-v1",
72
+ select(input: PolicyInput): ReadonlyArray<EvictionCandidate> {
73
+ const { entries, view, settings, pressure, estimateTokens } = input;
74
+ const index = buildPathIndex(entries, { cwd: input.cwd });
75
+ const callPaths = callPathsByToolCallId(entries);
76
+ const cutoffIndex = protectionCutoffIndex(entries, settings.protectLastTurns);
77
+ const candidates: EvictionCandidate[] = [];
78
+ const claimed = new Set<string>();
79
+ let freed = 0;
80
+
81
+ const entryIndexOf = new Map<string, number>();
82
+ for (let i = 0; i < entries.length; i += 1) {
83
+ const entry = entries[i];
84
+ if (entry !== undefined) entryIndexOf.set(entry.turnId, i);
85
+ }
86
+
87
+ const emit = (turnId: string, reason: EvictionReason, by?: string): boolean => {
88
+ if (claimed.has(turnId) || view.evicted.has(turnId)) return false;
89
+ const entryIndex = entryIndexOf.get(turnId);
90
+ if (entryIndex === undefined) return false;
91
+ const entry = entries[entryIndex];
92
+ if (entry === undefined) return false;
93
+ if (isProtected(entry, { entryIndex, cutoffIndex, input, index })) return false;
94
+ const candidate: EvictionCandidate = { ref: { entry: turnId }, reason, ...(by === undefined ? {} : { by }) };
95
+ claimed.add(turnId);
96
+ candidates.push(candidate);
97
+ freed += tokensFreedByEviction(estimateTokens, entry, candidate, callPaths);
98
+ return true;
99
+ };
100
+
101
+ // Newest-first within every rung, for the cost reason in charter 4.6:
102
+ // evicting the youngest safe unit keeps the cold region after the
103
+ // eviction point small, so the turn that pays for the event pays least.
104
+ const newestFirst = [...index.observations].reverse();
105
+
106
+ // 1. The file changed under it. Whatever the body said is now a claim
107
+ // about a file that no longer exists in that form.
108
+ for (const observation of newestFirst) {
109
+ if (!READ_CLASS.has(observation.op) || observation.path.length === 0) continue;
110
+ const mutation = firstMutationAfter(observation, index);
111
+ if (mutation !== null) emit(observation.ref.entry, "stale_after_mutation", mutation.ref.entry);
112
+ }
113
+
114
+ // 2. The agent asked for the same lines again. It already decided this
115
+ // content was worth re-fetching, and the newer copy is the live one.
116
+ for (const observation of newestFirst) {
117
+ if (observation.op !== "read" || observation.path.length === 0) continue;
118
+ const superseding = lastCoveringRead(observation, index);
119
+ if (superseding !== null) emit(observation.ref.entry, "superseded_read", superseding.ref.entry);
120
+ }
121
+
122
+ // 3. The failure was resolved. The marker keeps its first line, because
123
+ // a failure that happened is evidence even once it is fixed.
124
+ for (const observation of newestFirst) {
125
+ if (!observation.isError) continue;
126
+ const success = findLaterSuccess(observation, index);
127
+ if (success !== null) emit(observation.ref.entry, "failure_resolved", success.ref.entry);
128
+ }
129
+
130
+ // 4. The listing has been walked. One surfaced path still unread and it
131
+ // stays: that is the path the agent comes back to.
132
+ for (const observation of newestFirst) {
133
+ if (isListingConsumed(observation, index)) emit(observation.ref.entry, "listing_consumed");
134
+ }
135
+
136
+ // 5. Reasoning from a closed turn, the same rule age-horizon applies.
137
+ for (let i = cutoffIndex - 1; i >= 0; i -= 1) {
138
+ const entry = entries[i];
139
+ if (entry?.kind !== "message" || entry.role !== "assistant") continue;
140
+ if (hasThinking(entry.payload)) emit(entry.turnId, "thinking_turn_closed");
141
+ }
142
+
143
+ // 6. Age, and only under pressure. Everything above is redundancy the
144
+ // session can lose for free; this rung loses content that is still
145
+ // good, so it runs only when the projection is still over threshold
146
+ // and stops the moment it reaches target.
147
+ const window = pressure.contextWindow;
148
+ if (window <= 0) return candidates;
149
+ let projected = pressure.tokens - freed;
150
+ if (projected <= pressure.threshold * window) return candidates;
151
+ const targetTokens = pressure.target * window;
152
+ for (let i = cutoffIndex - 1; i >= 0 && projected > targetTokens; i -= 1) {
153
+ const entry = entries[i];
154
+ if (entry?.kind !== "message" || entry.role !== "tool_result") continue;
155
+ const before = freed;
156
+ if (emit(entry.turnId, "age_horizon")) projected -= freed - before;
157
+ }
158
+ return candidates;
159
+ },
160
+ };
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Apply a `WorkingSetView` to a ledger slice as an in-memory projection.
3
+ *
4
+ * This is the whole point of the layer: the ledger keeps every byte the tools
5
+ * produced, and the model sees a narrower view of it. Nothing here writes, and
6
+ * nothing here decides what leaves; `fold.ts` says what is out and this module
7
+ * renders that decision onto the entries the replay builder consumes.
8
+ *
9
+ * Pure and idempotent. Projecting an already-projected slice reproduces it
10
+ * byte for byte, because the marker comes from the ledger entry rather than
11
+ * from the body being replaced. Entries the view does not name are returned by
12
+ * reference, and a named entry is a shallow copy with a new payload: nothing
13
+ * below the payload is ever mutated, so a deep clone of the body about to be
14
+ * replaced would only cost the pricing loop the body's size.
15
+ *
16
+ * Callers may pass raw ledger entries: only entries whose `turnId` is a key in
17
+ * `view.evicted` change, and the view was already narrowed to the active path
18
+ * by the fold, so an eviction recorded on an abandoned branch cannot reach a
19
+ * live one (issue #94).
20
+ */
21
+
22
+ import type { MessageEntry, SessionEntry } from "../../session/entries.js";
23
+ import type { EvictedState, WorkingSetView } from "./contract.js";
24
+ import { hasThinking, isRecord, toolResultPayload, withoutThinkingBlocks } from "./payload.js";
25
+
26
+ /**
27
+ * Replace the observation body with its marker. Tool pairing (`toolCallId`,
28
+ * `toolName`) and `details` survive untouched, so replay still matches the
29
+ * result to its call and the renderer still knows what the call was; only the
30
+ * text the model reads changes. The `workingSet` stamp on `details` is how a
31
+ * reader tells a marker from a genuinely tiny tool result.
32
+ */
33
+ function projectToolResult(entry: MessageEntry, state: EvictedState): MessageEntry {
34
+ const next = { ...entry };
35
+ const { obj, result } = toolResultPayload(next.payload);
36
+ const details = isRecord(result) && isRecord(result.details) ? result.details : {};
37
+ next.payload = {
38
+ ...obj,
39
+ result: {
40
+ content: [{ type: "text", text: state.marker }],
41
+ details: {
42
+ ...details,
43
+ workingSet: { evicted: true, reason: state.reason, ref: entry.turnId },
44
+ },
45
+ },
46
+ output: undefined,
47
+ out: undefined,
48
+ content: undefined,
49
+ };
50
+ return next;
51
+ }
52
+
53
+ /**
54
+ * Drop reasoning from a closed turn. No marker replaces it: thinking is
55
+ * model-internal, the Anthropic API discards it after every turn anyway, and a
56
+ * marker would spend tokens to say that something the model cannot act on is
57
+ * gone. Both persisted shapes go: `thinking` content blocks and the
58
+ * payload-level string the local engine adapters write.
59
+ *
60
+ * A turn that was nothing but reasoning keeps it. Projected, it would reach
61
+ * the provider as an assistant message with no content, or vanish from the
62
+ * replay and leave two user messages adjacent; either is worse than the
63
+ * tokens. `planEviction` then prices such a turn at zero and records nothing.
64
+ */
65
+ function projectAssistant(entry: MessageEntry): MessageEntry {
66
+ const obj = isRecord(entry.payload) ? entry.payload : null;
67
+ if (obj === null || !hasThinking(obj)) return entry;
68
+ const content = withoutThinkingBlocks(obj.content);
69
+ if (!hasVisibleContent(obj, content)) return entry;
70
+ const next = { ...entry };
71
+ next.payload = {
72
+ ...obj,
73
+ ...(content !== undefined ? { content } : {}),
74
+ thinking: undefined,
75
+ };
76
+ return next;
77
+ }
78
+
79
+ /** What the replay builder would still send: a payload-level text or at least one surviving block. */
80
+ function hasVisibleContent(obj: Record<string, unknown>, content: unknown[] | undefined): boolean {
81
+ if (typeof obj.text === "string" && obj.text.length > 0) return true;
82
+ return content !== undefined && content.length > 0;
83
+ }
84
+
85
+ /**
86
+ * Usage recorded before the projection existed described a longer prompt than
87
+ * the model will now receive. `calculateContextTokens` anchors on the newest
88
+ * assistant usage it trusts, so leaving those anchors in place would report the
89
+ * pre-eviction size forever and the pressure estimator would never see the
90
+ * space the eviction freed. Mirrors `invalidateUsage()` in
91
+ * mask-observations.ts, bounded to the entries that precede the event.
92
+ */
93
+ function invalidateUsage(entry: SessionEntry): SessionEntry {
94
+ if (entry.kind !== "message" || entry.role !== "assistant") return entry;
95
+ const obj = isRecord(entry.payload) ? entry.payload : null;
96
+ if (obj === null || obj.contextUsageInvalidated === true) return entry;
97
+ const next = { ...entry };
98
+ next.payload = { ...obj, contextUsageInvalidated: true };
99
+ return next;
100
+ }
101
+
102
+ /**
103
+ * Index of the newest eviction event within this slice. An event the slice does
104
+ * not contain (a caller that truncated before it) is treated as later than
105
+ * everything here: every usage anchor in the slice predates the projection.
106
+ */
107
+ function eventIndex(entries: ReadonlyArray<SessionEntry>, lastEvictionTurnId: string | null): number {
108
+ if (lastEvictionTurnId === null) return entries.length;
109
+ const index = entries.findIndex((entry) => entry.turnId === lastEvictionTurnId);
110
+ return index < 0 ? entries.length : index;
111
+ }
112
+
113
+ export function projectWorkingSet(entries: ReadonlyArray<SessionEntry>, view: WorkingSetView): SessionEntry[] {
114
+ if (view.evicted.size === 0 && view.evictionEvents === 0) return [...entries];
115
+ const cutoff = view.evictionEvents > 0 ? eventIndex(entries, view.lastEvictionTurnId) : -1;
116
+ const out: SessionEntry[] = [];
117
+ for (let index = 0; index < entries.length; index += 1) {
118
+ const entry = entries[index];
119
+ if (entry === undefined) continue;
120
+ let next = entry;
121
+ if (entry.kind === "message") {
122
+ const state = view.evicted.get(entry.turnId);
123
+ if (state !== undefined) {
124
+ if (entry.role === "tool_result") next = projectToolResult(entry, state);
125
+ else if (entry.role === "assistant") next = projectAssistant(entry);
126
+ }
127
+ }
128
+ if (index < cutoff) next = invalidateUsage(next);
129
+ out.push(next);
130
+ }
131
+ return out;
132
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Protection predicates: what the working set never gives up, whatever a rule
3
+ * concludes.
4
+ *
5
+ * These run before every rule and are absolute. A policy is allowed to be
6
+ * wrong about relevance (that is what the replay table measures); it is not
7
+ * allowed to drop the operator's words, the last few turns of work, a failure
8
+ * nobody has resolved, or a mutation the current turn is still standing on.
9
+ * Charter 4.5 lists them; this module is that list, and `structural.ts` calls
10
+ * it on every candidate rather than reimplementing any of it.
11
+ *
12
+ * Pure over the entry, the index, and the policy input.
13
+ */
14
+
15
+ import type { SessionEntry } from "../../session/entries.js";
16
+ import type { PolicyInput } from "./contract.js";
17
+ import type { PathIndex, PathObservation } from "./path-index.js";
18
+ import { hasLegacyCompactionMarker, isRecord, toolResultBodyTokens } from "./payload.js";
19
+
20
+ export interface ProtectionContext {
21
+ entryIndex: number;
22
+ /** First entry of the protected recent window, from `protectionCutoffIndex`. */
23
+ cutoffIndex: number;
24
+ input: PolicyInput;
25
+ index: PathIndex;
26
+ }
27
+
28
+ /** Ops whose identity is the file they touched, so a retry on the same path counts as the same call. */
29
+ const PATH_IDENTIFIED_OPS = new Set(["read", "grep", "find"]);
30
+
31
+ function isBlockedResult(payload: unknown): boolean {
32
+ if (!isRecord(payload)) return false;
33
+ // The registry's admission verdict, persisted by turn-persistence. A call
34
+ // the safety rails refused is a decision the session made, not an
35
+ // observation it can re-fetch.
36
+ return payload.outcome === "blocked" || typeof payload.blockReason === "string";
37
+ }
38
+
39
+ function isErrorResult(payload: unknown): boolean {
40
+ if (!isRecord(payload)) return false;
41
+ return payload.isError === true || payload.error === true;
42
+ }
43
+
44
+ /**
45
+ * The later call that resolved this failure: same tool with byte-identical
46
+ * arguments, or, for the path-identified ops, the same file by any route. Null
47
+ * when nothing after it succeeded, which is what keeps the failure protected.
48
+ * A refused call is not a success: the safety rails returned a verdict, not
49
+ * the observation the failure was trying to make.
50
+ *
51
+ * Shared with `structural.ts` rung 3 on purpose: the rule that evicts a
52
+ * resolved failure and the predicate that protects an unresolved one must
53
+ * answer the same question, or a failure could be both.
54
+ */
55
+ export function findLaterSuccess(observation: PathObservation, index: PathIndex): PathObservation | null {
56
+ for (const candidate of index.observations) {
57
+ if (candidate.entryIndex <= observation.entryIndex || candidate.isError || candidate.isBlocked) continue;
58
+ if (candidate.toolName === observation.toolName && observation.argsKey.length > 0) {
59
+ if (candidate.argsKey === observation.argsKey) return candidate;
60
+ }
61
+ if (
62
+ PATH_IDENTIFIED_OPS.has(observation.op) &&
63
+ candidate.op === observation.op &&
64
+ observation.path.length > 0 &&
65
+ candidate.path === observation.path
66
+ ) {
67
+ return candidate;
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /** A write or edit the turn in flight is still standing on. */
74
+ function isActiveTurnMutation(observation: PathObservation, index: PathIndex): boolean {
75
+ if (observation.op !== "write" && observation.op !== "edit") return false;
76
+ return observation.turnIndex >= index.turnCount;
77
+ }
78
+
79
+ export function isProtected(entry: SessionEntry, ctx: ProtectionContext): boolean {
80
+ // Only two things ever leave the working set: a tool result's body and an
81
+ // assistant turn's thinking. Everything else (operator words, summaries,
82
+ // skill activations, ledgers, worker runs, bash executions) is the session's
83
+ // own record of itself.
84
+ if (entry.kind !== "message") return true;
85
+ if (entry.role !== "tool_result" && entry.role !== "assistant") return true;
86
+
87
+ // The recent window is untouchable for both kinds.
88
+ if (ctx.entryIndex >= ctx.cutoffIndex) return true;
89
+ if (entry.role === "assistant") return false;
90
+
91
+ // The floor protects low-yield bodies from churn. The engine separately
92
+ // rejects any candidate whose marker would free zero or negative tokens, so
93
+ // this setting may stay above the literal marker break-even point. The floor
94
+ // is the body's size, not the payload's: details never reach the model.
95
+ if (toolResultBodyTokens(entry.payload) < ctx.input.settings.minEvictableTokens) return true;
96
+ // A body the legacy destructive stage already replaced has nothing left to evict.
97
+ if (hasLegacyCompactionMarker(entry.payload)) return true;
98
+ if (isBlockedResult(entry.payload)) return true;
99
+
100
+ const observation = ctx.index.byRef.get(entry.turnId);
101
+ // No observation means no way to ask whether a failure was resolved, so an
102
+ // unindexed failure stays. Everything else unindexed is an ordinary result
103
+ // the age rung may still take under pressure.
104
+ if (observation === undefined) return isErrorResult(entry.payload);
105
+
106
+ if (isActiveTurnMutation(observation, ctx.index)) return true;
107
+ if (observation.isError && findLaterSuccess(observation, ctx.index) === null) return true;
108
+ return false;
109
+ }