@iowarp/clio-coder 0.5.1 → 0.5.2

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 (263) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/CHANGELOG.md +24 -0
  3. package/CONTRIBUTING.md +9 -0
  4. package/README.md +3 -3
  5. package/ROADMAP.md +30 -5
  6. package/dist/{acp-X3N7JDGS.js → acp-F6PGOFZ4.js} +2 -2
  7. package/dist/{agents-UYGSMAWR.js → agents-ZHP4R6EX.js} +22 -21
  8. package/dist/assets/codewiki.json +1 -1
  9. package/dist/{auth-CEDCO6O6.js → auth-N75U2H3Z.js} +4 -4
  10. package/dist/{chunk-DGMY2DWE.js → chunk-23UIXH6Z.js} +3 -3
  11. package/dist/{chunk-QTYRMBW6.js → chunk-36XVK5P5.js} +5 -5
  12. package/dist/{chunk-BIQLF3NZ.js → chunk-3DZ4LPLS.js} +6 -6
  13. package/dist/{chunk-7VNLPEUG.js → chunk-3GSPCWFX.js} +12 -12
  14. package/dist/{chunk-CRHPCPZP.js → chunk-4KC4CHKJ.js} +2 -2
  15. package/dist/{chunk-M5FPIZQW.js → chunk-4WJBWKMI.js} +2 -2
  16. package/dist/{chunk-KBUTSJSR.js → chunk-4XBYHXGK.js} +2 -2
  17. package/dist/{chunk-6W75D37F.js → chunk-53P775GE.js} +6 -3
  18. package/dist/{chunk-XZWSP67B.js → chunk-5APYAXCB.js} +2 -2
  19. package/dist/{chunk-A7G7OUND.js → chunk-67HPYNTJ.js} +2 -2
  20. package/dist/{chunk-5JQR5B4D.js → chunk-6KV4KCQZ.js} +4 -4
  21. package/dist/{chunk-V4XJWDHR.js → chunk-6UKFRTXI.js} +2 -1
  22. package/dist/{chunk-NDPF2N2L.js → chunk-7DW7CJW2.js} +2 -2
  23. package/dist/{chunk-5KQEUIWM.js → chunk-7LUEQODV.js} +4 -4
  24. package/dist/{chunk-LNOXCGN2.js → chunk-7UFDIOBT.js} +3 -3
  25. package/dist/{chunk-DXSETN72.js → chunk-7WUGNN43.js} +2 -2
  26. package/dist/{chunk-GW2KVAVI.js → chunk-AA3GRZWS.js} +2 -2
  27. package/dist/{chunk-6B76U65H.js → chunk-ADO3OQIC.js} +3 -3
  28. package/dist/{chunk-UX2L5CUD.js → chunk-AI36V3ON.js} +3 -3
  29. package/dist/{chunk-B7GY6QER.js → chunk-B26OUMJW.js} +2 -2
  30. package/dist/{chunk-LAR7DX5E.js → chunk-B5TASTCK.js} +153 -62
  31. package/dist/{chunk-TGWZXJ7L.js → chunk-BEMQK5Q7.js} +92 -84
  32. package/dist/{chunk-RNDCROSG.js → chunk-CNXU7TR2.js} +3 -3
  33. package/dist/{chunk-AKQ3N3L4.js → chunk-CVMUXT64.js} +35 -66
  34. package/dist/{chunk-733ZBT5P.js → chunk-E74MJF6G.js} +4 -4
  35. package/dist/{chunk-T2MDZES3.js → chunk-EBMTBE76.js} +3 -3
  36. package/dist/{chunk-OZ76TLGU.js → chunk-EEITU7S6.js} +2 -2
  37. package/dist/{chunk-7KI4ZSEB.js → chunk-ELDJWJKM.js} +97 -14
  38. package/dist/{chunk-GCQFN7YI.js → chunk-EMYEQLWO.js} +2 -2
  39. package/dist/{chunk-7FHSA7VB.js → chunk-EQPFEDFV.js} +222 -127
  40. package/dist/{chunk-TDGYTWM6.js → chunk-F4I3H56H.js} +2 -2
  41. package/dist/chunk-F6KCJO3U.js +523 -0
  42. package/dist/{chunk-FGSQHUCK.js → chunk-F6VKWTTY.js} +5 -5
  43. package/dist/{chunk-UJD5MPP6.js → chunk-FLNXQQ5B.js} +2 -2
  44. package/dist/{chunk-GGEEG7CD.js → chunk-FOSRJ2VZ.js} +4 -4
  45. package/dist/{chunk-ZARZ4POR.js → chunk-G5ZDQQE3.js} +3 -3
  46. package/dist/{chunk-QM7HFPQQ.js → chunk-G62JBPXN.js} +7 -7
  47. package/dist/{chunk-ZJPORZTC.js → chunk-GIAI7A2K.js} +2 -2
  48. package/dist/chunk-GPTR6OTU.js +2551 -0
  49. package/dist/{chunk-EZD2BXGP.js → chunk-GUFQPTQK.js} +2 -2
  50. package/dist/{chunk-OPD7GL6F.js → chunk-H6FQVNZC.js} +8 -7
  51. package/dist/chunk-H7REJ7L5.js +80 -0
  52. package/dist/{chunk-EYTLV3W3.js → chunk-HTWTORF7.js} +2 -2
  53. package/dist/{chunk-DF6PI6GN.js → chunk-I6UKQPGS.js} +3 -3
  54. package/dist/{chunk-ISHUS7HC.js → chunk-JRW236JT.js} +3 -3
  55. package/dist/{chunk-DE2LU267.js → chunk-KKAJGT3B.js} +3 -2
  56. package/dist/{chunk-OV62D6KO.js → chunk-KQMYL5GR.js} +3 -3
  57. package/dist/{chunk-WAISUJNQ.js → chunk-KWMFC7FN.js} +3 -3
  58. package/dist/{chunk-4D7QM7S5.js → chunk-LXYHCCIZ.js} +9 -9
  59. package/dist/{chunk-NFSK2VNU.js → chunk-LZMTVVN4.js} +2 -2
  60. package/dist/{chunk-ZNTGRDFA.js → chunk-M5PMRZSR.js} +123 -37
  61. package/dist/chunk-MMZMM6SW.js +131 -0
  62. package/dist/{chunk-FFYTC2KQ.js → chunk-MNABNEBX.js} +2 -2
  63. package/dist/{chunk-XMDBTYCH.js → chunk-N3CWOGVF.js} +3 -3
  64. package/dist/{chunk-VYRQHORQ.js → chunk-NMQSO6Z6.js} +4 -3
  65. package/dist/{chunk-4SYHGVDE.js → chunk-OQNCLZI4.js} +4 -4
  66. package/dist/{chunk-EG4ARXPC.js → chunk-OX6QTA4N.js} +3 -3
  67. package/dist/{chunk-P4GNZPXO.js → chunk-OYUS3UZP.js} +2 -2
  68. package/dist/{chunk-VWKR2NN5.js → chunk-PF36KOGI.js} +290 -111
  69. package/dist/{chunk-LFYA3HYH.js → chunk-PIU6BXXW.js} +2 -2
  70. package/dist/{chunk-EKV4UBCM.js → chunk-PZBQUJ2F.js} +2 -2
  71. package/dist/{chunk-7RET77VB.js → chunk-Q2HKY32Y.js} +2 -2
  72. package/dist/{chunk-VMTCZ2GR.js → chunk-Q4RMNWMZ.js} +4 -4
  73. package/dist/chunk-Q6FRR3AQ.js +48 -0
  74. package/dist/{chunk-6RJMYYIE.js → chunk-QJSIAJBY.js} +3 -3
  75. package/dist/{chunk-DOUISWEE.js → chunk-QOJ4ERMU.js} +5 -5
  76. package/dist/{chunk-IFPDAD7R.js → chunk-RWSRKXLT.js} +3 -3
  77. package/dist/{chunk-T33IYTZM.js → chunk-SAASVMIY.js} +3 -3
  78. package/dist/{chunk-2DTIWSAW.js → chunk-SEQDH6JC.js} +3 -3
  79. package/dist/{chunk-BRCOSE7O.js → chunk-SZUO5BSE.js} +2 -2
  80. package/dist/{chunk-HOEDA42N.js → chunk-T7HZ7KJF.js} +2 -2
  81. package/dist/{chunk-UCINIRHW.js → chunk-TPCZXWLT.js} +3 -3
  82. package/dist/{chunk-ZUKUCZYZ.js → chunk-TQ2KTH4A.js} +2 -2
  83. package/dist/{chunk-4WOOTKFD.js → chunk-U3FETNQB.js} +341 -20
  84. package/dist/{chunk-5B3RTYAK.js → chunk-UGDSB4AN.js} +2 -2
  85. package/dist/{chunk-VKHLUZNO.js → chunk-UZ7YBL43.js} +3 -3
  86. package/dist/{chunk-OXWRE2QJ.js → chunk-VKMSEO7Y.js} +4 -4
  87. package/dist/{chunk-FBXTHPNZ.js → chunk-VLX5VZ35.js} +5 -5
  88. package/dist/{chunk-KIAUZGKF.js → chunk-WAGBMMNX.js} +5 -5
  89. package/dist/{chunk-BFOSV5EZ.js → chunk-WX2YCH7F.js} +2 -2
  90. package/dist/{chunk-SAL2SBP4.js → chunk-XBIGUILU.js} +25 -10
  91. package/dist/{chunk-XOH3SD7H.js → chunk-XKYBFRWR.js} +5 -5
  92. package/dist/{chunk-U5QU5ZOD.js → chunk-XYPWFSU5.js} +3 -3
  93. package/dist/{chunk-YBUECLAF.js → chunk-YEJQDODA.js} +2 -2
  94. package/dist/{chunk-YQ6XEFVK.js → chunk-Z74OGONW.js} +39 -7
  95. package/dist/{chunk-I4ELN5BX.js → chunk-ZTNOEJRI.js} +2 -2
  96. package/dist/cli/index.js +25 -25
  97. package/dist/{clio-Z3JTEL2I.js → clio-4IKFE25F.js} +2 -2
  98. package/dist/{clio-context-tools-GR4OHFSO.js → clio-context-tools-OHJXYX4R.js} +25 -23
  99. package/dist/{code-nav-4X3OI6DK.js → code-nav-XPM3MY5I.js} +7 -7
  100. package/dist/{config-QYIRZHI7.js → config-343YS25T.js} +42 -40
  101. package/dist/{config-graph-TS3SGGQW.js → config-graph-WI263KDX.js} +42 -40
  102. package/dist/{configure-OX7KVJTQ.js → configure-GZDG4HER.js} +21 -21
  103. package/dist/{context-2BNF6HTP.js → context-4OFO7N7Y.js} +12 -12
  104. package/dist/{context-GTMUGAR4.js → context-JLK2RLOG.js} +41 -39
  105. package/dist/{context-NNLBES2Y.js → context-O6DIWCF6.js} +23 -21
  106. package/dist/{context-clear-FVJQBBL7.js → context-clear-MR7KOGQI.js} +41 -39
  107. package/dist/{context-working-set-MOE72SD6.js → context-working-set-YZAD3KWO.js} +14 -13
  108. package/dist/{data-tool-JJPBGMQJ.js → data-tool-EKD7DFHG.js} +7 -7
  109. package/dist/{detail-QZM3QAAU.js → detail-YZPHFEVJ.js} +42 -40
  110. package/dist/{dispatch-runner-2KSQO5FU.js → dispatch-runner-VKVXVTWJ.js} +43 -41
  111. package/dist/{doctor-2U6YR5NH.js → doctor-53AVQXAO.js} +16 -15
  112. package/dist/{doctor-deep-2EJQSHIZ.js → doctor-deep-BLRB2NCG.js} +5 -5
  113. package/dist/{eval-ZKK77JWZ.js → eval-P3BQSLQE.js} +21 -20
  114. package/dist/{evidence-POSEQW4Q.js → evidence-6LLI6SZH.js} +43 -41
  115. package/dist/{evidence-FMP7KZZ2.js → evidence-HTN3JLUO.js} +41 -39
  116. package/dist/{evidence-PJTOE7JA.js → evidence-NJRE3R5H.js} +41 -39
  117. package/dist/{evolve-JT6PAMZE.js → evolve-WWZHJVJP.js} +41 -39
  118. package/dist/{fleet-4XR2ZEOF.js → fleet-GGZ4BVJE.js} +63 -61
  119. package/dist/{fleet-6JJKNIMV.js → fleet-QG3IIWWM.js} +43 -41
  120. package/dist/{fleet-commands-L6WFT7KZ.js → fleet-commands-J2ZIKISQ.js} +7 -7
  121. package/dist/{fleet-decisions-BTQMWU5A.js → fleet-decisions-Y2Q7375Q.js} +8 -8
  122. package/dist/{fleet-graph-553DZ67Q.js → fleet-graph-TCBCCCBP.js} +11 -11
  123. package/dist/{fleet-inspect-SWJMFVDP.js → fleet-inspect-KOLKBC56.js} +43 -41
  124. package/dist/{fleet-preflight-KSIHWQAU.js → fleet-preflight-44UNCC4Y.js} +26 -24
  125. package/dist/{fleet-validate-7NLAE26R.js → fleet-validate-K2FXRZVA.js} +13 -13
  126. package/dist/{fleet-verify-QM6XU2KL.js → fleet-verify-AS444WDW.js} +41 -39
  127. package/dist/{fleet-view-RLKFHFCM.js → fleet-view-X4JU4J6O.js} +42 -40
  128. package/dist/gui/ops-worker.js +9 -9
  129. package/dist/gui/reads-worker.js +9 -8
  130. package/dist/{init-VBUPHABR.js → init-VSOWFHVL.js} +53 -51
  131. package/dist/{interactive-JCVBPVYT.js → interactive-JRCBENLZ.js} +154 -92
  132. package/dist/{interop-J7UOBOSF.js → interop-JR6BCY7R.js} +10 -10
  133. package/dist/{inventory-54G7FSVY.js → inventory-3JUGLVJI.js} +42 -40
  134. package/dist/{library-ROZAMUIL.js → library-7BOH3Y7N.js} +15 -15
  135. package/dist/{library-B3UPGY4V.js → library-7PFONXUI.js} +9 -9
  136. package/dist/{library-MXQA5DLT.js → library-TUYZMFIA.js} +7 -7
  137. package/dist/{library-import-2LTWOVBD.js → library-import-TJVXKLDK.js} +10 -10
  138. package/dist/{library-inventory-LJ3XHLWO.js → library-inventory-MD3AYTP4.js} +9 -9
  139. package/dist/{library-validation-DARHHUNW.js → library-validation-ZDLITEA3.js} +7 -7
  140. package/dist/{memory-5BPQSSOP.js → memory-Q2YVOKK5.js} +41 -39
  141. package/dist/{models-R6HILX6X.js → models-M7BOCW7F.js} +17 -16
  142. package/dist/{monitor-HVRKSFEQ.js → monitor-2HD3MC67.js} +46 -44
  143. package/dist/{orchestrator-OR7V4YWK.js → orchestrator-OUW3ZN6Y.js} +1890 -222
  144. package/dist/{panes-A743ONZ5.js → panes-TTPPSYCL.js} +3 -3
  145. package/dist/{preload-KLXFOX2F.js → preload-A47C2NUF.js} +41 -39
  146. package/dist/{providers-CMETPZNS.js → providers-T43W7EL4.js} +4 -4
  147. package/dist/{resources-FY6XYFN2.js → resources-TP6X2V6W.js} +20 -11
  148. package/dist/{run-UIDRUYAY.js → run-LDSBPMCS.js} +60 -58
  149. package/dist/{share-CAFMMQLM.js → share-URHKKMHR.js} +9 -9
  150. package/dist/{skills-ERKQUC7L.js → skills-E477MOOO.js} +13 -11
  151. package/dist/{skills-eval-JDAXOPWC.js → skills-eval-AIBR2L7H.js} +582 -92
  152. package/dist/{skills-inventory-TVUYJDVN.js → skills-inventory-HLMKY3R5.js} +13 -11
  153. package/dist/{slash-commands-MNKQ5QP3.js → slash-commands-LIIZKA5Y.js} +26 -24
  154. package/dist/{startup-background-PJMWTKVO.js → startup-background-JR2KDK6R.js} +42 -40
  155. package/dist/{steer-KKYXJSH4.js → steer-D7RTAPCM.js} +3 -3
  156. package/dist/{system-IVVZRKGI.js → system-X2P5N4IY.js} +14 -13
  157. package/dist/{targets-RFRUMCQ5.js → targets-VFFAKUFM.js} +25 -24
  158. package/dist/{tasks-6L2JJRC5.js → tasks-ERAWZA22.js} +6 -6
  159. package/dist/{trace-LA5MVR4Y.js → trace-MA5BT7XY.js} +3 -3
  160. package/dist/{usage-LA33SG3R.js → usage-WYFY2L6M.js} +49 -47
  161. package/dist/{verifiers-ECA5GIHL.js → verifiers-TYIW2XKF.js} +7 -7
  162. package/dist/{verify-R6QQCCEB.js → verify-242UFYTP.js} +6 -6
  163. package/dist/{web-fetch-PVZNMBOB.js → web-fetch-LRAGYU6Y.js} +3 -3
  164. package/dist/{wiki-generate-HL73TICA.js → wiki-generate-OFKOX2T6.js} +52 -50
  165. package/dist/worker/entry.js +30 -28
  166. package/docs/architecture/context-engine.md +2 -0
  167. package/docs/guide/context-continuity.md +49 -0
  168. package/docs/guide/proactive-memory.md +3 -0
  169. package/library/registry.yaml +292 -32
  170. package/library/skills/README.md +27 -2
  171. package/library/skills/context/context-handoff/SKILL.md +10 -4
  172. package/library/skills/meta/clio-coder-dev/SKILL.md +92 -88
  173. package/library/skills/meta/clio-coder-dev/evals.md +51 -46
  174. package/library/skills/meta/clio-coder-dev/plugin.json +2 -2
  175. package/library/skills/meta/clio-coder-dev/references/change-map.md +49 -0
  176. package/library/skills/meta/clio-coder-test/SKILL.md +89 -142
  177. package/library/skills/meta/clio-coder-test/evals.md +58 -62
  178. package/library/skills/meta/clio-coder-test/plugin.json +2 -2
  179. package/library/skills/meta/clio-coder-test/references/harness.md +3 -3
  180. package/library/skills/meta/clio-coder-test/references/lifecycle-validation.md +30 -0
  181. package/library/skills/meta/clio-coder-test/references/test-map.md +60 -89
  182. package/library/skills/registry.yaml +5 -5
  183. package/library/skills/skill-marketplace.json +12 -14
  184. package/package.json +1 -1
  185. package/src/cli/skills-eval.ts +1064 -54
  186. package/src/core/clio-repo.ts +3 -0
  187. package/src/core/tool-names.ts +1 -0
  188. package/src/domains/context/budget/inspection.ts +9 -0
  189. package/src/domains/context/budget/live-view.ts +418 -0
  190. package/src/domains/context/budget/pressure.ts +393 -0
  191. package/src/domains/context/budget/request-fit.ts +15 -0
  192. package/src/domains/evidence/build.ts +24 -0
  193. package/src/domains/memory/commit-state.ts +160 -0
  194. package/src/domains/memory/operations.ts +18 -10
  195. package/src/domains/memory/prompt-cache.ts +95 -0
  196. package/src/domains/memory/prompt-section.ts +50 -7
  197. package/src/domains/memory/relevance.ts +93 -0
  198. package/src/domains/memory/restoration.ts +78 -0
  199. package/src/domains/memory/store.ts +39 -1
  200. package/src/domains/middleware/memory-intervention.ts +116 -24
  201. package/src/domains/prompts/compiler.ts +1 -1
  202. package/src/domains/prompts/extension.ts +38 -1
  203. package/src/domains/resources/index.ts +11 -0
  204. package/src/domains/resources/skills/catalog-view.ts +571 -0
  205. package/src/domains/resources/skills/lexical-match.ts +136 -0
  206. package/src/domains/resources/skills/loader.ts +38 -0
  207. package/src/domains/resources/skills/promotion.ts +1 -55
  208. package/src/domains/resources/skills/provenance-pin.ts +50 -20
  209. package/src/domains/safety/action-classifier.ts +1 -0
  210. package/src/domains/session/compaction/branch-summary.ts +4 -1
  211. package/src/domains/session/compaction/compact.ts +18 -2
  212. package/src/domains/session/compaction/cut-point.ts +20 -1
  213. package/src/domains/session/compaction/tokens.ts +48 -3
  214. package/src/domains/session/context-accounting.ts +8 -1
  215. package/src/domains/session/continuity/carry.ts +59 -0
  216. package/src/domains/session/continuity/contract.ts +592 -0
  217. package/src/domains/session/continuity/evidence.ts +290 -0
  218. package/src/domains/session/continuity/fold.ts +1075 -0
  219. package/src/domains/session/continuity/note.ts +79 -0
  220. package/src/domains/session/continuity/operator-request.ts +104 -0
  221. package/src/domains/session/continuity/persistence.ts +408 -0
  222. package/src/domains/session/continuity/ports.ts +231 -0
  223. package/src/domains/session/continuity/projection.ts +538 -0
  224. package/src/domains/session/continuity/validate.ts +352 -0
  225. package/src/domains/session/entries.ts +50 -3
  226. package/src/domains/session/index.ts +48 -0
  227. package/src/domains/session/migrations/index.ts +7 -3
  228. package/src/domains/session/tree/fork.ts +15 -1
  229. package/src/engine/agent.ts +133 -9
  230. package/src/engine/session.ts +13 -6
  231. package/src/entry/orchestrator.ts +114 -31
  232. package/src/interactive/chat-loop.ts +216 -60
  233. package/src/interactive/chat-renderer.ts +99 -8
  234. package/src/interactive/context-overlay.ts +24 -3
  235. package/src/interactive/continuity-controller.ts +526 -0
  236. package/src/interactive/footer/dashboard.ts +12 -1
  237. package/src/interactive/footer/pages.ts +15 -7
  238. package/src/interactive/footer/widgets.ts +32 -7
  239. package/src/interactive/interactive-application.ts +2 -0
  240. package/src/interactive/interactive-slash-runtime.ts +43 -6
  241. package/src/interactive/model-session-replay.ts +130 -3
  242. package/src/interactive/output-reserve.ts +35 -0
  243. package/src/interactive/overlay-session-lifecycle.ts +18 -11
  244. package/src/interactive/session-transcript.ts +2 -1
  245. package/src/interactive/slash-commands.ts +20 -1
  246. package/src/interactive/turn-context.ts +685 -62
  247. package/src/interactive/turn-middleware.ts +39 -6
  248. package/src/interactive/turn-persistence.ts +15 -2
  249. package/src/interactive/turn-runtime.ts +48 -5
  250. package/src/tools/agent-tools.ts +3 -1
  251. package/src/tools/bootstrap.ts +6 -0
  252. package/src/tools/builtin-tool-catalog.ts +9 -0
  253. package/src/tools/context/index.ts +148 -113
  254. package/src/tools/context/surface.ts +11 -7
  255. package/src/tools/core-bootstrap.ts +4 -0
  256. package/src/tools/observation.ts +8 -2
  257. package/src/tools/policy.ts +3 -0
  258. package/src/tools/self-compact.ts +31 -0
  259. package/src/tools/surface.ts +1 -0
  260. package/src/tools/tasks.ts +1 -1
  261. package/dist/chunk-3OODBJ45.js +0 -1325
  262. package/dist/chunk-CTFPFW3H.js +0 -44
  263. package/dist/chunk-I4Y4WKZR.js +0 -386
@@ -22,6 +22,7 @@ import { extractText, hasStructuredToolCall, toolNamesFromAgentState } from "./c
22
22
  import type { AgentRuntime, ChatTurnState } from "./turn-state.js";
23
23
 
24
24
  export interface TurnMiddlewareDeps {
25
+ memoryContentGuard?: (() => () => boolean) | undefined;
25
26
  state: ChatTurnState;
26
27
  middleware?: MiddlewareContract | undefined;
27
28
  session?: SessionContract | undefined;
@@ -50,6 +51,7 @@ export interface TurnMiddleware {
50
51
  tokensBefore?: number,
51
52
  ): void;
52
53
  flushPendingReminders(): string;
54
+ takePendingReminderProjection(): () => string;
53
55
  clearPendingReminders(): void;
54
56
  /**
55
57
  * Deliver a reminder produced after its own turn boundary already closed.
@@ -57,7 +59,7 @@ export interface TurnMiddleware {
57
59
  * value; the reminder joins the same buffer, ledger, and transcript path a
58
60
  * turn_end reminder takes.
59
61
  */
60
- injectDeferredReminder(message: string, severity?: MiddlewareReminderSeverity): void;
62
+ injectDeferredReminder(message: string, severity?: MiddlewareReminderSeverity, isCurrent?: () => boolean): void;
61
63
  /**
62
64
  * Deliver an operator-facing finding produced after its turn boundary closed.
63
65
  * The watchdog seam: its run settles well after the turn it reviewed, and
@@ -76,11 +78,29 @@ export function createTurnMiddleware(deps: TurnMiddlewareDeps): TurnMiddleware {
76
78
  // injections). The next accepted prompt flushes them into the model
77
79
  // request as one system-reminder block. Completed background reminders
78
80
  // can also drain at a native tool-batch boundary; session switches clear all.
79
- const pendingReminders: Array<{ message: string; severity: MiddlewareReminderSeverity; deferred: boolean }> = [];
81
+ const pendingReminders: Array<{
82
+ message: string;
83
+ severity: MiddlewareReminderSeverity;
84
+ deferred: boolean;
85
+ isCurrent?: (() => boolean) | undefined;
86
+ }> = [];
87
+ const dropStale = () => {
88
+ for (let index = pendingReminders.length - 1; index >= 0; index--) {
89
+ if (pendingReminders[index]?.isCurrent?.() === false) pendingReminders.splice(index, 1);
90
+ }
91
+ };
80
92
 
81
- const bufferReminder = (message: string, severity: MiddlewareReminderSeverity, deferred = false): void => {
93
+ const bufferReminder = (
94
+ message: string,
95
+ severity: MiddlewareReminderSeverity,
96
+ deferred = false,
97
+ isCurrent?: () => boolean,
98
+ ): void => {
99
+ dropStale();
100
+ isCurrent ??= message.startsWith("Memory:") ? deps.memoryContentGuard?.() : undefined;
101
+ if (isCurrent?.() === false) return;
82
102
  if (pendingReminders.some((entry) => entry.message === message && entry.severity === severity)) return;
83
- pendingReminders.push({ message, severity, deferred });
103
+ pendingReminders.push({ message, severity, deferred, isCurrent });
84
104
  };
85
105
 
86
106
  const runMiddlewareTurnHook = (input: MiddlewareHookInput): ReadonlyArray<MiddlewareEffect> => {
@@ -194,6 +214,7 @@ export function createTurnMiddleware(deps: TurnMiddlewareDeps): TurnMiddleware {
194
214
  [],
195
215
  );
196
216
  if (!current()) return false;
217
+ dropStale();
197
218
  const ready = pendingReminders.filter((entry) => entry.deferred);
198
219
  if (ready.length === 0) return false;
199
220
  const content = `<system-reminder>\n${[...new Set(ready.map((entry) => entry.message))].join("\n\n")}\n</system-reminder>`;
@@ -314,7 +335,17 @@ export function createTurnMiddleware(deps: TurnMiddlewareDeps): TurnMiddleware {
314
335
  });
315
336
  },
316
337
 
338
+ takePendingReminderProjection(): () => string {
339
+ const entries = pendingReminders.splice(0);
340
+ return () => {
341
+ const messages = [
342
+ ...new Set(entries.filter((entry) => entry.isCurrent?.() !== false).map((entry) => entry.message)),
343
+ ];
344
+ return messages.length ? `<system-reminder>\n${messages.join("\n\n")}\n</system-reminder>` : "";
345
+ };
346
+ },
317
347
  flushPendingReminders(): string {
348
+ dropStale();
318
349
  if (pendingReminders.length === 0) return "";
319
350
  // Severity affects host actions, not the text delivered to the model.
320
351
  // A paired continuation/info and reminder/warn still needs one copy.
@@ -327,14 +358,16 @@ export function createTurnMiddleware(deps: TurnMiddlewareDeps): TurnMiddleware {
327
358
  pendingReminders.length = 0;
328
359
  },
329
360
 
330
- injectDeferredReminder(message, severity = "advisory"): void {
361
+ injectDeferredReminder(message, severity = "advisory", isCurrent): void {
362
+ dropStale();
363
+ if (isCurrent?.() === false) return;
331
364
  const text = message.trim();
332
365
  if (text.length === 0) return;
333
366
  // A deferred reminder never carries hard-block authority: the turn it
334
367
  // could have interrupted is already over.
335
368
  const level: MiddlewareReminderSeverity = severity === "hard-block" ? "advisory" : severity;
336
369
  if (pendingReminders.some((entry) => entry.message === text)) return;
337
- bufferReminder(text, level, true);
370
+ bufferReminder(text, level, true, isCurrent);
338
371
  appendMiddlewareReminderEntry(text, level);
339
372
  deps.emitNotice(text);
340
373
  },
@@ -60,7 +60,11 @@ export interface TurnPersistenceDeps {
60
60
  export interface TurnPersistence {
61
61
  /** True when this exact assistant message object was already persisted. */
62
62
  wasPersisted(message: unknown): boolean;
63
- appendAssistantTurn(message: AgentMessage, timing?: AssistantCallTiming | null): void;
63
+ appendAssistantTurn(
64
+ message: AgentMessage,
65
+ timing?: AssistantCallTiming | null,
66
+ deliveryId?: string,
67
+ ): string | undefined;
64
68
  appendQueuedUserTurn(message: AgentMessage): void;
65
69
  appendToolCallTurn(event: Extract<AgentEvent, { type: "tool_execution_start" }>): void;
66
70
  appendToolResultTurn(
@@ -226,7 +230,11 @@ export function createTurnPersistence(deps: TurnPersistenceDeps): TurnPersistenc
226
230
  };
227
231
  };
228
232
 
229
- const appendAssistantTurn = (message: AgentMessage, timing?: AssistantCallTiming | null): void => {
233
+ const appendAssistantTurn = (
234
+ message: AgentMessage,
235
+ timing?: AssistantCallTiming | null,
236
+ deliveryId?: string,
237
+ ): string | undefined => {
230
238
  if (message?.role !== "assistant") return;
231
239
  if (persistedAssistantMessages.has(message)) return;
232
240
  // A loop-guard interrupt already persisted a durable closing turn with the
@@ -237,6 +245,10 @@ export function createTurnPersistence(deps: TurnPersistenceDeps): TurnPersistenc
237
245
  const failure = terminalFailureFromAssistantMessage(message);
238
246
  const payload = assistantSessionPayload(message, failure);
239
247
  if (timing) payload.timing = timing;
248
+ if (deliveryId) {
249
+ payload.continuityDeliveryId = deliveryId;
250
+ payload.stopReason = message.stopReason;
251
+ }
240
252
  const usage = (message as { usage?: Usage }).usage;
241
253
  if (usage && typeof usage === "object") {
242
254
  const backendTimings = (message as { backendTimings?: BackendCompletionTimings }).backendTimings;
@@ -279,6 +291,7 @@ export function createTurnPersistence(deps: TurnPersistenceDeps): TurnPersistenc
279
291
  tokens: traceUsage?.totalTokens ?? null,
280
292
  });
281
293
  if (!turnContinues) finishTracedTurn(failure ? "fail" : "success", failure?.errorMessage ?? null);
294
+ return turn.id;
282
295
  };
283
296
 
284
297
  return {
@@ -1,4 +1,8 @@
1
+ import { requestFits } from "../domains/context/budget/request-fit.js";
2
+ import { estimateInputTokensFromContext } from "../engine/apis/output-budget.js";
1
3
  import { resolvedRequestContext } from "../engine/context.js";
4
+ import type { ContinuityController } from "./continuity-controller.js";
5
+ import { resolveTurnOutputReserve } from "./output-reserve.js";
2
6
  /**
3
7
  * Turn runtime ownership: orchestrator target resolution, model synthesis,
4
8
  * live-agent construction and model hot-swap, thinking-level reconciliation,
@@ -133,6 +137,8 @@ export interface TurnRuntimeDeps {
133
137
  middlewareToolChoice: MiddlewareToolChoiceControl;
134
138
  persistence: TurnPersistence;
135
139
  context: TurnContext;
140
+ continuity?: ContinuityController;
141
+ hasQueuedSteering?: () => boolean;
136
142
  middleware: TurnMiddleware;
137
143
  retrySettings: () => RetrySettings;
138
144
  /** Stable Clio session id forwarded only to gateway runtimes that understand it. */
@@ -512,6 +518,37 @@ export function createTurnRuntime(deps: TurnRuntimeDeps): TurnRuntime {
512
518
  },
513
519
  maxRetryDelayMs: deps.retrySettings().maxDelayMs,
514
520
  ...(gatewaySessionId ? { sessionId: gatewaySessionId } : {}),
521
+ beforeToolBatch: ({ assistantMessage }) => {
522
+ const calls = assistantMessage.content.filter((block) => block.type === "toolCall");
523
+ if (calls.some((call) => call.name === "self_compact") && calls.length !== 1) {
524
+ return { reason: "self_compact must be the only call in its batch; no sibling was executed." };
525
+ }
526
+ return undefined;
527
+ },
528
+ beforeStreamRequest: ({ context: request }) => {
529
+ setGlobalDefaultMaxOutputTokens(deps.getSettings().chat.maxOutputTokens);
530
+ if (state.activeInterruptReason !== null || state.runtime !== localRuntime) {
531
+ return { block: true, reason: "Request ownership changed before invocation." };
532
+ }
533
+ if (deps.hasQueuedSteering?.() && deps.continuity?.admission().block === false) {
534
+ const handoff = deps.continuity.admission();
535
+ if (!handoff.block && handoff.correlationId)
536
+ return { block: true, reason: "Operator steering arrived during handoff preparation; handoff paused." };
537
+ }
538
+ const view = context.refreshLiveBudget();
539
+ const resolved = resolvedRequestContext(request);
540
+ const actual = state.synthesisToolLock
541
+ ? { ...resolved, systemPrompt: lockedSynthesisSystemPrompt(resolved.systemPrompt ?? "") }
542
+ : request;
543
+ const input = Math.max(estimateInputTokensFromContext(actual), view.inputTokens ?? 0);
544
+ const output = resolveTurnOutputReserve(localRuntime, input);
545
+ return requestFits(input, output, view.effectiveWindow)
546
+ ? (deps.continuity?.admission() ?? { block: false })
547
+ : {
548
+ block: true,
549
+ reason: `Context window exceeded: input ${input} + output ${output}, window ${view.effectiveWindow ?? "unknown"}.`,
550
+ };
551
+ },
515
552
  onStreamInvocation: () => {
516
553
  setGlobalDefaultMaxOutputTokens(deps.getSettings().chat.maxOutputTokens);
517
554
  apiCallStartedAt = performance.now();
@@ -634,19 +671,22 @@ export function createTurnRuntime(deps: TurnRuntimeDeps): TurnRuntime {
634
671
  stallTimer.unref?.();
635
672
  };
636
673
 
637
- handle.agent.prepareNextTurn = async (signal?: AbortSignal) => {
674
+ handle.agent.prepareNextTurnWithContext = async (_completed, signal?: AbortSignal) => {
638
675
  stallSuspendDepth += 1;
639
676
  try {
640
677
  if (signal?.aborted || state.activeInterruptReason !== null) return undefined;
641
- const contextChanged = await middleware.prepareToolContinuation(localRuntime, signal);
678
+ if (deps.hasQueuedSteering?.()) await deps.continuity?.pause();
679
+ const handoffChanged = (await deps.continuity?.settle(signal)) ?? false;
680
+ const contextChanged = (await middleware.prepareToolContinuation(localRuntime, signal)) || handoffChanged;
642
681
  const update = await context.postToolContinuationGuard(localRuntime, signal, contextChanged);
682
+ const restored = context.installMemoryRestoration(localRuntime);
643
683
  if (signal?.aborted || state.activeInterruptReason !== null) return undefined;
644
684
  // Compaction owns any replacement snapshot. Without one, publish the
645
685
  // already-accounted reminder through context, not message events that
646
686
  // would manufacture a new operator turn and reset task authority.
647
687
  return (
648
- update ??
649
- (contextChanged
688
+ (!restored ? update : undefined) ??
689
+ (contextChanged || restored
650
690
  ? {
651
691
  context: {
652
692
  messages: [...localRuntime.agent.state.messages],
@@ -950,7 +990,9 @@ export function createTurnRuntime(deps: TurnRuntimeDeps): TurnRuntime {
950
990
  apiMs: Math.round(Math.max(0, eventClock - apiCallStartedAt)),
951
991
  }
952
992
  : null;
953
- persistence.appendAssistantTurn(enrichedEvent.message, timing);
993
+ const correlationId = event.type === "message_end" ? handle.requestCorrelationId(event.message) : undefined;
994
+ const persistedId = persistence.appendAssistantTurn(enrichedEvent.message, timing, correlationId);
995
+ await deps.continuity?.response(persistedId, correlationId);
954
996
  if (isAssistant) apiCallStartedAt = null;
955
997
  const usage = (enrichedEvent.message as { usage?: Usage }).usage;
956
998
  if (isAssistant && usage && typeof usage === "object" && runFirstCallVerdict === null) {
@@ -974,6 +1016,7 @@ export function createTurnRuntime(deps: TurnRuntimeDeps): TurnRuntime {
974
1016
  : null;
975
1017
  }
976
1018
  if (enrichedEvent.type === "agent_end") {
1019
+ await deps.continuity?.pause();
977
1020
  clearStallTimer();
978
1021
  const terminal = pendingTerminalToolResult;
979
1022
  pendingTerminalToolResult = null;
@@ -497,5 +497,7 @@ export function resolveSessionTools(
497
497
  supportsImages: runtime.runtimeResolution.capabilityDecisions.vision === true,
498
498
  });
499
499
  if (telemetry) input.telemetry = telemetry;
500
- return resolveAgentTools(input);
500
+ return resolveAgentTools(input).filter(
501
+ (tool) => tool.name !== ToolNames.SelfCompact || !runtime.runtimeResolution.runtime.externalAgentLoop,
502
+ );
501
503
  }
@@ -22,11 +22,13 @@ import { lazyTool } from "./lazy-tool.js";
22
22
  import { monitorToolSurface } from "./monitor-surface.js";
23
23
  import { panesToolSurface } from "./panes-surface.js";
24
24
  import type { ToolRegistry } from "./registry.js";
25
+ import { createSelfCompactTool, type RequestSelfCompact } from "./self-compact.js";
25
26
  import { steerToolSurface } from "./steer-surface.js";
26
27
 
27
28
  export { toolPromptHintsForNames };
28
29
 
29
30
  export interface ToolBootstrapDeps extends Omit<CoreToolBootstrapDeps, "mcpCapabilities"> {
31
+ requestSelfCompact?: RequestSelfCompact;
30
32
  captureWorkerContext?: () => WorkerContextSnapshot | null;
31
33
  dispatch?: DispatchContract;
32
34
  bus?: SafeEventBus;
@@ -77,6 +79,10 @@ export function registerAllTools(registry: ToolRegistry, deps: ToolBootstrapDeps
77
79
  ...coreDeps,
78
80
  ...(mcpCapabilities ? { mcpCapabilities } : {}),
79
81
  });
82
+ if (deps.requestSelfCompact)
83
+ registry.register(
84
+ builtin(createSelfCompactTool(deps.requestSelfCompact), { path: "src/tools/self-compact.ts", scope: "core" }),
85
+ );
80
86
  if (deps.dispatch) {
81
87
  const dispatch = deps.dispatch;
82
88
  // Display tail only. In a composed process the dispatch domain writes the
@@ -100,6 +100,15 @@ const TOOL_METADATA: Readonly<Record<string, ToolMetadata>> = {
100
100
  promptHint:
101
101
  "Use code_nav with source=workspace (default) for project code and source=clio for Clio's shipped code map; modes: symbol, path, entries, outline, deps, dependents, wiki (workspace only).",
102
102
  },
103
+ [ToolNames.SelfCompact]: {
104
+ objective: "Save an exact assistant handoff and reduce native session context.",
105
+ uiLabel: "Compact",
106
+ retrySafety: "not_retry_safe",
107
+ resultSizePolicy: { kind: "exact", maxBytes: 2048 },
108
+ costLatency: "local_fast",
109
+ promptHint:
110
+ 'Use self_compact alone at a safe boundary. Preserve decisions, evidence, paths, and next actions in note_to_self. Inspect context(scope="budget") for current pressure; recovery belongs to the operator.',
111
+ },
103
112
  [ToolNames.Context]: {
104
113
  objective: "Return workspace, effective configuration, skill, or recall context.",
105
114
  uiLabel: "Context",
@@ -3,14 +3,9 @@ import path from "node:path";
3
3
  import type { ContextRecalledPayload } from "../../core/bus-events.js";
4
4
  import type { ClioSettings } from "../../core/config.js";
5
5
  import { settingsAwareness } from "../../core/settings-awareness.js";
6
- import {
7
- SKILL_INSTALL_OFFER_OPTION_NEVER,
8
- SKILL_INSTALL_OFFER_OPTION_NOT_NOW,
9
- SKILL_INSTALL_OFFER_OPTION_PROJECT,
10
- SKILL_INSTALL_OFFER_OPTION_USER,
11
- SKILL_SUGGESTION_ANCHOR,
12
- } from "../../core/skill-activation.js";
6
+ import { SKILL_SUGGESTION_ANCHOR } from "../../core/skill-activation.js";
13
7
  import { ToolNames } from "../../core/tool-names.js";
8
+ import type { BudgetProvider } from "../../domains/context/budget/inspection.js";
14
9
  import type { WorkerRecall } from "../../domains/context/worker/recall.js";
15
10
  import { foldWorkingSet } from "../../domains/context/working-set/fold.js";
16
11
  import {
@@ -21,7 +16,9 @@ import {
21
16
  resolveRecall,
22
17
  } from "../../domains/context/working-set/recall.js";
23
18
  import {
19
+ buildSkillCatalogView,
24
20
  checkSkillDrift,
21
+ checkSkillDriftBatch,
25
22
  discoverMarketplaceSkills,
26
23
  installedSkillNames,
27
24
  installedSkillPackages,
@@ -30,6 +27,7 @@ import {
30
27
  type MarketplaceSkill,
31
28
  modelVisibleSkills,
32
29
  type Skill,
30
+ type SkillCatalogRowKind,
33
31
  } from "../../domains/resources/index.js";
34
32
  import type { SessionEntryInput } from "../../domains/session/contract.js";
35
33
  import type { SessionEntry } from "../../domains/session/entries.js";
@@ -78,6 +76,8 @@ export interface ContextSessionDeps {
78
76
  }
79
77
 
80
78
  export interface ContextToolDeps {
79
+ /** Refreshes this run's native budget; absent for external/worker registries. */
80
+ getContextBudget?: BudgetProvider;
81
81
  /** Live session view, including overrides. Omit when no authoritative settings snapshot exists. */
82
82
  getSettings?: () => Readonly<ClioSettings>;
83
83
  /** Run-scoped evidence port; never reads or appends the parent session. */
@@ -294,84 +294,29 @@ function marketplaceRowsFor(
294
294
  }
295
295
  }
296
296
 
297
- function renderSkillsList(
298
- skills: ReadonlyArray<Skill>,
299
- marketplace: ReadonlyArray<MarketplaceSkill>,
300
- marketplaceOffered: boolean,
301
- modelActivation: boolean,
302
- packages: ReturnType<typeof installedSkillPackages> = [],
303
- ): string {
304
- if (skills.length === 0 && marketplace.length === 0 && packages.length === 0) {
305
- // A registry that never offers the marketplace (a worker) must not
306
- // claim none is configured; it simply has nothing to list.
307
- return marketplaceOffered
308
- ? "No skills are available in Clio and no additional marketplace skills were found."
309
- : "No skills are available in Clio.";
310
- }
311
- // The header names the listing and nothing more. The reply protocol is
312
- // stated once, as the recency anchor at the bottom, because that is the
313
- // line literal models act on; a second copy up here cost every listing
314
- // call the same sentences again.
315
- const lines = ["Available skills.", ""];
316
-
317
- const native = skills.filter((skill) => skill.source === "clio-coder" || skill.source === "plugin");
318
- const discovered = skills.filter((skill) => skill.source !== "clio-coder" && skill.source !== "plugin");
319
- lines.push(`Ready skills in Clio (${native.length}):`);
320
- if (native.length === 0) lines.push("- none");
321
- for (const skill of native) {
322
- lines.push(`- ${skill.name} (source: ${skill.source}; scope: ${skill.scope}): ${skill.description}`);
323
- }
324
- if (discovered.length > 0) {
325
- lines.push("", "Explicitly supplied session skills (not installed packages):");
326
- for (const skill of discovered) {
327
- lines.push(
328
- `- ${skill.name} (source: ${skill.source}; scope: ${skill.scope}; file: ${skill.filePath}): ${skill.description}`,
329
- );
330
- }
331
- lines.push(
332
- "These skills were explicitly supplied for this session. Preserve their source; session availability does not mean Clio installed or copied them.",
333
- );
334
- }
335
-
336
- if (packages.length > 0) {
337
- lines.push("", `Installed packages providing skills (${packages.length}):`);
338
- for (const pkg of packages)
339
- lines.push(
340
- `- ${pkg.name} (scope: ${pkg.scope}; origin: ${pkg.origin === "catalog" ? "marketplace catalog" : pkg.origin}; state: ${pkg.state}; path: ${pkg.path})`,
341
- );
342
- }
343
- lines.push(
344
- "",
345
- "Other-agent skill folders are discovery-only. Explicitly import into Clio before use; the trust-imports setting does not install or activate loose files.",
346
- );
347
-
348
- if (marketplace.length > 0) {
349
- // Installable rows are suggested exactly like installed ones: the
350
- // operator's /skill <name> prompts to install before it runs, so the
351
- // model's move is the same suggest-and-wait. Only the body is out of
352
- // reach until then, which is why the description is all that appears.
353
- lines.push("", "Marketplace (additional skills available to install; /skill <name> offers to install):");
354
- for (const entry of marketplace) {
355
- const category = entry.category ? ` [${entry.category}]` : "";
356
- lines.push(`- ${entry.name}${category}: ${entry.description}`);
357
- }
358
- }
359
- // Recency anchor with an exact reply shape: literal models act on an output
360
- // template where they skip conditional prose in the header.
361
- lines.push(
362
- "",
363
- modelActivation
364
- ? `If one skill above matches the current task, load it now with context(scope="skills", name="<name>") and continue in the same turn; at this autonomy level you activate installed skills yourself and do not wait for the operator. Marketplace additions still require operator approval. If none match, do not mention skills.`
365
- : `If one skill above matches the current task, begin your reply with the line \`${SKILL_SUGGESTION_ANCHOR}\` (a comma-separated sequence, in order, when several compose), then continue the task in the same turn without it; only the operator can run it. If none match, do not mention skills.`,
366
- );
367
- if (marketplace.length > 0) {
368
- // The offer protocol mirrors the marketplace-offer middleware: fixed
369
- // option labels so the harness recognizes and handles the answer.
370
- lines.push(
371
- `When no installed skill serves the task but a marketplace skill above genuinely does, you may instead ask the operator with ask_user (mode=single_question, header "Install skill") whether to install it, offering exactly: "${SKILL_INSTALL_OFFER_OPTION_PROJECT}", "${SKILL_INSTALL_OFFER_OPTION_USER}", "${SKILL_INSTALL_OFFER_OPTION_NOT_NOW}", "${SKILL_INSTALL_OFFER_OPTION_NEVER}". The harness handles those exact offer options. An explicit operator request or approval also authorizes the documented library install CLI. After installation, refresh the inventory; distinguish installed from ready and report /library reload when required.`,
372
- );
297
+ /**
298
+ * Ready rows whose installed content no longer matches the hash recorded for
299
+ * it, resolved for the whole listing in one manifest read.
300
+ *
301
+ * Drift used to be checked only in the activation branch below, so the listing
302
+ * reported a drifted skill as ready with no caveat and the warning arrived only
303
+ * after the model had already spent its turn loading it. This is the same
304
+ * comparison, one step earlier, through the batched entry point so a listing
305
+ * does not re-parse the pin manifest once per row.
306
+ *
307
+ * It annotates and nothing else. The skill stays listed, stays ready and stays
308
+ * loadable, the installed copy keeps its ownership, and the activation warning
309
+ * is unchanged. A skill with no recorded hash is not marked, because absence of
310
+ * evidence is not drift, and a failure here yields an unmarked listing rather
311
+ * than no listing.
312
+ */
313
+ function driftedSkillNames(skills: ReadonlyArray<Skill>, cwd: string): Set<string> {
314
+ try {
315
+ const reports = checkSkillDriftBatch(skills, cwd);
316
+ return new Set([...reports].filter(([, report]) => report.verdict === "mismatch").map(([name]) => name));
317
+ } catch {
318
+ return new Set<string>();
373
319
  }
374
- return lines.join("\n");
375
320
  }
376
321
 
377
322
  function runWorkspaceScope(
@@ -496,38 +441,71 @@ function runSkillsScope(
496
441
  cwdFromDeps(deps),
497
442
  deps.getSkillLoaderOptions?.().trustProjectCompatRoots === true,
498
443
  );
499
- const rendered = renderSkillsList(
500
- visible,
501
- marketplace,
502
- deps.skillMarketplace !== false,
503
- options?.pendingSkillPolicy?.modelActivation === true,
444
+ // The catalog is bounded but not small, and it has to fit the per-call cap
445
+ // like any observation. It is cut by whole rows with the reply protocol
446
+ // reserved first, rather than by head-truncating the finished string:
447
+ // head truncation keeps the head, and the tail is the one line a model
448
+ // acts on. `query`, `limit` and `offset` are the context tool's existing
449
+ // optional arguments, wired here so a caller can ask for less than all of
450
+ // it; omitting them still lists every row.
451
+ const view = buildSkillCatalogView({
452
+ skills: visible,
504
453
  packages,
505
- );
506
- // The catalog is bounded but not small: the whole listing must fit the
507
- // per-call cap like any observation, and a cut list says so instead of
508
- // silently dropping the marketplace tail.
509
- const truncation = truncateHead(rendered, { maxBytes: reservation.callCapBytes, maxLines: Number.MAX_SAFE_INTEGER });
510
- const total = visible.length + marketplace.length + packages.length;
454
+ marketplace,
455
+ drifted: driftedSkillNames(visible, cwdFromDeps(deps)),
456
+ marketplaceOffered: deps.skillMarketplace !== false,
457
+ modelActivation: options?.pendingSkillPolicy?.modelActivation === true,
458
+ query: typeof args.query === "string" ? args.query : "",
459
+ limit: typeof args.limit === "number" ? args.limit : undefined,
460
+ offset: typeof args.offset === "number" ? args.offset : 0,
461
+ capBytes: reservation.callCapBytes,
462
+ });
463
+ // Shown rows are selected by the row's own stable key, not by its name. An
464
+ // installed standalone skill has a ready row and a package row under one
465
+ // name, and the same package id can be installed at user and project scope,
466
+ // so a name set puts rows in `details` that the text never carried.
467
+ const shownKeys = new Set(view.rows.map((row) => row.key));
468
+ const shown = (kind: SkillCatalogRowKind, identity: string): boolean => shownKeys.has(`${kind}:${identity}`);
511
469
  return finalizeObservation({
512
470
  tool: ToolNames.Context,
513
471
  unit: "entries",
514
- output: truncation.content,
515
- ...(truncation.truncated ? { fullOutput: rendered } : {}),
516
- shownCount: total,
517
- totalCount: total,
518
- truncated: truncation.truncated,
472
+ output: view.text,
473
+ shownCount: view.shown,
474
+ totalCount: view.total,
475
+ truncated: view.nextOffset !== undefined || view.shown < view.total,
476
+ // Offsets index the filtered result set. A continuation carrying only
477
+ // the offset would be applied to the unfiltered catalog, which repeats
478
+ // rows and skips matches, so the envelope says the query is part of it.
479
+ ...(view.nextOffset !== undefined
480
+ ? {
481
+ next: view.filtered ? `offset=${view.nextOffset} with the same query` : `offset=${view.nextOffset}`,
482
+ }
483
+ : {}),
519
484
  details: {
520
- skills: visible.map((skill) => ({
521
- name: skill.name,
522
- scope: skill.scope,
523
- source: skill.source,
524
- path: skill.filePath,
525
- })),
526
- installedPackages: packages,
527
- marketplace: marketplace.map((entry) => ({
528
- name: entry.name,
529
- ...(entry.category ? { category: entry.category } : {}),
530
- })),
485
+ // Rows on this page, so a caller reading `details` sees the same
486
+ // listing the text carries. The totals beside them are how a
487
+ // filtered or paged view is told apart from a complete one.
488
+ skills: visible
489
+ .filter((skill) => shown("ready", skill.filePath) || shown("session", skill.filePath))
490
+ .map((skill) => ({
491
+ name: skill.name,
492
+ scope: skill.scope,
493
+ source: skill.source,
494
+ path: skill.filePath,
495
+ ...(view.driftedNames.includes(skill.name) ? { drift: "mismatch" } : {}),
496
+ })),
497
+ installedPackages: packages.filter((pkg) => shown("package", `${pkg.scope}:${pkg.path}`)),
498
+ marketplace: marketplace
499
+ .filter((entry) => shown("marketplace", entry.sourceUrl))
500
+ .map((entry) => ({
501
+ name: entry.name,
502
+ ...(entry.category ? { category: entry.category } : {}),
503
+ })),
504
+ totalSkills: visible.length,
505
+ totalPackages: packages.length,
506
+ totalMarketplace: marketplace.length,
507
+ ...(view.filtered ? { query: (args.query as string).trim(), matchMode: view.matchMode } : {}),
508
+ ...(view.driftedNames.length > 0 ? { drifted: view.driftedNames } : {}),
531
509
  },
532
510
  reservation,
533
511
  ...(options ? { options } : {}),
@@ -767,6 +745,52 @@ function runRecallScope(
767
745
  });
768
746
  }
769
747
 
748
+ /** A byte-fitted JSON observation. Inspection never spills to disk. */
749
+ function runBudgetScope(
750
+ deps: ContextToolDeps,
751
+ reservation: ObservationReservation,
752
+ options?: ToolInvokeOptions,
753
+ ): ToolResult {
754
+ const inspection = deps.getContextBudget?.() ?? {
755
+ status: "unavailable",
756
+ reason:
757
+ "This run has no native session budget port. Worker and external-agent budgets are not the parent session's budget.",
758
+ };
759
+ const payload =
760
+ inspection.status === "available"
761
+ ? {
762
+ ...inspection,
763
+ mode: "enforced",
764
+ admissionNote:
765
+ "Submission and continuation enforce input plus reserved output against the effective window. Counts are estimates unless provider-attested; pressure advice remains advisory.",
766
+ }
767
+ : inspection;
768
+ const full = JSON.stringify(payload);
769
+ const fits = Buffer.byteLength(full, "utf8") <= reservation.callCapBytes;
770
+ // Do not pass fullOutput to finalizeObservation: an inspection must not
771
+ // persist its view even when an optional projection contains huge text.
772
+ const output = fits
773
+ ? full
774
+ : JSON.stringify({
775
+ status: "unavailable",
776
+ reason: "observation-limit",
777
+ message:
778
+ "The budget view exceeds the available observation bytes. Continue in a follow-up turn; do not retry this turn.",
779
+ });
780
+ return finalizeObservation({
781
+ tool: ToolNames.Context,
782
+ unit: "results",
783
+ format: "json",
784
+ output,
785
+ shownCount: fits ? 1 : 0,
786
+ totalCount: 1,
787
+ totalBytes: Buffer.byteLength(full, "utf8"),
788
+ truncated: !fits,
789
+ reservation,
790
+ ...(options ? { options } : {}),
791
+ });
792
+ }
793
+
770
794
  export function createContextTool(deps: ContextToolDeps = {}): ToolSpec {
771
795
  return {
772
796
  ...contextToolSurface,
@@ -782,12 +806,21 @@ export function createContextTool(deps: ContextToolDeps = {}): ToolSpec {
782
806
  message: `context: scope "${scope}" is a gateway capability now: call gateway(op="call", capability="${capability}", args={...}) or gateway(op="describe", capability="${capability}") for its arguments.`,
783
807
  };
784
808
  }
785
- if (scope !== "workspace" && scope !== "settings" && scope !== "skills" && scope !== "recall") {
809
+ if (
810
+ scope !== "workspace" &&
811
+ scope !== "settings" &&
812
+ scope !== "skills" &&
813
+ scope !== "recall" &&
814
+ scope !== "budget"
815
+ ) {
786
816
  return {
787
817
  kind: "error",
788
- message: `context: scope must be workspace, settings, skills, or recall; got '${scope}'`,
818
+ message: `context: scope must be workspace, settings, skills, recall, or budget; got '${scope}'`,
789
819
  };
790
820
  }
821
+ if (scope === "budget" && Object.keys(args).some((key) => key !== "scope")) {
822
+ return { kind: "error", message: 'context: scope="budget" accepts only scope; it inspects the current request.' };
823
+ }
791
824
  const selfCap = scope === "skills" ? OBSERVE_SELF_CAPS.contextSkills : OBSERVE_SELF_CAPS.contextWorkspace;
792
825
  // Reserved before any scope handler runs, so an exhausted pool answers
793
826
  // with the notice and no scope does its work for nothing.
@@ -796,6 +829,7 @@ export function createContextTool(deps: ContextToolDeps = {}): ToolSpec {
796
829
  return observationBudgetExhausted({
797
830
  tool: ToolNames.Context,
798
831
  unit: scope === "skills" ? "entries" : "results",
832
+ ...(scope === "budget" ? { format: "json" as const } : {}),
799
833
  reservation,
800
834
  subject: `scope=${scope}`,
801
835
  hint: "Continue in a follow-up turn.",
@@ -838,6 +872,7 @@ export function createContextTool(deps: ContextToolDeps = {}): ToolSpec {
838
872
  ...(options ? { options } : {}),
839
873
  });
840
874
  }
875
+ if (scope === "budget") return runBudgetScope(deps, reservation, options);
841
876
  if (scope === "workspace") return runWorkspaceScope(deps, reservation, options);
842
877
  if (scope === "recall") return runRecallScope(deps, args, reservation, options);
843
878
  return runSkillsScope(deps, args, reservation, options);