@iowarp/clio-coder 0.3.3 → 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 (244) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +3 -3
  4. package/dist/{acp-P2AQILE2.js → acp-S5R4RR5B.js} +7 -6
  5. package/dist/{agents-72W3BI7I.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-2DJ2KNFG.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-4XUGQOHA.js → chunk-35MKKU5R.js} +4 -4
  13. package/dist/{chunk-LM5TQCJZ.js → chunk-3HZ5RWN2.js} +5 -5
  14. package/dist/{chunk-AGYYIBLL.js → chunk-3JLKSKD7.js} +2 -2
  15. package/dist/{chunk-KZWTDYJF.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-X6IAEBZR.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-V6RTAOC2.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-DUYJ5IO6.js → chunk-EDRHSCIE.js} +4 -4
  29. package/dist/{chunk-XBXAASKX.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-FNTMWMX5.js → chunk-HV5X7OR2.js} +14 -12
  33. package/dist/{chunk-TZTZS7QK.js → chunk-HXG4IURW.js} +5 -3
  34. package/dist/{chunk-5UFT4SUX.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-OC7FIQPC.js → chunk-KRPY7NTG.js} +10 -7
  38. package/dist/chunk-LL4KHSZI.js +22 -0
  39. package/dist/{chunk-DSELYM6W.js → chunk-MEQ45TQ4.js} +15 -9
  40. package/dist/{chunk-5UUP6MWO.js → chunk-MV3K5QF2.js} +5 -436
  41. package/dist/{chunk-6SGHMWE3.js → chunk-N4CZJQRK.js} +5 -5
  42. package/dist/{chunk-TZK7PACC.js → chunk-NILBFAPG.js} +14 -8
  43. package/dist/chunk-OZNBF4L3.js +23 -0
  44. package/dist/{verify-375KUB3Y.js → chunk-PCZJO5TI.js} +127 -42
  45. package/dist/chunk-QQK64KLB.js +1360 -0
  46. package/dist/{chunk-SRDMMSEP.js → chunk-QQL5RT5M.js} +979 -1619
  47. package/dist/{chunk-LZSJBIVT.js → chunk-QWU7ZBO7.js} +70 -720
  48. package/dist/{chunk-2TLUCQVG.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-PIWWS5BL.js → chunk-UZHIZC5S.js} +7 -7
  55. package/dist/{chunk-UFIIWP2H.js → chunk-VAWWTKDP.js} +8 -8
  56. package/dist/{chunk-COU2UHX6.js → chunk-VEZEGCGW.js} +170 -2
  57. package/dist/{chunk-LW6DSM3M.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-PAJK6MAQ.js → chunk-X6COSD2O.js} +5 -5
  63. package/dist/chunk-ZGVHUX3M.js +66 -0
  64. package/dist/{chunk-LWLEKMDQ.js → chunk-ZYKPLLNQ.js} +510 -547
  65. package/dist/cli/index.js +27 -23
  66. package/dist/{clio-JOU4FXVA.js → clio-J5JIOIDS.js} +7 -6
  67. package/dist/{code-nav-7AX6FYE6.js → code-nav-AXCXSBHX.js} +5 -3
  68. package/dist/{config-XCDVKR23.js → config-OEBMIN2U.js} +37 -27
  69. package/dist/{configure-4GAP54ZW.js → configure-PUQOSIXQ.js} +16 -13
  70. package/dist/{context-5VKGUVJJ.js → context-EKDCKUUZ.js} +82 -7
  71. package/dist/{context-4UOGGLQ5.js → context-MGSE4Z2T.js} +33 -23
  72. package/dist/{context-77FM5DV5.js → context-URSXPBCK.js} +17 -9
  73. package/dist/{context-clear-XXJRLCJJ.js → context-clear-KDAJRNUK.js} +33 -23
  74. package/dist/context-working-set-SBKMPPI2.js +1552 -0
  75. package/dist/{dispatch-runner-QPRDDBDX.js → dispatch-runner-MSWN72NK.js} +43 -29
  76. package/dist/{doctor-HR46URBJ.js → doctor-7BSE27PJ.js} +10 -10
  77. package/dist/{eval-XSSNATB4.js → eval-IZGDOO4H.js} +9 -8
  78. package/dist/{evidence-6HG2PY2B.js → evidence-SR7WXB5B.js} +51 -23
  79. package/dist/{evolve-K7YU3NCY.js → evolve-K7VE2CBX.js} +30 -20
  80. package/dist/{fleet-VY3HHKN6.js → fleet-7XMJNQNF.js} +48 -38
  81. package/dist/{fleet-preflight-DDN536IT.js → fleet-preflight-AQNAH644.js} +3 -3
  82. package/dist/{init-JYGXI3FK.js → init-JGNPAYXT.js} +41 -31
  83. package/dist/{memory-WFZMGYHX.js → memory-4ALKDJ4Q.js} +32 -22
  84. package/dist/{models-I5QWSEOM.js → models-ZMMLFJNN.js} +22 -19
  85. package/dist/{monitor-GE4ID3IA.js → monitor-2F3T5KHP.js} +55 -43
  86. package/dist/{orchestrator-EM5MC3HM.js → orchestrator-ORHT43JB.js} +572 -388
  87. package/dist/{reset-L2FQEE3E.js → reset-NXGTYNUO.js} +4 -3
  88. package/dist/{run-ZU3QMZPZ.js → run-RF4WJGMT.js} +51 -41
  89. package/dist/{share-S5BZQC5I.js → share-UT3W6E4M.js} +5 -4
  90. package/dist/{skills-X5VXCRNQ.js → skills-PSACKC5Q.js} +2 -2
  91. package/dist/{skills-eval-WKIHWTHR.js → skills-eval-WJSI55RZ.js} +34 -24
  92. package/dist/{targets-SNCPI2NR.js → targets-PIIRAOYS.js} +23 -20
  93. package/dist/{terminal-lease-BNAHVHBS.js → terminal-lease-ULWXWNVY.js} +4 -3
  94. package/dist/{upgrade-JQHHPQ4K.js → upgrade-346TZ6AV.js} +18 -17
  95. package/dist/{usage-OR4O5SMZ.js → usage-6KKXR32N.js} +34 -24
  96. package/dist/verifiers-4UUM6TEE.js +1214 -0
  97. package/dist/verify-X5HDROLA.js +25 -0
  98. package/dist/{wiki-generate-UEXP2ARI.js → wiki-generate-7STOCIFZ.js} +42 -31
  99. package/dist/worker/entry.js +33 -24
  100. package/docs/README.md +7 -6
  101. package/docs/acp.md +1 -1
  102. package/docs/alcf-provider.md +1 -1
  103. package/docs/architecture.md +2 -2
  104. package/docs/artifact-versions.md +1 -1
  105. package/docs/built-in-agents.md +1 -1
  106. package/docs/capacity-and-scheduling.md +1 -1
  107. package/docs/commands-and-modes.md +53 -21
  108. package/docs/config-knobs-audit.md +1 -2
  109. package/docs/configuration-and-targets.md +15 -1
  110. package/docs/context-engine.md +64 -12
  111. package/docs/context-working-set.md +194 -0
  112. package/docs/development-pipeline.md +1 -1
  113. package/docs/documentation-coverage.md +5 -5
  114. package/docs/documentation-guide.md +6 -5
  115. package/docs/environment-variables.md +2 -1
  116. package/docs/eval-runner.md +1 -1
  117. package/docs/evals-internal.md +14 -1
  118. package/docs/evidence-and-memory.md +74 -2
  119. package/docs/evolution.md +1 -1
  120. package/docs/exit-codes-and-output.md +1 -1
  121. package/docs/extensions-and-sharing.md +2 -2
  122. package/docs/fleet-dispatch.md +22 -7
  123. package/docs/glossary.md +21 -1
  124. package/docs/installation-and-lifecycle.md +2 -2
  125. package/docs/middleware-and-components.md +1 -1
  126. package/docs/model-catalog.md +7 -9
  127. package/docs/observability.md +4 -4
  128. package/docs/proactive-memory.md +1 -1
  129. package/docs/prompt-envelope-and-tools.md +4 -4
  130. package/docs/provider-adapter-cookbook.md +1 -1
  131. package/docs/release-cut-checklist.md +31 -31
  132. package/docs/safety-model.md +23 -4
  133. package/docs/scientific-validation.md +21 -3
  134. package/docs/session-lifecycle.md +3 -3
  135. package/docs/skills-marketplace.md +1 -1
  136. package/docs/tool-usage.md +79 -12
  137. package/docs/trace-store.md +1 -1
  138. package/docs/troubleshooting.md +1 -1
  139. package/docs/tui-design.md +1 -1
  140. package/docs/worker-dispatch-mechanics.md +11 -1
  141. package/package.json +8 -11
  142. package/skills/meta/clio-test/SKILL.md +20 -17
  143. package/skills/meta/clio-test/evals.md +3 -3
  144. package/skills/meta/clio-test/references/harness.md +35 -6
  145. package/skills/meta/clio-test/references/test-map.md +20 -10
  146. package/skills/registry.yaml +2 -2
  147. package/skills/skill-marketplace.json +1 -1
  148. package/src/cli/context-working-set.ts +513 -0
  149. package/src/cli/context.ts +8 -0
  150. package/src/cli/evidence.ts +20 -2
  151. package/src/cli/index.ts +4 -0
  152. package/src/cli/verifiers.ts +325 -0
  153. package/src/core/bash-exec.ts +39 -14
  154. package/src/core/bus-events.ts +19 -4
  155. package/src/core/config.ts +54 -0
  156. package/src/core/defaults.ts +50 -3
  157. package/src/core/verification-scripts.ts +6 -0
  158. package/src/domains/agents/builtins/verifier.md +3 -0
  159. package/src/domains/config/classify.ts +1 -0
  160. package/src/domains/context/working-set/contract.ts +161 -0
  161. package/src/domains/context/working-set/defaults.ts +28 -0
  162. package/src/domains/context/working-set/engine.ts +203 -0
  163. package/src/domains/context/working-set/fold.ts +62 -0
  164. package/src/domains/context/working-set/horizon.ts +38 -0
  165. package/src/domains/context/working-set/marker.ts +103 -0
  166. package/src/domains/context/working-set/path-index.ts +436 -0
  167. package/src/domains/context/working-set/payload.ts +152 -0
  168. package/src/domains/context/working-set/policies/age-horizon.ts +55 -0
  169. package/src/domains/context/working-set/policies/index.ts +21 -0
  170. package/src/domains/context/working-set/policies/structural.ts +160 -0
  171. package/src/domains/context/working-set/project.ts +132 -0
  172. package/src/domains/context/working-set/protect.ts +109 -0
  173. package/src/domains/context/working-set/recall.ts +177 -0
  174. package/src/domains/context/working-set/replay/controls.ts +112 -0
  175. package/src/domains/context/working-set/replay/load-clio.ts +199 -0
  176. package/src/domains/context/working-set/replay/metrics.ts +185 -0
  177. package/src/domains/context/working-set/replay/reference-graph.ts +79 -0
  178. package/src/domains/context/working-set/replay/report.ts +139 -0
  179. package/src/domains/context/working-set/replay/runner.ts +325 -0
  180. package/src/domains/context/working-set/replay/synthetic.ts +422 -0
  181. package/src/domains/context/working-set/replay/trace.ts +21 -0
  182. package/src/domains/context/working-set/visible.ts +54 -0
  183. package/src/domains/evidence/build.ts +112 -45
  184. package/src/domains/evidence/eval.ts +24 -7
  185. package/src/domains/evidence/index.ts +53 -0
  186. package/src/domains/evidence/ordering.ts +12 -0
  187. package/src/domains/evidence/run-trust.ts +221 -0
  188. package/src/domains/evidence/store.ts +46 -6
  189. package/src/domains/evidence/trust-status.ts +854 -0
  190. package/src/domains/evidence/types.ts +26 -0
  191. package/src/domains/middleware/memory-intervention.ts +3 -0
  192. package/src/domains/middleware/stalled-turn.ts +165 -4
  193. package/src/domains/safety/autonomy.ts +1 -1
  194. package/src/domains/safety/default-path-policy.ts +8 -0
  195. package/src/domains/safety/finish-contract.ts +4 -3
  196. package/src/domains/safety/policy-engine.ts +48 -6
  197. package/src/domains/session/compaction/compact.ts +23 -1
  198. package/src/domains/session/compaction/cut-point.ts +2 -0
  199. package/src/domains/session/compaction/tokens.ts +16 -1
  200. package/src/domains/session/context-ledger.ts +2 -0
  201. package/src/domains/session/entries.ts +107 -1
  202. package/src/domains/session/manager.ts +9 -2
  203. package/src/domains/session/migrations/index.ts +22 -3
  204. package/src/engine/acp/server.ts +3 -0
  205. package/src/engine/agent.ts +18 -1
  206. package/src/engine/session.ts +9 -3
  207. package/src/entry/orchestrator.ts +16 -4
  208. package/src/interactive/chat-loop-messages.ts +18 -6
  209. package/src/interactive/chat-panel.ts +17 -1
  210. package/src/interactive/chat-renderer.ts +30 -21
  211. package/src/interactive/context-meter.ts +10 -0
  212. package/src/interactive/context-overlay.ts +81 -6
  213. package/src/interactive/context-recall-command.ts +110 -0
  214. package/src/interactive/interactive-slash-runtime.ts +37 -1
  215. package/src/interactive/model-session-replay.ts +21 -0
  216. package/src/interactive/overlay-general-openers.ts +6 -0
  217. package/src/interactive/overlay-session-lifecycle.ts +8 -4
  218. package/src/interactive/renderers/tool-execution.ts +18 -2
  219. package/src/interactive/session-transcript.ts +2 -2
  220. package/src/interactive/slash-commands.ts +29 -2
  221. package/src/interactive/turn-context.ts +238 -88
  222. package/src/interactive/turn-middleware.ts +6 -6
  223. package/src/tools/agent-tools.ts +11 -4
  224. package/src/tools/bash.ts +144 -82
  225. package/src/tools/builtin-tool-catalog.ts +11 -5
  226. package/src/tools/context/index.ts +105 -3
  227. package/src/tools/context/surface.ts +3 -2
  228. package/src/tools/core-bootstrap.ts +21 -0
  229. package/src/tools/dispatch-runner.ts +9 -7
  230. package/src/tools/monitor.ts +28 -20
  231. package/src/tools/registry.ts +59 -7
  232. package/src/tools/result-disposition.ts +550 -0
  233. package/src/tools/result-shaping.ts +262 -19
  234. package/src/tools/safe-exec.ts +2 -0
  235. package/src/tools/verify/authoring.ts +1119 -0
  236. package/src/tools/verify/catalog.ts +346 -0
  237. package/src/tools/verify/index.ts +13 -3
  238. package/src/tools/verify/scripts.ts +135 -37
  239. package/src/tools/verify/surface.ts +9 -5
  240. package/src/tools/worker-evidence.ts +35 -12
  241. package/dist/chunk-J7CWMCQD.js +0 -255
  242. package/dist/chunk-T6YILFSB.js +0 -80
  243. package/dist/chunk-VAKQQHWR.js +0 -434
  244. package/dist/chunk-VPAYEGVX.js +0 -184
@@ -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
+ }
@@ -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
+ }