iCode-TUI 0.29.1__py3-none-any.whl

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 (1138) hide show
  1. chrys/__init__.py +16 -0
  2. chrys/app/__init__.py +3 -0
  3. chrys/app/acp/__init__.py +3 -0
  4. chrys/app/acp/bridge.py +664 -0
  5. chrys/app/acp/content.py +132 -0
  6. chrys/app/acp/doc/frontend-api.md +425 -0
  7. chrys/app/acp/history.py +585 -0
  8. chrys/app/acp/server.py +1796 -0
  9. chrys/app/acp/session_manager.py +1815 -0
  10. chrys/app/acp/tool_status.py +45 -0
  11. chrys/app/cli/__init__.py +3 -0
  12. chrys/app/cli/acp.py +178 -0
  13. chrys/app/cli/app.py +165 -0
  14. chrys/app/cli/approvals.py +101 -0
  15. chrys/app/cli/headless.py +223 -0
  16. chrys/app/cli/launch_cwd.py +42 -0
  17. chrys/app/cli/profiles.py +298 -0
  18. chrys/app/cli/progress.py +857 -0
  19. chrys/app/cli/run.py +425 -0
  20. chrys/app/cli/serve.py +1593 -0
  21. chrys/app/cli/tool_summary.py +225 -0
  22. chrys/app/cli/trajectory.py +201 -0
  23. chrys/app/cli/workflow.py +601 -0
  24. chrys/app/features/__init__.py +3 -0
  25. chrys/app/features/buddy/__init__.py +3 -0
  26. chrys/app/features/buddy/actions.py +68 -0
  27. chrys/app/features/buddy/animation.py +76 -0
  28. chrys/app/features/buddy/commands.py +196 -0
  29. chrys/app/features/buddy/docs/design.md +187 -0
  30. chrys/app/features/buddy/hatchery.py +80 -0
  31. chrys/app/features/buddy/lifecycle.py +41 -0
  32. chrys/app/features/buddy/model.py +265 -0
  33. chrys/app/features/buddy/pixel_renderer.py +113 -0
  34. chrys/app/features/buddy/pixel_sprites.py +279 -0
  35. chrys/app/features/buddy/portrait.py +76 -0
  36. chrys/app/features/buddy/progression.py +58 -0
  37. chrys/app/features/buddy/replies.py +191 -0
  38. chrys/app/features/buddy/sprites/alien.toml +70 -0
  39. chrys/app/features/buddy/sprites/axolotl.toml +71 -0
  40. chrys/app/features/buddy/sprites/bat.toml +69 -0
  41. chrys/app/features/buddy/sprites/bee.toml +69 -0
  42. chrys/app/features/buddy/sprites/cactus.toml +73 -0
  43. chrys/app/features/buddy/sprites/capybara.toml +70 -0
  44. chrys/app/features/buddy/sprites/cat.toml +69 -0
  45. chrys/app/features/buddy/sprites/crab.toml +70 -0
  46. chrys/app/features/buddy/sprites/dragon.toml +71 -0
  47. chrys/app/features/buddy/sprites/duck.toml +69 -0
  48. chrys/app/features/buddy/sprites/fox.toml +69 -0
  49. chrys/app/features/buddy/sprites/frog.toml +70 -0
  50. chrys/app/features/buddy/sprites/ghost.toml +70 -0
  51. chrys/app/features/buddy/sprites/goose.toml +69 -0
  52. chrys/app/features/buddy/sprites/jelly.toml +70 -0
  53. chrys/app/features/buddy/sprites/llama.toml +70 -0
  54. chrys/app/features/buddy/sprites/mushroom.toml +69 -0
  55. chrys/app/features/buddy/sprites/octopus.toml +70 -0
  56. chrys/app/features/buddy/sprites/owl.toml +70 -0
  57. chrys/app/features/buddy/sprites/panda.toml +71 -0
  58. chrys/app/features/buddy/sprites/penguin.toml +69 -0
  59. chrys/app/features/buddy/sprites/rabbit.toml +70 -0
  60. chrys/app/features/buddy/sprites/robot.toml +72 -0
  61. chrys/app/features/buddy/sprites/shark.toml +70 -0
  62. chrys/app/features/buddy/sprites/snail.toml +69 -0
  63. chrys/app/features/buddy/sprites/snake.toml +69 -0
  64. chrys/app/features/buddy/sprites/tabby.toml +69 -0
  65. chrys/app/features/buddy/sprites/turtle.toml +69 -0
  66. chrys/app/features/buddy/store.py +143 -0
  67. chrys/app/features/session_title/__init__.py +7 -0
  68. chrys/app/features/session_title/updater.py +582 -0
  69. chrys/app/installer.py +651 -0
  70. chrys/app/parsing.py +17 -0
  71. chrys/app/tui/__init__.py +3 -0
  72. chrys/app/tui/app.py +1287 -0
  73. chrys/app/tui/behaviors/__init__.py +3 -0
  74. chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
  75. chrys/app/tui/behaviors/insert_clipboard.py +73 -0
  76. chrys/app/tui/behaviors/right_click_copy.py +146 -0
  77. chrys/app/tui/binding_display.py +139 -0
  78. chrys/app/tui/buddy_messages.py +17 -0
  79. chrys/app/tui/buddy_reply.py +86 -0
  80. chrys/app/tui/chrys.tcss +549 -0
  81. chrys/app/tui/clipboard.py +109 -0
  82. chrys/app/tui/copy_messages.py +10 -0
  83. chrys/app/tui/i18n.py +230 -0
  84. chrys/app/tui/language.py +58 -0
  85. chrys/app/tui/notifications/__init__.py +12 -0
  86. chrys/app/tui/notifications/drivers.py +180 -0
  87. chrys/app/tui/notifications/service.py +126 -0
  88. chrys/app/tui/notifications/settings.py +141 -0
  89. chrys/app/tui/screens/__init__.py +3 -0
  90. chrys/app/tui/screens/agents/__init__.py +8 -0
  91. chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
  92. chrys/app/tui/screens/agents/config.py +2142 -0
  93. chrys/app/tui/screens/agents/config.tcss +169 -0
  94. chrys/app/tui/screens/agents/panels/__init__.py +3 -0
  95. chrys/app/tui/screens/agents/panels/acp.py +1176 -0
  96. chrys/app/tui/screens/agents/panels/basic.py +497 -0
  97. chrys/app/tui/screens/agents/panels/compaction.py +231 -0
  98. chrys/app/tui/screens/agents/panels/config_card.py +218 -0
  99. chrys/app/tui/screens/agents/panels/instructions.py +91 -0
  100. chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
  101. chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
  102. chrys/app/tui/screens/agents/panels/memory.py +684 -0
  103. chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
  104. chrys/app/tui/screens/agents/panels/skills.py +806 -0
  105. chrys/app/tui/screens/agents/panels/subagents.py +652 -0
  106. chrys/app/tui/screens/agents/panels/tools.py +205 -0
  107. chrys/app/tui/screens/agents/picker.py +103 -0
  108. chrys/app/tui/screens/agents/skill_paths.py +38 -0
  109. chrys/app/tui/screens/agents/validation_messages.py +452 -0
  110. chrys/app/tui/screens/dialogs/__init__.py +35 -0
  111. chrys/app/tui/screens/dialogs/agent_load.py +834 -0
  112. chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
  113. chrys/app/tui/screens/dialogs/app_mode.py +55 -0
  114. chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
  115. chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
  116. chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
  117. chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
  118. chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
  119. chrys/app/tui/screens/dialogs/approval/body.py +110 -0
  120. chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
  121. chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
  122. chrys/app/tui/screens/dialogs/ask_user.py +148 -0
  123. chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
  124. chrys/app/tui/screens/dialogs/base.py +84 -0
  125. chrys/app/tui/screens/dialogs/confirm.py +184 -0
  126. chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
  127. chrys/app/tui/screens/dialogs/connection_test.py +183 -0
  128. chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
  129. chrys/app/tui/screens/dialogs/diagram.py +102 -0
  130. chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
  131. chrys/app/tui/screens/dialogs/editor.py +378 -0
  132. chrys/app/tui/screens/dialogs/editor.tcss +132 -0
  133. chrys/app/tui/screens/dialogs/file_picker.py +582 -0
  134. chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
  135. chrys/app/tui/screens/dialogs/fork_session.py +219 -0
  136. chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
  137. chrys/app/tui/screens/dialogs/image_compression.py +102 -0
  138. chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
  139. chrys/app/tui/screens/dialogs/man_page.py +172 -0
  140. chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
  141. chrys/app/tui/screens/dialogs/new_folder.py +277 -0
  142. chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
  143. chrys/app/tui/screens/dialogs/notice.tcss +14 -0
  144. chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
  145. chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
  146. chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
  147. chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
  148. chrys/app/tui/screens/dialogs/session_title.py +110 -0
  149. chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
  150. chrys/app/tui/screens/dialogs/tool_view.py +207 -0
  151. chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
  152. chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
  153. chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
  154. chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
  155. chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
  156. chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
  157. chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
  158. chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
  159. chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
  160. chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
  161. chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
  162. chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
  163. chrys/app/tui/screens/diff/__init__.py +22 -0
  164. chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
  165. chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
  166. chrys/app/tui/screens/diff/screen.py +1532 -0
  167. chrys/app/tui/screens/diff/screen.tcss +104 -0
  168. chrys/app/tui/screens/guides/__init__.py +7 -0
  169. chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
  170. chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
  171. chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
  172. chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
  173. chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
  174. chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
  175. chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
  176. chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
  177. chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
  178. chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
  179. chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
  180. chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
  181. chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
  182. chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
  183. chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
  184. chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
  185. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
  186. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
  187. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
  188. chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
  189. chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
  190. chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
  191. chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
  192. chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
  193. chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
  194. chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
  195. chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
  196. chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
  197. chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
  198. chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
  199. chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
  200. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
  201. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
  202. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
  203. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
  204. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
  205. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
  206. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
  207. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
  208. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
  209. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
  210. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
  211. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
  212. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
  213. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
  214. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
  215. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
  216. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
  217. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
  218. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
  219. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
  220. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
  221. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
  222. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
  223. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
  224. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
  225. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
  226. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
  227. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
  228. chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
  229. chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
  230. chrys/app/tui/screens/guides/index.py +215 -0
  231. chrys/app/tui/screens/guides/reader.py +173 -0
  232. chrys/app/tui/screens/guides/screen.py +442 -0
  233. chrys/app/tui/screens/guides/screen.tcss +52 -0
  234. chrys/app/tui/screens/logs/__init__.py +7 -0
  235. chrys/app/tui/screens/logs/screen.py +504 -0
  236. chrys/app/tui/screens/logs/screen.tcss +45 -0
  237. chrys/app/tui/screens/main/__init__.py +7 -0
  238. chrys/app/tui/screens/main/action_availability.py +30 -0
  239. chrys/app/tui/screens/main/buddy_command.py +143 -0
  240. chrys/app/tui/screens/main/commands.py +1073 -0
  241. chrys/app/tui/screens/main/config_actions.py +464 -0
  242. chrys/app/tui/screens/main/copy_actions.py +117 -0
  243. chrys/app/tui/screens/main/dialog_controllers.py +826 -0
  244. chrys/app/tui/screens/main/dialog_gateway.py +166 -0
  245. chrys/app/tui/screens/main/diff_controller.py +478 -0
  246. chrys/app/tui/screens/main/engine_read_model.py +184 -0
  247. chrys/app/tui/screens/main/event_handlers.py +2014 -0
  248. chrys/app/tui/screens/main/input_flow.py +241 -0
  249. chrys/app/tui/screens/main/live_diff.py +244 -0
  250. chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
  251. chrys/app/tui/screens/main/model_indicator.py +217 -0
  252. chrys/app/tui/screens/main/navigation.py +343 -0
  253. chrys/app/tui/screens/main/ports.py +571 -0
  254. chrys/app/tui/screens/main/recent_dirs.py +123 -0
  255. chrys/app/tui/screens/main/rollback_controller.py +586 -0
  256. chrys/app/tui/screens/main/runtime_info.py +71 -0
  257. chrys/app/tui/screens/main/screen.py +2265 -0
  258. chrys/app/tui/screens/main/screen.tcss +21 -0
  259. chrys/app/tui/screens/main/session_handlers.py +1127 -0
  260. chrys/app/tui/screens/main/session_title.py +239 -0
  261. chrys/app/tui/screens/main/settings_coordinator.py +365 -0
  262. chrys/app/tui/screens/main/settings_persistence.py +184 -0
  263. chrys/app/tui/screens/main/shell_mode.py +112 -0
  264. chrys/app/tui/screens/main/state.py +289 -0
  265. chrys/app/tui/screens/main/submit_state.py +11 -0
  266. chrys/app/tui/screens/main/subscriptions.py +177 -0
  267. chrys/app/tui/screens/main/suggestions.py +982 -0
  268. chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
  269. chrys/app/tui/screens/main/view_adapter.py +1567 -0
  270. chrys/app/tui/screens/main/workflow_browser.py +747 -0
  271. chrys/app/tui/screens/main/workflow_content.py +248 -0
  272. chrys/app/tui/screens/main/workflow_controller.py +240 -0
  273. chrys/app/tui/screens/main/workflow_feedback.py +137 -0
  274. chrys/app/tui/screens/main/workflow_flow.py +49 -0
  275. chrys/app/tui/screens/main/workflow_run_control.py +456 -0
  276. chrys/app/tui/screens/main/workflow_session_view.py +316 -0
  277. chrys/app/tui/screens/main/workspace_actions.py +272 -0
  278. chrys/app/tui/screens/main/workspace_branch.py +194 -0
  279. chrys/app/tui/screens/menus/__init__.py +8 -0
  280. chrys/app/tui/screens/menus/languages.py +114 -0
  281. chrys/app/tui/screens/models/__init__.py +7 -0
  282. chrys/app/tui/screens/models/picker.py +147 -0
  283. chrys/app/tui/screens/models/screen.py +2043 -0
  284. chrys/app/tui/screens/models/screen.tcss +255 -0
  285. chrys/app/tui/screens/sessions/__init__.py +8 -0
  286. chrys/app/tui/screens/sessions/presenter.py +586 -0
  287. chrys/app/tui/screens/sessions/screen.py +779 -0
  288. chrys/app/tui/screens/sessions/screen.tcss +127 -0
  289. chrys/app/tui/screens/settings/__init__.py +29 -0
  290. chrys/app/tui/screens/settings/danger.py +26 -0
  291. chrys/app/tui/screens/settings/dialog.py +281 -0
  292. chrys/app/tui/screens/settings/layout.py +519 -0
  293. chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
  294. chrys/app/tui/screens/settings/panes/__init__.py +3 -0
  295. chrys/app/tui/screens/settings/panes/notifications.py +248 -0
  296. chrys/app/tui/screens/settings/panes/sessions.py +148 -0
  297. chrys/app/tui/screens/settings/ports.py +55 -0
  298. chrys/app/tui/screens/settings/provenance.py +102 -0
  299. chrys/app/tui/screens/settings/rows.py +546 -0
  300. chrys/app/tui/screens/settings/settings.tcss +406 -0
  301. chrys/app/tui/screens/themes/__init__.py +8 -0
  302. chrys/app/tui/screens/themes/dialogs.py +330 -0
  303. chrys/app/tui/screens/themes/editor.py +632 -0
  304. chrys/app/tui/screens/themes/editor.tcss +268 -0
  305. chrys/app/tui/screens/themes/messages.py +95 -0
  306. chrys/app/tui/screens/themes/palette.py +694 -0
  307. chrys/app/tui/screens/themes/panel.py +113 -0
  308. chrys/app/tui/screens/themes/picker.py +203 -0
  309. chrys/app/tui/screens/themes/save.py +191 -0
  310. chrys/app/tui/support/__init__.py +3 -0
  311. chrys/app/tui/support/file_manager.py +64 -0
  312. chrys/app/tui/support/gc_freeze.py +1114 -0
  313. chrys/app/tui/support/path_shortcuts.py +50 -0
  314. chrys/app/tui/support/prompt_history.py +298 -0
  315. chrys/app/tui/support/workspace_mru.py +476 -0
  316. chrys/app/tui/terminal/__init__.py +6 -0
  317. chrys/app/tui/terminal/_pty_backend.py +114 -0
  318. chrys/app/tui/terminal/emulator/__init__.py +24 -0
  319. chrys/app/tui/terminal/emulator/buffer.py +456 -0
  320. chrys/app/tui/terminal/emulator/charsets.py +113 -0
  321. chrys/app/tui/terminal/emulator/core.py +963 -0
  322. chrys/app/tui/terminal/emulator/input.py +342 -0
  323. chrys/app/tui/terminal/emulator/parser.py +227 -0
  324. chrys/app/tui/terminal/emulator/pen.py +158 -0
  325. chrys/app/tui/terminal/launcher.py +455 -0
  326. chrys/app/tui/terminal/leaked_reports.py +44 -0
  327. chrys/app/tui/terminal/panel.py +323 -0
  328. chrys/app/tui/terminal/pty_process.py +405 -0
  329. chrys/app/tui/terminal/rendering.py +151 -0
  330. chrys/app/tui/terminal/shell.py +273 -0
  331. chrys/app/tui/terminal/shell_integration.py +199 -0
  332. chrys/app/tui/terminal/title.py +224 -0
  333. chrys/app/tui/terminal/widget.py +654 -0
  334. chrys/app/tui/theme.py +422 -0
  335. chrys/app/tui/theme_loader.py +201 -0
  336. chrys/app/tui/theme_stylesheet.py +49 -0
  337. chrys/app/tui/themes/__init__.py +3 -0
  338. chrys/app/tui/themes/document.py +120 -0
  339. chrys/app/tui/themes/preview.py +170 -0
  340. chrys/app/tui/themes/store.py +176 -0
  341. chrys/app/tui/toasts.py +69 -0
  342. chrys/app/tui/util/__init__.py +3 -0
  343. chrys/app/tui/util/context_pressure.py +146 -0
  344. chrys/app/tui/util/debug_log.py +292 -0
  345. chrys/app/tui/util/diff_entries.py +64 -0
  346. chrys/app/tui/util/formatting.py +53 -0
  347. chrys/app/tui/util/git_branch.py +326 -0
  348. chrys/app/tui/util/invocation_progress.py +57 -0
  349. chrys/app/tui/util/logo.py +32 -0
  350. chrys/app/tui/util/message_gate.py +35 -0
  351. chrys/app/tui/util/mutation_sources.py +14 -0
  352. chrys/app/tui/util/picker_text.py +27 -0
  353. chrys/app/tui/util/removal.py +47 -0
  354. chrys/app/tui/util/rich_style.py +36 -0
  355. chrys/app/tui/util/source_text.py +12 -0
  356. chrys/app/tui/util/static_update.py +88 -0
  357. chrys/app/tui/util/todo_format.py +51 -0
  358. chrys/app/tui/util/visibility.py +214 -0
  359. chrys/app/tui/widgets/__init__.py +60 -0
  360. chrys/app/tui/widgets/ask_user_controls.py +652 -0
  361. chrys/app/tui/widgets/ask_user_markdown.py +17 -0
  362. chrys/app/tui/widgets/ask_user_prompt.py +606 -0
  363. chrys/app/tui/widgets/buttons.py +52 -0
  364. chrys/app/tui/widgets/chat/__init__.py +3 -0
  365. chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
  366. chrys/app/tui/widgets/chat/chrome.py +296 -0
  367. chrys/app/tui/widgets/chat/compaction_card.py +353 -0
  368. chrys/app/tui/widgets/chat/context_fold.py +77 -0
  369. chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
  370. chrys/app/tui/widgets/chat/image_preview.py +214 -0
  371. chrys/app/tui/widgets/chat/live_renderer.py +383 -0
  372. chrys/app/tui/widgets/chat/messages.py +1039 -0
  373. chrys/app/tui/widgets/chat/panel.py +1769 -0
  374. chrys/app/tui/widgets/chat/ports.py +245 -0
  375. chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
  376. chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
  377. chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
  378. chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
  379. chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
  380. chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
  381. chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
  382. chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
  383. chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
  384. chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
  385. chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
  386. chrys/app/tui/widgets/chat/renderers/search.py +348 -0
  387. chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
  388. chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
  389. chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
  390. chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
  391. chrys/app/tui/widgets/chat/renderers/web.py +69 -0
  392. chrys/app/tui/widgets/chat/replay.py +1605 -0
  393. chrys/app/tui/widgets/chat/replay_mount.py +322 -0
  394. chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
  395. chrys/app/tui/widgets/chat/selection_controller.py +435 -0
  396. chrys/app/tui/widgets/chat/session_json.py +508 -0
  397. chrys/app/tui/widgets/chat/toc_model.py +156 -0
  398. chrys/app/tui/widgets/chat/tool_call.py +2478 -0
  399. chrys/app/tui/widgets/chat/tool_registry.py +753 -0
  400. chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
  401. chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
  402. chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
  403. chrys/app/tui/widgets/checkbox.py +24 -0
  404. chrys/app/tui/widgets/chrome/__init__.py +3 -0
  405. chrys/app/tui/widgets/chrome/app_header.py +283 -0
  406. chrys/app/tui/widgets/chrome/commands.py +95 -0
  407. chrys/app/tui/widgets/chrome/file_index.py +68 -0
  408. chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
  409. chrys/app/tui/widgets/chrome/footer.py +284 -0
  410. chrys/app/tui/widgets/chrome/image_paste.py +351 -0
  411. chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
  412. chrys/app/tui/widgets/chrome/status_bar.py +911 -0
  413. chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
  414. chrys/app/tui/widgets/click_affordance.py +24 -0
  415. chrys/app/tui/widgets/color_picker/__init__.py +254 -0
  416. chrys/app/tui/widgets/color_picker/controls.py +171 -0
  417. chrys/app/tui/widgets/color_picker/model.py +89 -0
  418. chrys/app/tui/widgets/dialog_buttons.py +58 -0
  419. chrys/app/tui/widgets/dialog_scroll.py +24 -0
  420. chrys/app/tui/widgets/diff_view/__init__.py +20 -0
  421. chrys/app/tui/widgets/diff_view/cells.py +59 -0
  422. chrys/app/tui/widgets/diff_view/code.py +280 -0
  423. chrys/app/tui/widgets/diff_view/compute.py +286 -0
  424. chrys/app/tui/widgets/diff_view/gutter.py +161 -0
  425. chrys/app/tui/widgets/diff_view/inline.py +77 -0
  426. chrys/app/tui/widgets/diff_view/palette.py +87 -0
  427. chrys/app/tui/widgets/diff_view/protocols.py +24 -0
  428. chrys/app/tui/widgets/diff_view/rows.py +138 -0
  429. chrys/app/tui/widgets/diff_view/unified.py +267 -0
  430. chrys/app/tui/widgets/diff_view/widget.py +209 -0
  431. chrys/app/tui/widgets/editor/__init__.py +79 -0
  432. chrys/app/tui/widgets/editor/coordinates.py +30 -0
  433. chrys/app/tui/widgets/editor/highlighter.py +139 -0
  434. chrys/app/tui/widgets/editor/keymaps.py +357 -0
  435. chrys/app/tui/widgets/editor/types.py +216 -0
  436. chrys/app/tui/widgets/editor/vim.py +1195 -0
  437. chrys/app/tui/widgets/editor/widget.py +401 -0
  438. chrys/app/tui/widgets/file_preview.py +58 -0
  439. chrys/app/tui/widgets/hatch.py +138 -0
  440. chrys/app/tui/widgets/input.py +66 -0
  441. chrys/app/tui/widgets/loading.py +125 -0
  442. chrys/app/tui/widgets/markdown/__init__.py +25 -0
  443. chrys/app/tui/widgets/markdown/_utils.py +21 -0
  444. chrys/app/tui/widgets/markdown/anchors.py +71 -0
  445. chrys/app/tui/widgets/markdown/blocks.py +115 -0
  446. chrys/app/tui/widgets/markdown/copyable.py +163 -0
  447. chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
  448. chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
  449. chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
  450. chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
  451. chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
  452. chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
  453. chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
  454. chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
  455. chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
  456. chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
  457. chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
  458. chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
  459. chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
  460. chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
  461. chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
  462. chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
  463. chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
  464. chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
  465. chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
  466. chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
  467. chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
  468. chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
  469. chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
  470. chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
  471. chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
  472. chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
  473. chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
  474. chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
  475. chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
  476. chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
  477. chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
  478. chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
  479. chrys/app/tui/widgets/markdown/links.py +47 -0
  480. chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
  481. chrys/app/tui/widgets/markdown/math/layout.py +739 -0
  482. chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
  483. chrys/app/tui/widgets/markdown/math/parser.py +325 -0
  484. chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
  485. chrys/app/tui/widgets/markdown/navigator.py +80 -0
  486. chrys/app/tui/widgets/markdown/parser.py +1057 -0
  487. chrys/app/tui/widgets/markdown/stream.py +73 -0
  488. chrys/app/tui/widgets/markdown/toc.py +98 -0
  489. chrys/app/tui/widgets/markdown/viewer.py +146 -0
  490. chrys/app/tui/widgets/markdown/widget.py +2046 -0
  491. chrys/app/tui/widgets/marquee.py +101 -0
  492. chrys/app/tui/widgets/option_menu.py +82 -0
  493. chrys/app/tui/widgets/page_navigator.py +118 -0
  494. chrys/app/tui/widgets/select.py +196 -0
  495. chrys/app/tui/widgets/selection.py +59 -0
  496. chrys/app/tui/widgets/sidebar/__init__.py +3 -0
  497. chrys/app/tui/widgets/sidebar/buddy.py +367 -0
  498. chrys/app/tui/widgets/sidebar/context.py +482 -0
  499. chrys/app/tui/widgets/sidebar/debug.py +195 -0
  500. chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
  501. chrys/app/tui/widgets/sidebar/panel.py +198 -0
  502. chrys/app/tui/widgets/sidebar/tasks.py +198 -0
  503. chrys/app/tui/widgets/sidebar/toc.py +166 -0
  504. chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
  505. chrys/app/tui/widgets/syntax_theme.py +85 -0
  506. chrys/app/tui/widgets/text_area.py +258 -0
  507. chrys/app/tui/widgets/trajectory/__init__.py +8 -0
  508. chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
  509. chrys/app/tui/widgets/trajectory/insights.py +1266 -0
  510. chrys/app/tui/widgets/trajectory/overview.py +1101 -0
  511. chrys/app/tui/widgets/trajectory/panel.py +983 -0
  512. chrys/app/tui/widgets/trajectory/presentation.py +311 -0
  513. chrys/app/tui/widgets/trajectory/session_info.py +124 -0
  514. chrys/app/tui/widgets/trajectory/text_view.py +124 -0
  515. chrys/app/tui/widgets/trajectory/timeline.py +380 -0
  516. chrys/app/tui/widgets/welcome.py +126 -0
  517. chrys/app/tui/widgets/workflow/__init__.py +5 -0
  518. chrys/app/tui/widgets/workflow/edges.py +89 -0
  519. chrys/app/tui/widgets/workflow/graph.py +869 -0
  520. chrys/app/tui/widgets/workflow/info.py +310 -0
  521. chrys/app/tui/widgets/workflow/node_view.py +27 -0
  522. chrys/app/tui/widgets/workflow/output.py +225 -0
  523. chrys/app/tui/widgets/workflow/panel.py +658 -0
  524. chrys/app/tui/widgets/workflow/projector.py +324 -0
  525. chrys/app/tui/widgets/workflow/records.py +69 -0
  526. chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
  527. chrys/app/tui/widgets/workflow/selection.py +151 -0
  528. chrys/app/tui/widgets/workflow/source.py +65 -0
  529. chrys/app/tui/widgets/workflow/text.py +257 -0
  530. chrys/app/tui/widgets/workflow/values.py +317 -0
  531. chrys/foundation/__init__.py +0 -0
  532. chrys/foundation/branding.py +15 -0
  533. chrys/foundation/config/__init__.py +7 -0
  534. chrys/foundation/config/coercion.py +374 -0
  535. chrys/foundation/config/context.py +34 -0
  536. chrys/foundation/config/env_file.py +141 -0
  537. chrys/foundation/config/env_layers.py +370 -0
  538. chrys/foundation/config/migrations.py +521 -0
  539. chrys/foundation/config/notification_events.py +22 -0
  540. chrys/foundation/config/process_settings.py +332 -0
  541. chrys/foundation/config/runtime_pointer.py +133 -0
  542. chrys/foundation/config/settings.py +1952 -0
  543. chrys/foundation/config/settings_store.py +996 -0
  544. chrys/foundation/config/spec.py +396 -0
  545. chrys/foundation/config/user_settings.py +140 -0
  546. chrys/foundation/config/warnings.py +262 -0
  547. chrys/foundation/config/web_values.py +135 -0
  548. chrys/foundation/config/yaml_store.py +487 -0
  549. chrys/foundation/errors/__init__.py +68 -0
  550. chrys/foundation/errors/_legacy.py +117 -0
  551. chrys/foundation/errors/_text.py +13 -0
  552. chrys/foundation/errors/_walk.py +85 -0
  553. chrys/foundation/errors/classify.py +278 -0
  554. chrys/foundation/errors/display.py +274 -0
  555. chrys/foundation/errors/formatting.py +283 -0
  556. chrys/foundation/errors/kinds.py +67 -0
  557. chrys/foundation/errors/network.py +497 -0
  558. chrys/foundation/errors/provider.py +445 -0
  559. chrys/foundation/errors/route.py +85 -0
  560. chrys/foundation/events/__init__.py +8 -0
  561. chrys/foundation/events/bus.py +143 -0
  562. chrys/foundation/events/types.py +1629 -0
  563. chrys/foundation/events/workflow.py +38 -0
  564. chrys/foundation/hosted_tools.py +142 -0
  565. chrys/foundation/i18n/__init__.py +32 -0
  566. chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
  567. chrys/foundation/i18n/catalogs.py +42 -0
  568. chrys/foundation/i18n/formatting.py +202 -0
  569. chrys/foundation/i18n/locale.py +92 -0
  570. chrys/foundation/i18n/localizer.py +187 -0
  571. chrys/foundation/i18n/messages.py +268 -0
  572. chrys/foundation/io/__init__.py +3 -0
  573. chrys/foundation/io/result_content.py +237 -0
  574. chrys/foundation/models/__init__.py +3 -0
  575. chrys/foundation/models/approval_reuse.py +23 -0
  576. chrys/foundation/models/ask_user.py +468 -0
  577. chrys/foundation/models/execution.py +18 -0
  578. chrys/foundation/models/history_markers.py +115 -0
  579. chrys/foundation/models/invocations.py +63 -0
  580. chrys/foundation/models/mutation_scope.py +28 -0
  581. chrys/foundation/models/session_env.py +87 -0
  582. chrys/foundation/models/session_surface.py +29 -0
  583. chrys/foundation/models/todos.py +74 -0
  584. chrys/foundation/models/turns.py +125 -0
  585. chrys/foundation/models/workflow_session.py +89 -0
  586. chrys/foundation/models/workspace.py +59 -0
  587. chrys/foundation/net/__init__.py +3 -0
  588. chrys/foundation/net/address_class.py +57 -0
  589. chrys/foundation/net/route_probe.py +94 -0
  590. chrys/foundation/net/url.py +107 -0
  591. chrys/foundation/observability/__init__.py +3 -0
  592. chrys/foundation/observability/exporters.py +174 -0
  593. chrys/foundation/observability/gate.py +31 -0
  594. chrys/foundation/observability/setup.py +497 -0
  595. chrys/foundation/observability/sink.py +228 -0
  596. chrys/foundation/patches/__init__.py +19 -0
  597. chrys/foundation/patches/mdurl_cache.py +59 -0
  598. chrys/foundation/patches/patcher.py +287 -0
  599. chrys/foundation/patches/staged_members.py +205 -0
  600. chrys/foundation/patches/textual_block_border.py +105 -0
  601. chrys/foundation/patches/textual_button_ansi.py +65 -0
  602. chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
  603. chrys/foundation/patches/textual_callback_cache.py +248 -0
  604. chrys/foundation/patches/textual_callback_dispatch.py +109 -0
  605. chrys/foundation/patches/textual_compositor_cjk.py +566 -0
  606. chrys/foundation/patches/textual_dispatch_cache.py +150 -0
  607. chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
  608. chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
  609. chrys/foundation/patches/textual_lru_acyclic.py +241 -0
  610. chrys/foundation/patches/textual_message_pump.py +136 -0
  611. chrys/foundation/patches/textual_node_diet.py +875 -0
  612. chrys/foundation/patches/textual_one_shot_timer.py +48 -0
  613. chrys/foundation/patches/textual_option_list.py +90 -0
  614. chrys/foundation/patches/textual_precompose.py +89 -0
  615. chrys/foundation/patches/textual_pruned_tabs.py +69 -0
  616. chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
  617. chrys/foundation/patches/textual_removed_node_caches.py +86 -0
  618. chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
  619. chrys/foundation/patches/textual_selection_extract.py +48 -0
  620. chrys/foundation/patches/textual_strip_cycles.py +119 -0
  621. chrys/foundation/patches/textual_tab_selection.py +1611 -0
  622. chrys/foundation/patches/textual_timer_skip.py +70 -0
  623. chrys/foundation/patches/textual_utf8_decoder.py +106 -0
  624. chrys/foundation/patches/textual_win_sleep.py +167 -0
  625. chrys/foundation/patches/watchdog_fsevents.py +70 -0
  626. chrys/foundation/patches/watchdog_windows.py +52 -0
  627. chrys/foundation/platform/__init__.py +556 -0
  628. chrys/foundation/platform/c_api.py +32 -0
  629. chrys/foundation/platform/child_reap.py +107 -0
  630. chrys/foundation/platform/command_line.py +95 -0
  631. chrys/foundation/platform/files.py +1470 -0
  632. chrys/foundation/platform/output_capture.py +113 -0
  633. chrys/foundation/platform/paths.py +193 -0
  634. chrys/foundation/platform/process.py +1468 -0
  635. chrys/foundation/platform/pty_output.py +19 -0
  636. chrys/foundation/platform/runtime_paths.py +281 -0
  637. chrys/foundation/platform/win_console.py +129 -0
  638. chrys/foundation/reasoning_origin.py +67 -0
  639. chrys/foundation/recovery.py +16 -0
  640. chrys/foundation/retry.py +278 -0
  641. chrys/foundation/text/__init__.py +3 -0
  642. chrys/foundation/text/encoding.py +603 -0
  643. chrys/foundation/text/images.py +304 -0
  644. chrys/foundation/text/lines.py +24 -0
  645. chrys/foundation/text/mentions.py +90 -0
  646. chrys/foundation/text/model_json.py +25 -0
  647. chrys/foundation/text/reading_order.py +20 -0
  648. chrys/foundation/text/tokenizer.py +25 -0
  649. chrys/foundation/text/tool_output.py +287 -0
  650. chrys/foundation/text/yaml_io.py +62 -0
  651. chrys/foundation/tool_call_context.py +202 -0
  652. chrys/foundation/tool_execution_stamp.py +184 -0
  653. chrys/foundation/tool_invocation_order.py +48 -0
  654. chrys/foundation/tool_kinds.py +102 -0
  655. chrys/foundation/tool_result_metadata.py +287 -0
  656. chrys/foundation/trajectory/__init__.py +10 -0
  657. chrys/foundation/trajectory/context.py +481 -0
  658. chrys/foundation/trajectory/envelope.py +571 -0
  659. chrys/foundation/trajectory/event_types.py +294 -0
  660. chrys/foundation/trajectory/finalizers.py +46 -0
  661. chrys/foundation/trajectory/fingerprint.py +62 -0
  662. chrys/foundation/trajectory/ids.py +155 -0
  663. chrys/foundation/trajectory/keys.py +99 -0
  664. chrys/foundation/trajectory/lease.py +173 -0
  665. chrys/foundation/trajectory/metadata.py +72 -0
  666. chrys/foundation/trajectory/reader.py +271 -0
  667. chrys/foundation/trajectory/recovery.py +225 -0
  668. chrys/foundation/trajectory/revisions.py +152 -0
  669. chrys/foundation/trajectory/segments.py +225 -0
  670. chrys/foundation/trajectory/tools.py +117 -0
  671. chrys/foundation/trajectory/usage.py +151 -0
  672. chrys/foundation/trajectory/writer.py +972 -0
  673. chrys/foundation/trajectory_timing.py +97 -0
  674. chrys/foundation/util/__init__.py +3 -0
  675. chrys/foundation/util/async_tasks.py +29 -0
  676. chrys/foundation/util/chrys_headers.py +23 -0
  677. chrys/foundation/util/env_templates.py +104 -0
  678. chrys/foundation/util/filenames.py +96 -0
  679. chrys/foundation/util/header_charset.py +138 -0
  680. chrys/foundation/util/httpx_helpers.py +12 -0
  681. chrys/foundation/util/lock.py +236 -0
  682. chrys/foundation/util/once_close.py +46 -0
  683. chrys/foundation/util/session_ids.py +29 -0
  684. chrys/foundation/util/shell_tokens.py +88 -0
  685. chrys/foundation/util/sub_agent_context.py +38 -0
  686. chrys/foundation/util/time.py +35 -0
  687. chrys/foundation/util/unicode_scalars.py +36 -0
  688. chrys/foundation/vendor/__init__.py +43 -0
  689. chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
  690. chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
  691. chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
  692. chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
  693. chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
  694. chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
  695. chrys/kernel/__init__.py +256 -0
  696. chrys/kernel/_content.py +1866 -0
  697. chrys/kernel/_loop_recorder.py +631 -0
  698. chrys/kernel/_null_overlay.py +1026 -0
  699. chrys/kernel/_result_ceiling.py +216 -0
  700. chrys/kernel/_serialization.py +431 -0
  701. chrys/kernel/_tool_arg_errors.py +1629 -0
  702. chrys/kernel/_tool_execution.py +1172 -0
  703. chrys/kernel/_tool_expansion.py +26 -0
  704. chrys/kernel/_types.py +2598 -0
  705. chrys/kernel/agent.py +994 -0
  706. chrys/kernel/client.py +1084 -0
  707. chrys/kernel/compaction.py +1315 -0
  708. chrys/kernel/exceptions.py +211 -0
  709. chrys/kernel/exchanges.py +683 -0
  710. chrys/kernel/identity.py +95 -0
  711. chrys/kernel/images.py +36 -0
  712. chrys/kernel/instrumentation.py +1618 -0
  713. chrys/kernel/loop.py +2308 -0
  714. chrys/kernel/middleware.py +616 -0
  715. chrys/kernel/sessions.py +604 -0
  716. chrys/kernel/tools.py +1061 -0
  717. chrys/kernel/types.py +126 -0
  718. chrys/orchestration/__init__.py +3 -0
  719. chrys/orchestration/engine/__init__.py +3 -0
  720. chrys/orchestration/engine/assembly.py +236 -0
  721. chrys/orchestration/engine/build/__init__.py +3 -0
  722. chrys/orchestration/engine/build/builder.py +1061 -0
  723. chrys/orchestration/engine/build/construction.py +408 -0
  724. chrys/orchestration/engine/build/loaded.py +139 -0
  725. chrys/orchestration/engine/engine.py +789 -0
  726. chrys/orchestration/engine/execution.py +771 -0
  727. chrys/orchestration/engine/loader.py +865 -0
  728. chrys/orchestration/engine/rollback.py +918 -0
  729. chrys/orchestration/engine/run/__init__.py +3 -0
  730. chrys/orchestration/engine/run/active_injection.py +419 -0
  731. chrys/orchestration/engine/run/attachments.py +519 -0
  732. chrys/orchestration/engine/run/bindings.py +1095 -0
  733. chrys/orchestration/engine/run/coordinator.py +1062 -0
  734. chrys/orchestration/engine/run/finalizer.py +417 -0
  735. chrys/orchestration/engine/run/input_refs.py +60 -0
  736. chrys/orchestration/engine/run/prompt_content.py +457 -0
  737. chrys/orchestration/engine/run/resume.py +299 -0
  738. chrys/orchestration/engine/run/retry.py +948 -0
  739. chrys/orchestration/engine/run/runner.py +625 -0
  740. chrys/orchestration/engine/run/runtime_skills.py +146 -0
  741. chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
  742. chrys/orchestration/engine/run/turn_hooks.py +258 -0
  743. chrys/orchestration/engine/run/turn_state.py +149 -0
  744. chrys/orchestration/engine/run/working_dir.py +50 -0
  745. chrys/orchestration/engine/session_lifecycle.py +2013 -0
  746. chrys/orchestration/engine/state/__init__.py +3 -0
  747. chrys/orchestration/engine/state/active_session.py +183 -0
  748. chrys/orchestration/engine/state/controls.py +639 -0
  749. chrys/orchestration/engine/state/current_agent.py +26 -0
  750. chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
  751. chrys/orchestration/engine/state/machine.py +193 -0
  752. chrys/orchestration/engine/state/session_writer.py +434 -0
  753. chrys/orchestration/engine/trajectory.py +714 -0
  754. chrys/orchestration/engine/usage.py +118 -0
  755. chrys/orchestration/invoker/__init__.py +3 -0
  756. chrys/orchestration/invoker/acp.py +727 -0
  757. chrys/orchestration/invoker/acp_protocol.py +1115 -0
  758. chrys/orchestration/invoker/acp_spec.py +79 -0
  759. chrys/orchestration/invoker/attempts.py +994 -0
  760. chrys/orchestration/invoker/child_compaction.py +131 -0
  761. chrys/orchestration/invoker/child_history.py +99 -0
  762. chrys/orchestration/invoker/contracts.py +253 -0
  763. chrys/orchestration/invoker/evidence.py +94 -0
  764. chrys/orchestration/invoker/kernel.py +343 -0
  765. chrys/orchestration/invoker/origin.py +79 -0
  766. chrys/orchestration/invoker/resources.py +343 -0
  767. chrys/orchestration/invoker/runtime.py +299 -0
  768. chrys/orchestration/invoker/usage.py +80 -0
  769. chrys/orchestration/session_hooks.py +117 -0
  770. chrys/orchestration/session_host.py +1023 -0
  771. chrys/orchestration/session_resources.py +95 -0
  772. chrys/orchestration/session_usage.py +219 -0
  773. chrys/orchestration/startup.py +255 -0
  774. chrys/orchestration/sub_agents/__init__.py +3 -0
  775. chrys/orchestration/sub_agents/acp_policy.py +269 -0
  776. chrys/orchestration/sub_agents/kernel_policy.py +848 -0
  777. chrys/orchestration/sub_agents/registration.py +44 -0
  778. chrys/orchestration/sub_agents/shell.py +337 -0
  779. chrys/orchestration/sub_agents/tools.py +2228 -0
  780. chrys/orchestration/workflows/__init__.py +3 -0
  781. chrys/orchestration/workflows/agent_archive.py +156 -0
  782. chrys/orchestration/workflows/agent_node.py +894 -0
  783. chrys/orchestration/workflows/agent_node_build.py +687 -0
  784. chrys/orchestration/workflows/catalog.py +332 -0
  785. chrys/orchestration/workflows/coordinator.py +771 -0
  786. chrys/orchestration/workflows/hooks.py +176 -0
  787. chrys/orchestration/workflows/preview.py +322 -0
  788. chrys/orchestration/workflows/rollback.py +151 -0
  789. chrys/orchestration/workflows/runner.py +986 -0
  790. chrys/orchestration/workflows/session.py +390 -0
  791. chrys/orchestration/workflows/settings.py +97 -0
  792. chrys/orchestration/workflows/validation.py +674 -0
  793. chrys/orchestration/workflows/worker_client.py +1002 -0
  794. chrys/service/__init__.py +3 -0
  795. chrys/service/acp_client/__init__.py +65 -0
  796. chrys/service/acp_client/_transport.py +922 -0
  797. chrys/service/acp_client/client.py +1311 -0
  798. chrys/service/acp_client/errors.py +224 -0
  799. chrys/service/acp_client/protocol.py +220 -0
  800. chrys/service/acp_client/spawn.py +239 -0
  801. chrys/service/acp_client/spec.py +100 -0
  802. chrys/service/agent_middleware/__init__.py +60 -0
  803. chrys/service/agent_middleware/_metadata_keys.py +52 -0
  804. chrys/service/agent_middleware/control/__init__.py +3 -0
  805. chrys/service/agent_middleware/control/approval.py +769 -0
  806. chrys/service/agent_middleware/control/approval_hooks.py +62 -0
  807. chrys/service/agent_middleware/control/ask_user.py +207 -0
  808. chrys/service/agent_middleware/control/interrupt.py +50 -0
  809. chrys/service/agent_middleware/control/sleep.py +118 -0
  810. chrys/service/agent_middleware/control/todo.py +117 -0
  811. chrys/service/agent_middleware/events/__init__.py +3 -0
  812. chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
  813. chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
  814. chrys/service/agent_middleware/events/intermediate_text.py +128 -0
  815. chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
  816. chrys/service/agent_middleware/events/result_persistence.py +98 -0
  817. chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
  818. chrys/service/agent_middleware/events/tool_error_text.py +22 -0
  819. chrys/service/agent_middleware/events/tool_events.py +779 -0
  820. chrys/service/agent_middleware/injection.py +510 -0
  821. chrys/service/agent_middleware/reminders/__init__.py +91 -0
  822. chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
  823. chrys/service/agent_middleware/reminders/base.py +25 -0
  824. chrys/service/agent_middleware/reminders/context_usage.py +74 -0
  825. chrys/service/agent_middleware/reminders/file_change.py +63 -0
  826. chrys/service/agent_middleware/reminders/mcp.py +39 -0
  827. chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
  828. chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
  829. chrys/service/agent_middleware/reminders/skills.py +44 -0
  830. chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
  831. chrys/service/agent_middleware/reminders/todo.py +35 -0
  832. chrys/service/agent_middleware/reminders/turn_line.py +48 -0
  833. chrys/service/agent_middleware/response_validation.py +1380 -0
  834. chrys/service/agent_middleware/system_reminder.py +1107 -0
  835. chrys/service/agent_middleware/validators.py +396 -0
  836. chrys/service/analytics/__init__.py +124 -0
  837. chrys/service/analytics/_actions.py +896 -0
  838. chrys/service/analytics/_context_evidence.py +357 -0
  839. chrys/service/analytics/_critical_path.py +179 -0
  840. chrys/service/analytics/_facts.py +919 -0
  841. chrys/service/analytics/_insights.py +769 -0
  842. chrys/service/analytics/_metric_ops.py +83 -0
  843. chrys/service/analytics/_session_projection.py +460 -0
  844. chrys/service/analytics/_timeline.py +434 -0
  845. chrys/service/analytics/_turn_graph.py +1237 -0
  846. chrys/service/analytics/_turns.py +1377 -0
  847. chrys/service/analytics/_workflows.py +171 -0
  848. chrys/service/analytics/aggregation.py +1474 -0
  849. chrys/service/analytics/classification.py +128 -0
  850. chrys/service/analytics/export.py +803 -0
  851. chrys/service/analytics/findings.py +282 -0
  852. chrys/service/analytics/math.py +79 -0
  853. chrys/service/analytics/model.py +886 -0
  854. chrys/service/analytics/reader.py +321 -0
  855. chrys/service/approval/__init__.py +3 -0
  856. chrys/service/approval/arbitration.py +102 -0
  857. chrys/service/approval/command_identity.py +109 -0
  858. chrys/service/approval/correlation.py +57 -0
  859. chrys/service/approval/grant_store.py +108 -0
  860. chrys/service/approval/judge.py +589 -0
  861. chrys/service/approval/level.py +15 -0
  862. chrys/service/approval/policy.py +209 -0
  863. chrys/service/approval/reuse.py +223 -0
  864. chrys/service/approval/reuse_binding.py +133 -0
  865. chrys/service/approval/safety_classifier.py +536 -0
  866. chrys/service/approval/turn_context.py +49 -0
  867. chrys/service/context/__init__.py +3 -0
  868. chrys/service/context/compaction/__init__.py +38 -0
  869. chrys/service/context/compaction/breaker.py +156 -0
  870. chrys/service/context/compaction/budgets.py +93 -0
  871. chrys/service/context/compaction/calibration.py +77 -0
  872. chrys/service/context/compaction/compression.py +690 -0
  873. chrys/service/context/compaction/current_turn_drop.py +458 -0
  874. chrys/service/context/compaction/events.py +201 -0
  875. chrys/service/context/compaction/exclusions.py +261 -0
  876. chrys/service/context/compaction/groups.py +361 -0
  877. chrys/service/context/compaction/last_words.py +1996 -0
  878. chrys/service/context/compaction/last_words_state.py +752 -0
  879. chrys/service/context/compaction/scoped.py +472 -0
  880. chrys/service/context/compaction/spill.py +1947 -0
  881. chrys/service/context/compaction/strategy.py +1116 -0
  882. chrys/service/context/compaction/summaries.py +319 -0
  883. chrys/service/context/compaction/turns.py +375 -0
  884. chrys/service/context/manager.py +174 -0
  885. chrys/service/context/memory_loader.py +792 -0
  886. chrys/service/context/middleware/__init__.py +3 -0
  887. chrys/service/context/middleware/usage.py +332 -0
  888. chrys/service/context/providers/__init__.py +3 -0
  889. chrys/service/context/providers/context_management.py +432 -0
  890. chrys/service/context/providers/history.py +647 -0
  891. chrys/service/context/providers/memory.py +50 -0
  892. chrys/service/context/tool_content.py +22 -0
  893. chrys/service/hooks/__init__.py +37 -0
  894. chrys/service/hooks/detached_worker.py +118 -0
  895. chrys/service/hooks/docs/README.md +14 -0
  896. chrys/service/hooks/docs/authoring.md +684 -0
  897. chrys/service/hooks/docs/configuration.md +499 -0
  898. chrys/service/hooks/docs/design.md +359 -0
  899. chrys/service/hooks/docs/project-hooks.md +348 -0
  900. chrys/service/hooks/events.py +157 -0
  901. chrys/service/hooks/loader.py +665 -0
  902. chrys/service/hooks/manager.py +807 -0
  903. chrys/service/hooks/matcher.py +111 -0
  904. chrys/service/hooks/outbox.py +294 -0
  905. chrys/service/hooks/runner.py +572 -0
  906. chrys/service/hooks/schema.py +407 -0
  907. chrys/service/llm/__init__.py +7 -0
  908. chrys/service/llm/_structured_outputs.py +192 -0
  909. chrys/service/llm/anthropic_messages/__init__.py +9 -0
  910. chrys/service/llm/anthropic_messages/client.py +254 -0
  911. chrys/service/llm/anthropic_messages/decode.py +331 -0
  912. chrys/service/llm/anthropic_messages/history.py +279 -0
  913. chrys/service/llm/anthropic_messages/request.py +323 -0
  914. chrys/service/llm/anthropic_messages/server_tools.py +319 -0
  915. chrys/service/llm/anthropic_messages/stream.py +297 -0
  916. chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
  917. chrys/service/llm/chat_completions/__init__.py +9 -0
  918. chrys/service/llm/chat_completions/client.py +369 -0
  919. chrys/service/llm/chat_completions/decode.py +338 -0
  920. chrys/service/llm/chat_completions/history.py +657 -0
  921. chrys/service/llm/chat_completions/reasoning.py +244 -0
  922. chrys/service/llm/chat_completions/request.py +192 -0
  923. chrys/service/llm/chat_completions/stream.py +546 -0
  924. chrys/service/llm/chat_completions/validation.py +308 -0
  925. chrys/service/llm/clients.py +613 -0
  926. chrys/service/llm/images.py +65 -0
  927. chrys/service/llm/mock.py +413 -0
  928. chrys/service/llm/observer.py +446 -0
  929. chrys/service/llm/one_shot.py +106 -0
  930. chrys/service/llm/openai_exceptions.py +93 -0
  931. chrys/service/llm/openai_responses/__init__.py +10 -0
  932. chrys/service/llm/openai_responses/client.py +323 -0
  933. chrys/service/llm/openai_responses/decode.py +432 -0
  934. chrys/service/llm/openai_responses/history.py +519 -0
  935. chrys/service/llm/openai_responses/hosted.py +625 -0
  936. chrys/service/llm/openai_responses/replay.py +629 -0
  937. chrys/service/llm/openai_responses/request.py +342 -0
  938. chrys/service/llm/openai_responses/stream.py +847 -0
  939. chrys/service/llm/openai_timestamps.py +57 -0
  940. chrys/service/llm/providers.py +107 -0
  941. chrys/service/llm/proxy_route.py +54 -0
  942. chrys/service/llm/raw_http_log.py +309 -0
  943. chrys/service/llm/route_facts.py +63 -0
  944. chrys/service/llm/route_sessions.py +49 -0
  945. chrys/service/llm/sdk_anthropic.py +18 -0
  946. chrys/service/llm/sdk_headers.py +47 -0
  947. chrys/service/llm/sdk_openai.py +18 -0
  948. chrys/service/llm/sdk_retry.py +59 -0
  949. chrys/service/llm/wire_client.py +246 -0
  950. chrys/service/mcp/__init__.py +3 -0
  951. chrys/service/mcp/_connection.py +235 -0
  952. chrys/service/mcp/_http_transport.py +350 -0
  953. chrys/service/mcp/_progressive.py +459 -0
  954. chrys/service/mcp/_stdio_transport.py +614 -0
  955. chrys/service/mcp/_tool_mixins.py +96 -0
  956. chrys/service/mcp/adapter.py +1031 -0
  957. chrys/service/mcp/cache.py +476 -0
  958. chrys/service/mcp/content.py +32 -0
  959. chrys/service/mcp/errors.py +169 -0
  960. chrys/service/mcp/owned.py +1330 -0
  961. chrys/service/mcp/result_limits.py +168 -0
  962. chrys/service/mcp/thinking_warning.py +95 -0
  963. chrys/service/mcp/validation.py +74 -0
  964. chrys/service/mutations/__init__.py +84 -0
  965. chrys/service/mutations/coordination.py +894 -0
  966. chrys/service/mutations/detector.py +458 -0
  967. chrys/service/mutations/git_calibrator.py +397 -0
  968. chrys/service/mutations/git_state.py +647 -0
  969. chrys/service/mutations/pipeline.py +779 -0
  970. chrys/service/mutations/scanner.py +466 -0
  971. chrys/service/mutations/snapshot_files.py +233 -0
  972. chrys/service/mutations/store.py +625 -0
  973. chrys/service/mutations/tool_names.py +15 -0
  974. chrys/service/mutations/trace.py +246 -0
  975. chrys/service/mutations/tracker.py +1289 -0
  976. chrys/service/mutations/types.py +559 -0
  977. chrys/service/mutations/workspace_changes.py +1583 -0
  978. chrys/service/profiles/__init__.py +2 -0
  979. chrys/service/profiles/agents/__init__.py +3 -0
  980. chrys/service/profiles/agents/builtins/Code.yaml +199 -0
  981. chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
  982. chrys/service/profiles/agents/builtins/General.yaml +114 -0
  983. chrys/service/profiles/agents/builtins/QA.yaml +128 -0
  984. chrys/service/profiles/agents/loader.py +808 -0
  985. chrys/service/profiles/agents/registry.py +727 -0
  986. chrys/service/profiles/agents/schema.py +371 -0
  987. chrys/service/profiles/agents/serializer.py +196 -0
  988. chrys/service/profiles/coercion.py +22 -0
  989. chrys/service/profiles/models/__init__.py +3 -0
  990. chrys/service/profiles/models/env_bridge.py +157 -0
  991. chrys/service/profiles/models/loader.py +282 -0
  992. chrys/service/profiles/models/options.py +438 -0
  993. chrys/service/profiles/models/registry.py +104 -0
  994. chrys/service/profiles/models/resolver.py +286 -0
  995. chrys/service/profiles/models/schema.py +91 -0
  996. chrys/service/profiles/models/serializer.py +76 -0
  997. chrys/service/session/__init__.py +3 -0
  998. chrys/service/session/checkpoint.py +156 -0
  999. chrys/service/session/history.py +1616 -0
  1000. chrys/service/session/message_metadata.py +151 -0
  1001. chrys/service/session/persistence.py +435 -0
  1002. chrys/service/session/runtime_metadata.py +210 -0
  1003. chrys/service/session/sub_agent_logs.py +1069 -0
  1004. chrys/service/session/sub_agent_transcript.py +654 -0
  1005. chrys/service/skills/__init__.py +3 -0
  1006. chrys/service/skills/adapter.py +408 -0
  1007. chrys/service/skills/constants.py +8 -0
  1008. chrys/service/skills/loader.py +575 -0
  1009. chrys/service/skills/model.py +130 -0
  1010. chrys/service/skills/provider.py +841 -0
  1011. chrys/service/skills/runner.py +390 -0
  1012. chrys/service/state/__init__.py +3 -0
  1013. chrys/service/state/_fork.py +710 -0
  1014. chrys/service/state/_session_files.py +175 -0
  1015. chrys/service/state/_session_meta.py +552 -0
  1016. chrys/service/state/locks.py +154 -0
  1017. chrys/service/state/serializers.py +156 -0
  1018. chrys/service/state/session_catalog.py +361 -0
  1019. chrys/service/state/session_listing.py +84 -0
  1020. chrys/service/state/session_migration.py +446 -0
  1021. chrys/service/state/session_mru.py +315 -0
  1022. chrys/service/state/store.py +2132 -0
  1023. chrys/service/state/workflow.py +158 -0
  1024. chrys/service/text_blocks.py +47 -0
  1025. chrys/service/todos/__init__.py +3 -0
  1026. chrys/service/todos/reminder.py +31 -0
  1027. chrys/service/todos/tracker.py +58 -0
  1028. chrys/service/tools/__init__.py +3 -0
  1029. chrys/service/tools/approval_targets.py +92 -0
  1030. chrys/service/tools/builtins/__init__.py +3 -0
  1031. chrys/service/tools/builtins/ask_user.py +326 -0
  1032. chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
  1033. chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
  1034. chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
  1035. chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
  1036. chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
  1037. chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
  1038. chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
  1039. chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
  1040. chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
  1041. chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
  1042. chrys/service/tools/builtins/doc_converter/registry.py +55 -0
  1043. chrys/service/tools/builtins/filesystem.py +1071 -0
  1044. chrys/service/tools/builtins/search.py +1265 -0
  1045. chrys/service/tools/builtins/shell.py +967 -0
  1046. chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
  1047. chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
  1048. chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
  1049. chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
  1050. chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
  1051. chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
  1052. chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
  1053. chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
  1054. chrys/service/tools/builtins/shell_filter.py +24 -0
  1055. chrys/service/tools/builtins/sleep.py +91 -0
  1056. chrys/service/tools/builtins/todo.py +64 -0
  1057. chrys/service/tools/builtins/web/__init__.py +6 -0
  1058. chrys/service/tools/builtins/web/build.py +268 -0
  1059. chrys/service/tools/builtins/web/config.py +338 -0
  1060. chrys/service/tools/builtins/web/credentials.py +37 -0
  1061. chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
  1062. chrys/service/tools/builtins/web/fetch/cache.py +68 -0
  1063. chrys/service/tools/builtins/web/fetch/convert.py +56 -0
  1064. chrys/service/tools/builtins/web/fetch/tool.py +223 -0
  1065. chrys/service/tools/builtins/web/hosted.py +61 -0
  1066. chrys/service/tools/builtins/web/http.py +330 -0
  1067. chrys/service/tools/builtins/web/progress.py +18 -0
  1068. chrys/service/tools/builtins/web/search/__init__.py +3 -0
  1069. chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
  1070. chrys/service/tools/builtins/web/search/html.py +152 -0
  1071. chrys/service/tools/builtins/web/search/json_api.py +227 -0
  1072. chrys/service/tools/builtins/web/search/providers.py +77 -0
  1073. chrys/service/tools/builtins/web/search/tool.py +218 -0
  1074. chrys/service/tools/builtins/web/search/types.py +28 -0
  1075. chrys/service/tools/builtins/web/tls_identity.py +66 -0
  1076. chrys/service/tools/kinds.py +115 -0
  1077. chrys/service/tools/names.py +47 -0
  1078. chrys/service/tools/registry.py +242 -0
  1079. chrys/service/tools/result_metadata.py +103 -0
  1080. chrys/service/tools/session_artifacts.py +196 -0
  1081. chrys/service/tools/spill.py +218 -0
  1082. chrys/service/tools/workspace_paths.py +81 -0
  1083. chrys/service/trajectory/__init__.py +11 -0
  1084. chrys/service/trajectory/approvals.py +201 -0
  1085. chrys/service/trajectory/compaction.py +420 -0
  1086. chrys/service/trajectory/fork.py +75 -0
  1087. chrys/service/trajectory/hooks.py +217 -0
  1088. chrys/service/trajectory/items.py +41 -0
  1089. chrys/service/trajectory/preparation.py +374 -0
  1090. chrys/service/trajectory/retries.py +95 -0
  1091. chrys/service/trajectory/revisions.py +129 -0
  1092. chrys/service/trajectory/segmented.py +163 -0
  1093. chrys/service/trajectory/session.py +762 -0
  1094. chrys/service/trajectory/state.py +83 -0
  1095. chrys/service/trajectory/sub_agents.py +169 -0
  1096. chrys/service/trajectory/tombstone.py +262 -0
  1097. chrys/service/trajectory/tools.py +323 -0
  1098. chrys/service/trajectory/validation.py +130 -0
  1099. chrys/service/trajectory/waits.py +161 -0
  1100. chrys/service/trajectory/workflow.py +148 -0
  1101. chrys/service/vision.py +128 -0
  1102. chrys/service/workflows/__init__.py +3 -0
  1103. chrys/service/workflows/admission.py +151 -0
  1104. chrys/service/workflows/artifacts.py +109 -0
  1105. chrys/service/workflows/asks.py +130 -0
  1106. chrys/service/workflows/builtins/__init__.py +12 -0
  1107. chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
  1108. chrys/service/workflows/builtins/demo-workflow.py +756 -0
  1109. chrys/service/workflows/discovery.py +554 -0
  1110. chrys/service/workflows/environment.py +284 -0
  1111. chrys/service/workflows/graph.py +430 -0
  1112. chrys/service/workflows/history.py +82 -0
  1113. chrys/service/workflows/interpreter.py +107 -0
  1114. chrys/service/workflows/journal.py +334 -0
  1115. chrys/service/workflows/layout.py +56 -0
  1116. chrys/service/workflows/ledger.py +149 -0
  1117. chrys/service/workflows/model_selection.py +18 -0
  1118. chrys/service/workflows/orphans.py +209 -0
  1119. chrys/service/workflows/outcomes.py +46 -0
  1120. chrys/service/workflows/protocol.py +210 -0
  1121. chrys/service/workflows/records.py +142 -0
  1122. chrys/service/workflows/scheduler.py +770 -0
  1123. chrys/service/workflows/sdk/__init__.py +51 -0
  1124. chrys/service/workflows/sdk/_ask.py +167 -0
  1125. chrys/service/workflows/sdk/_builder.py +781 -0
  1126. chrys/service/workflows/sdk/_values.py +92 -0
  1127. chrys/service/workflows/sdk_artifact.py +74 -0
  1128. chrys/service/workflows/store.py +494 -0
  1129. chrys/service/workflows/transcript.py +102 -0
  1130. chrys/service/workflows/values.py +73 -0
  1131. chrys/service/workflows/worker_host.py +1820 -0
  1132. chrys/workflows.py +38 -0
  1133. icode_tui-0.29.1.dist-info/METADATA +104 -0
  1134. icode_tui-0.29.1.dist-info/RECORD +1138 -0
  1135. icode_tui-0.29.1.dist-info/WHEEL +4 -0
  1136. icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
  1137. icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
  1138. icode_tui-0.29.1.dist-info/licenses/NOTICE +1955 -0
@@ -0,0 +1,1947 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """Phase-4 dropped-call records, catalog projection, and reconciliation."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ import os
12
+ import re
13
+ import threading
14
+ from collections.abc import Iterable, Mapping, Sequence
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any, BinaryIO, cast
19
+ from urllib.parse import parse_qsl, urlsplit
20
+ from uuid import uuid4
21
+
22
+ from chrys.foundation.platform import get_platform
23
+ from chrys.foundation.platform.files import atomic_write_text, fsync_directory, is_utf8_encodable
24
+ from chrys.foundation.text.images import hashable_image_identity
25
+ from chrys.foundation.tool_execution_stamp import (
26
+ canonicalize_effective_arguments,
27
+ execution_stamp_from_metadata,
28
+ )
29
+ from chrys.foundation.tool_result_metadata import TOOL_ERROR_KIND_METADATA_KEY
30
+ from chrys.foundation.util.filenames import bounded_safe_stem, safe_platform_basename
31
+ from chrys.kernel import TOOL_CALL_CONTENT_TYPES, Content, is_image_content
32
+
33
+ if TYPE_CHECKING:
34
+ from chrys.service.context.compaction.scoped import ScopedGroup
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ PER_RECORD_CAP_BYTES = 2 * 1024 * 1024
39
+ """Maximum bytes retained in one spill record."""
40
+
41
+ PER_ROUND_CAP_BYTES = 64 * 1024 * 1024
42
+ """Maximum spill-record bytes admitted by one compaction round."""
43
+
44
+ PER_SESSION_QUOTA_BYTES = 256 * 1024 * 1024
45
+ """Maximum live spill-record bytes retained by one session."""
46
+
47
+ COMPACTIONS_DIR_NAME = "compactions"
48
+ DROPPED_DIR_NAME = "dropped"
49
+ CATALOG_FILE_NAME = "catalog.jsonl"
50
+ MANIFEST_FILE_NAME = "manifest.md"
51
+ SUB_AGENTS_DIR_NAME = "sub_agents"
52
+ RECORD_FILE_SUFFIX = ".md"
53
+
54
+ CATALOG_RELATIVE_PATH = Path(COMPACTIONS_DIR_NAME) / DROPPED_DIR_NAME / CATALOG_FILE_NAME
55
+ """Session-relative location of the append-only record catalog."""
56
+
57
+ RECORD_ID_HEX_CHARS = 8
58
+ """Record-ID length: a turn spills at most a few hundred groups and filename
59
+ collisions are handled by the exclusive-write retry, so 8 hex chars suffice."""
60
+
61
+ CATALOG_KIND_GROUP = "group"
62
+ """Catalog ``kind`` for records archiving one dropped scoped group."""
63
+
64
+ CATALOG_KIND_NOTE = "note"
65
+ """Catalog ``kind`` for records archiving one superseded LAST_WORDS note."""
66
+
67
+ NOTE_RECORD_TOOL_NAME = "last_words"
68
+ """Pseudo tool name used in note-record filenames and manifest lines."""
69
+
70
+ NOTE_RECORD_GROUP_ID = "superseded_note"
71
+ """Synthetic manifest group ID for note records (no scoped group backs them)."""
72
+
73
+ _RECORD_ID_RE = re.compile(rf"^[0-9a-f]{{{RECORD_ID_HEX_CHARS}}}$")
74
+ _TURN_DIRECTORY_RE = re.compile(r"^turn[0-9]{3,}$")
75
+ _RECORD_TERMINATOR = "<!-- end of record -->\n"
76
+ _RECORD_TRUNCATION_TEMPLATE = "\n[… middle-truncated from {size} bytes …]\n"
77
+
78
+
79
+ class _UnsafeCatalogPathError(OSError):
80
+ """The fixed spill catalog path is not owned directly by its session root."""
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class CatalogRecord:
85
+ """One live catalog record."""
86
+
87
+ record_id: str
88
+ relative_path: str
89
+ turn: int
90
+ round: int
91
+ tool: str
92
+ bytes: int
93
+ created_at: str
94
+ kind: str = CATALOG_KIND_GROUP
95
+
96
+ def to_catalog_line(self) -> dict[str, Any]:
97
+ """Return the append-only JSON object for this record."""
98
+ return {
99
+ "record_id": self.record_id,
100
+ "relative_path": self.relative_path,
101
+ "turn": self.turn,
102
+ "round": self.round,
103
+ "tool": self.tool,
104
+ "bytes": self.bytes,
105
+ "created_at": self.created_at,
106
+ "kind": self.kind,
107
+ }
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class SpillManifestItem:
112
+ """Neutral spill result converted to ``last_words_state.ManifestEntry`` state."""
113
+
114
+ record_id: str
115
+ group_id: str
116
+ record_dir: str
117
+ relative_path: str
118
+ turn: int
119
+ round: int
120
+ sequence: int
121
+ tool: str
122
+ display_argument: str
123
+ outcome: str
124
+ size_chars: int
125
+ assistant_text: bool = False
126
+ available: bool = True
127
+ no_record_reason: str = ""
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class SpillBatchResult:
132
+ """Worker-thread result for one Phase-4 spill batch."""
133
+
134
+ entries: tuple[SpillManifestItem, ...]
135
+ unexpected_io_failure: bool = False
136
+ evicted_relative_paths: frozenset[str] = frozenset()
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class SpillReconciliationResult:
141
+ """Catalog-derived state produced while restoring a session."""
142
+
143
+ live_total_bytes: int
144
+ live_record_count: int
145
+ available_relative_paths: frozenset[str]
146
+ deleted_orphans: int = 0
147
+
148
+
149
+ class SpillQuota:
150
+ """Thread-safe session spill ledger with reserve-before-write semantics.
151
+
152
+ ``spent_bytes`` reports committed live bytes; outstanding reservations also
153
+ consume headroom internally. ``commit`` rejects an actual byte count larger
154
+ than its reservation and leaves that reservation intact for explicit release.
155
+ ``reclaim`` returns committed headroom after cataloged records are evicted.
156
+ Reconciliation failures mark storage unavailable so retained but unaccounted
157
+ records can never be followed by a fresh quota's worth of writes.
158
+
159
+ The separate ``catalog_lock`` serializes the session's append-only catalog
160
+ and its ``manifest.md`` projections across main and sub-agent workers. It is
161
+ intentionally distinct from the quota ledger lock so catalog work may call
162
+ reserve/commit/reclaim without lock recursion.
163
+ """
164
+
165
+ def __init__(self, limit_bytes: int = PER_SESSION_QUOTA_BYTES) -> None:
166
+ if limit_bytes < 0:
167
+ raise ValueError("limit_bytes must be non-negative")
168
+ self._limit_bytes = limit_bytes
169
+ self._spent_bytes = 0
170
+ self._reserved_bytes = 0
171
+ self._available_relative_paths: set[str] = set()
172
+ self._committed_bytes_by_path: dict[str, int] = {}
173
+ self._live_record_counts_by_path: dict[str, int] = {}
174
+ self._live_catalog_records_by_id: dict[str, CatalogRecord] = {}
175
+ self._catalog_record_id_by_path: dict[str, str] = {}
176
+ self._live_catalog_records_by_directory: dict[str, dict[str, CatalogRecord]] = {}
177
+ self._live_record_count = 0
178
+ self._storage_available = True
179
+ self._lock = threading.Lock()
180
+ self._catalog_lock = threading.Lock()
181
+
182
+ @property
183
+ def spent_bytes(self) -> int:
184
+ """Return committed live bytes, excluding outstanding reservations."""
185
+ with self._lock:
186
+ return self._spent_bytes
187
+
188
+ @property
189
+ def catalog_lock(self) -> threading.Lock:
190
+ """Return the engine-owned lock serializing catalog/projection work."""
191
+ return self._catalog_lock
192
+
193
+ @property
194
+ def storage_available(self) -> bool:
195
+ """Return whether reconciled spill storage may accept new records."""
196
+ with self._lock:
197
+ return self._storage_available
198
+
199
+ def initialize(
200
+ self,
201
+ live_total_bytes: int,
202
+ available_relative_paths: Iterable[str] = (),
203
+ *,
204
+ committed_bytes_by_path: Mapping[str, int] | None = None,
205
+ live_relative_paths: Iterable[str] | None = None,
206
+ live_catalog_records: Iterable[CatalogRecord] | None = None,
207
+ ) -> None:
208
+ """Set the catalog-derived committed baseline before spill work starts."""
209
+ if live_total_bytes < 0:
210
+ raise ValueError("live_total_bytes must be non-negative")
211
+ available = set(available_relative_paths)
212
+ committed = dict(committed_bytes_by_path or {})
213
+ catalog_records = list(live_catalog_records or ())
214
+ if live_relative_paths is not None:
215
+ live_paths = live_relative_paths
216
+ elif catalog_records:
217
+ live_paths = (record.relative_path for record in catalog_records)
218
+ else:
219
+ live_paths = available
220
+ live_counts: dict[str, int] = {}
221
+ for relative_path in live_paths:
222
+ live_counts[relative_path] = live_counts.get(relative_path, 0) + 1
223
+ if any(byte_count < 0 for byte_count in committed.values()):
224
+ raise ValueError("committed record bytes must be non-negative")
225
+ if sum(committed.values()) > live_total_bytes:
226
+ raise ValueError("committed record bytes cannot exceed live_total_bytes")
227
+ with self._lock:
228
+ if self._reserved_bytes:
229
+ raise RuntimeError("cannot initialize SpillQuota with outstanding reservations")
230
+ self._spent_bytes = live_total_bytes
231
+ self._available_relative_paths = available
232
+ self._committed_bytes_by_path = committed
233
+ self._live_record_counts_by_path = live_counts
234
+ self._live_catalog_records_by_id = {record.record_id: record for record in catalog_records}
235
+ self._catalog_record_id_by_path = {record.relative_path: record.record_id for record in catalog_records}
236
+ records_by_directory: dict[str, dict[str, CatalogRecord]] = {}
237
+ for record in catalog_records:
238
+ directory = Path(record.relative_path).parent.as_posix()
239
+ records_by_directory.setdefault(directory, {})[record.record_id] = record
240
+ self._live_catalog_records_by_directory = records_by_directory
241
+ self._live_record_count = sum(live_counts.values())
242
+ self._storage_available = True
243
+
244
+ def disable_storage(self) -> None:
245
+ """Fail closed after storage cannot be reconciled safely."""
246
+ with self._lock:
247
+ if self._reserved_bytes:
248
+ raise RuntimeError("cannot disable SpillQuota with outstanding reservations")
249
+ self._spent_bytes = 0
250
+ self._available_relative_paths.clear()
251
+ self._committed_bytes_by_path.clear()
252
+ self._live_record_counts_by_path.clear()
253
+ self._live_catalog_records_by_id.clear()
254
+ self._catalog_record_id_by_path.clear()
255
+ self._live_catalog_records_by_directory.clear()
256
+ self._live_record_count = 0
257
+ self._storage_available = False
258
+
259
+ def try_reserve(self, byte_count: int) -> bool:
260
+ """Atomically reserve quota headroom, returning false at the limit."""
261
+ if byte_count < 0:
262
+ raise ValueError("byte_count must be non-negative")
263
+ with self._lock:
264
+ if not self._storage_available:
265
+ return False
266
+ if self._spent_bytes + self._reserved_bytes + byte_count > self._limit_bytes:
267
+ return False
268
+ self._reserved_bytes += byte_count
269
+ return True
270
+
271
+ def commit(
272
+ self,
273
+ reserved: int,
274
+ actual: int,
275
+ *,
276
+ relative_path: str | None = None,
277
+ catalog_record: CatalogRecord | None = None,
278
+ ) -> None:
279
+ """Replace one reservation with the actual successfully written bytes."""
280
+ if reserved < 0 or actual < 0:
281
+ raise ValueError("reserved and actual must be non-negative")
282
+ if actual > reserved:
283
+ raise ValueError("actual bytes cannot exceed the reservation")
284
+ if catalog_record is not None:
285
+ if catalog_record.bytes != actual:
286
+ raise ValueError("catalog record bytes must equal committed bytes")
287
+ if relative_path is not None and relative_path != catalog_record.relative_path:
288
+ raise ValueError("catalog record path must equal committed relative_path")
289
+ relative_path = catalog_record.relative_path
290
+ with self._lock:
291
+ if reserved > self._reserved_bytes:
292
+ raise RuntimeError("commit exceeds outstanding SpillQuota reservations")
293
+ if catalog_record is not None and (
294
+ catalog_record.record_id in self._live_catalog_records_by_id
295
+ or catalog_record.relative_path in self._catalog_record_id_by_path
296
+ ):
297
+ raise RuntimeError("catalog record is already committed to SpillQuota")
298
+ self._reserved_bytes -= reserved
299
+ self._spent_bytes += actual
300
+ if relative_path:
301
+ self._available_relative_paths.add(relative_path)
302
+ self._committed_bytes_by_path[relative_path] = actual
303
+ self._live_record_counts_by_path[relative_path] = (
304
+ self._live_record_counts_by_path.get(relative_path, 0) + 1
305
+ )
306
+ self._live_record_count += 1
307
+ if catalog_record is not None:
308
+ self._live_catalog_records_by_id[catalog_record.record_id] = catalog_record
309
+ self._catalog_record_id_by_path[catalog_record.relative_path] = catalog_record.record_id
310
+ directory = Path(catalog_record.relative_path).parent.as_posix()
311
+ self._live_catalog_records_by_directory.setdefault(directory, {})[catalog_record.record_id] = (
312
+ catalog_record
313
+ )
314
+
315
+ def reclaim(self, byte_count: int, *, relative_path: str | None = None) -> None:
316
+ """Return committed quota after live spill records are evicted."""
317
+ if byte_count < 0:
318
+ raise ValueError("byte_count must be non-negative")
319
+ with self._lock:
320
+ if byte_count > self._spent_bytes:
321
+ raise RuntimeError("reclaim exceeds committed SpillQuota bytes")
322
+ self._spent_bytes -= byte_count
323
+ if relative_path:
324
+ self._available_relative_paths.discard(relative_path)
325
+ self._committed_bytes_by_path.pop(relative_path, None)
326
+ self._decrement_live_record(relative_path)
327
+
328
+ def reclaim_record(self, relative_path: str) -> int:
329
+ """Return the bytes actually accounted to one evicted record."""
330
+ with self._lock:
331
+ byte_count = self._committed_bytes_by_path.pop(relative_path, 0)
332
+ if byte_count > self._spent_bytes:
333
+ raise RuntimeError("record reclaim exceeds committed SpillQuota bytes")
334
+ self._spent_bytes -= byte_count
335
+ self._available_relative_paths.discard(relative_path)
336
+ self._decrement_live_record(relative_path)
337
+ return byte_count
338
+
339
+ def is_record_available(self, relative_path: str) -> bool:
340
+ """Return current in-memory availability for one cataloged record."""
341
+ with self._lock:
342
+ return relative_path in self._available_relative_paths
343
+
344
+ def has_catalog_record_id(self, record_id: str) -> bool:
345
+ """Return whether one record ID is live anywhere in this session's catalog."""
346
+ with self._lock:
347
+ return record_id in self._live_catalog_records_by_id
348
+
349
+ def live_record_count(self, *, excluded_relative_paths: Iterable[str] = ()) -> int:
350
+ """Return the in-memory live catalog count, excluding active-turn records."""
351
+ excluded = set(excluded_relative_paths)
352
+ with self._lock:
353
+ return self._live_record_count - sum(
354
+ self._live_record_counts_by_path.get(relative_path, 0) for relative_path in excluded
355
+ )
356
+
357
+ def live_catalog_records(self) -> tuple[CatalogRecord, ...]:
358
+ """Return the ordered in-memory mirror of live authoritative catalog records."""
359
+ with self._lock:
360
+ return tuple(self._live_catalog_records_by_id.values())
361
+
362
+ def catalog_records_for_directories(self, relative_dirs: Iterable[Path]) -> tuple[CatalogRecord, ...]:
363
+ """Return live records belonging only to the requested projection directories."""
364
+ directory_keys = {Path(relative_dir).as_posix() for relative_dir in relative_dirs}
365
+ with self._lock:
366
+ return tuple(
367
+ record
368
+ for directory in sorted(directory_keys)
369
+ for record in self._live_catalog_records_by_directory.get(directory, {}).values()
370
+ )
371
+
372
+ def release(self, reserved: int) -> None:
373
+ """Release a reservation after a clean failed or skipped write."""
374
+ if reserved < 0:
375
+ raise ValueError("reserved must be non-negative")
376
+ with self._lock:
377
+ if reserved > self._reserved_bytes:
378
+ raise RuntimeError("release exceeds outstanding SpillQuota reservations")
379
+ self._reserved_bytes -= reserved
380
+
381
+ def _decrement_live_record(self, relative_path: str) -> None:
382
+ count = self._live_record_counts_by_path.get(relative_path, 0)
383
+ if not count:
384
+ return
385
+ self._live_record_count -= 1
386
+ if count <= 1:
387
+ self._live_record_counts_by_path.pop(relative_path, None)
388
+ else:
389
+ self._live_record_counts_by_path[relative_path] = count - 1
390
+ record_id = self._catalog_record_id_by_path.pop(relative_path, None)
391
+ if record_id is not None:
392
+ record = self._live_catalog_records_by_id.pop(record_id, None)
393
+ if record is not None:
394
+ directory = Path(record.relative_path).parent.as_posix()
395
+ directory_records = self._live_catalog_records_by_directory.get(directory)
396
+ if directory_records is not None:
397
+ directory_records.pop(record_id, None)
398
+ if not directory_records:
399
+ self._live_catalog_records_by_directory.pop(directory, None)
400
+
401
+
402
+ def turn_directory_name(absolute_turn: int) -> str:
403
+ """Return the zero-padded directory name for one absolute turn."""
404
+ if absolute_turn < 0:
405
+ raise ValueError("absolute_turn must be non-negative")
406
+ return f"turn{absolute_turn:03d}"
407
+
408
+
409
+ def dropped_turn_relative_path(absolute_turn: int) -> Path:
410
+ """Return ``compactions/dropped/turn<NNN>`` relative to a session root."""
411
+ return Path(COMPACTIONS_DIR_NAME) / DROPPED_DIR_NAME / turn_directory_name(absolute_turn)
412
+
413
+
414
+ def sub_agent_dropped_turn_relative_path(tool_name: str, invocation_id: str, absolute_turn: int) -> Path:
415
+ """Return the isolated dropped-record directory for one sub-agent invocation."""
416
+ return (
417
+ Path(COMPACTIONS_DIR_NAME)
418
+ / SUB_AGENTS_DIR_NAME
419
+ / safe_platform_basename(tool_name, default="agent")
420
+ / safe_platform_basename(invocation_id, default="invocation")
421
+ / DROPPED_DIR_NAME
422
+ / turn_directory_name(absolute_turn)
423
+ )
424
+
425
+
426
+ def build_record_filename(sequence: int, tool_name: str, record_id: str) -> str:
427
+ """Build ``<seq>_<bounded-tool>_<record-id>.md`` within 255 UTF-8 bytes."""
428
+ if sequence < 0:
429
+ raise ValueError("sequence must be non-negative")
430
+ if not _RECORD_ID_RE.fullmatch(record_id):
431
+ raise ValueError(f"record_id must be {RECORD_ID_HEX_CHARS} lowercase hexadecimal characters")
432
+ prefix = f"{sequence:03d}_"
433
+ suffix = f"_{record_id}{RECORD_FILE_SUFFIX}"
434
+ stem = bounded_safe_stem(
435
+ tool_name,
436
+ prefix=prefix,
437
+ suffix=suffix,
438
+ default="tool",
439
+ )
440
+ return f"{prefix}{stem}{suffix}"
441
+
442
+
443
+ _RECORD_ID_MAX_ATTEMPTS = 64
444
+
445
+
446
+ def _generate_record_id(quota: SpillQuota) -> str | None:
447
+ """Draw one short record ID that no live catalog record already uses.
448
+
449
+ Eight hex characters span a 32-bit space, so a fresh draw practically
450
+ always succeeds immediately; the attempt cap only guards against a broken
451
+ entropy source. Checking the quota's session-wide live map keeps the
452
+ truncated IDs collision-free across record directories — the same map
453
+ :meth:`SpillQuota.commit` enforces uniqueness against.
454
+ """
455
+ for _ in range(_RECORD_ID_MAX_ATTEMPTS):
456
+ record_id = uuid4().hex[:RECORD_ID_HEX_CHARS]
457
+ if not quota.has_catalog_record_id(record_id):
458
+ return record_id
459
+ return None
460
+
461
+
462
+ def write_spill_batch(
463
+ session_root: Path | None,
464
+ quota: SpillQuota | None,
465
+ groups: Sequence[ScopedGroup],
466
+ *,
467
+ record_dir: Path,
468
+ absolute_turn: int,
469
+ round_number: int,
470
+ session_id: str,
471
+ superseded_note: str | None = None,
472
+ ) -> SpillBatchResult:
473
+ """Write one ordered spill batch and flush catalog projections.
474
+
475
+ This synchronous API is designed to run under ``asyncio.to_thread``. A
476
+ missing session root/quota is a storage-unavailable policy outcome rather
477
+ than an I/O exception; actual filesystem failures set the durability signal.
478
+
479
+ ``superseded_note`` archives the LAST_WORDS note this round's merge is
480
+ replacing. Unlike group records it is strictly best-effort: its substance
481
+ already survives inside the merged replacement, so a failed write is
482
+ logged, produces no manifest entry, and never sets the durability signal.
483
+ """
484
+ if session_root is None or quota is None or not quota.storage_available:
485
+ entries = _policy_entries(
486
+ groups,
487
+ record_dir=record_dir,
488
+ absolute_turn=absolute_turn,
489
+ round_number=round_number,
490
+ reason="storage unavailable",
491
+ )
492
+ return SpillBatchResult(entries=tuple(entries))
493
+
494
+ entries: list[SpillManifestItem] = []
495
+ protected_record_ids: set[str] = set()
496
+ evicted_relative_paths: set[str] = set()
497
+ dirty_projection_dirs: set[Path] = set()
498
+ projection_dirty = False
499
+ round_bytes = 0
500
+ round_cap_reached = False
501
+ unexpected = False
502
+ root = session_root.resolve(strict=False)
503
+ with quota.catalog_lock:
504
+ for sequence, group in enumerate(groups, start=1):
505
+ if round_cap_reached:
506
+ entries.extend(
507
+ _group_manifest_items(
508
+ group,
509
+ record_dir=record_dir,
510
+ relative_path="",
511
+ record_id="",
512
+ absolute_turn=absolute_turn,
513
+ round_number=round_number,
514
+ sequence=sequence,
515
+ no_record_reason="round cap",
516
+ )
517
+ )
518
+ continue
519
+
520
+ created_at = datetime.now(UTC).isoformat()
521
+ record_id = _generate_record_id(quota)
522
+ if record_id is None:
523
+ logger.warning("Spill record ID space exhausted; skipping one record")
524
+ entries.extend(
525
+ _group_manifest_items(
526
+ group,
527
+ record_dir=record_dir,
528
+ relative_path="",
529
+ record_id="",
530
+ absolute_turn=absolute_turn,
531
+ round_number=round_number,
532
+ sequence=sequence,
533
+ no_record_reason="record ID exhaustion",
534
+ )
535
+ )
536
+ continue
537
+ tool = _group_tool_name(group)
538
+ payload = _render_record(
539
+ group,
540
+ record_id=record_id,
541
+ absolute_turn=absolute_turn,
542
+ round_number=round_number,
543
+ created_at=created_at,
544
+ session_id=session_id,
545
+ )
546
+ if round_bytes + len(payload) > PER_ROUND_CAP_BYTES:
547
+ round_cap_reached = True
548
+ entries.extend(
549
+ _group_manifest_items(
550
+ group,
551
+ record_dir=record_dir,
552
+ relative_path="",
553
+ record_id="",
554
+ absolute_turn=absolute_turn,
555
+ round_number=round_number,
556
+ sequence=sequence,
557
+ no_record_reason="round cap",
558
+ )
559
+ )
560
+ continue
561
+
562
+ try:
563
+ reserved = len(payload)
564
+ reservation_acquired = quota.try_reserve(reserved)
565
+ if not reservation_acquired and quota.storage_available:
566
+ evicted_count = len(evicted_relative_paths)
567
+ try:
568
+ _evict_until_reservable(
569
+ root,
570
+ quota,
571
+ reserved,
572
+ protected_record_ids=protected_record_ids,
573
+ evicted_relative_paths=evicted_relative_paths,
574
+ )
575
+ finally:
576
+ if len(evicted_relative_paths) > evicted_count:
577
+ projection_dirty = True
578
+ dirty_projection_dirs.update(Path(path).parent for path in evicted_relative_paths)
579
+ reservation_acquired = quota.try_reserve(reserved)
580
+ if not reservation_acquired:
581
+ entries.extend(
582
+ _group_manifest_items(
583
+ group,
584
+ record_dir=record_dir,
585
+ relative_path="",
586
+ record_id="",
587
+ absolute_turn=absolute_turn,
588
+ round_number=round_number,
589
+ sequence=sequence,
590
+ no_record_reason="quota",
591
+ )
592
+ )
593
+ continue
594
+ except OSError, RuntimeError:
595
+ logger.exception("Unexpected spill eviction/catalog failure")
596
+ unexpected = True
597
+ entries.extend(
598
+ _group_manifest_items(
599
+ group,
600
+ record_dir=record_dir,
601
+ relative_path="",
602
+ record_id="",
603
+ absolute_turn=absolute_turn,
604
+ round_number=round_number,
605
+ sequence=sequence,
606
+ no_record_reason="I/O failure",
607
+ )
608
+ )
609
+ continue
610
+
611
+ written_path: Path | None = None
612
+ try:
613
+ relative_path = record_dir / build_record_filename(sequence, tool, record_id)
614
+ full_path = root / relative_path
615
+ if not _record_path_within_root(root, relative_path):
616
+ raise OSError(f"Unsafe spill record path: {relative_path}")
617
+ full_path.parent.mkdir(parents=True, exist_ok=True)
618
+ written_path, record_id = _exclusive_write_with_retry(
619
+ full_path,
620
+ sequence=sequence,
621
+ tool=tool,
622
+ payload=payload,
623
+ original_record_id=record_id,
624
+ rerender=lambda new_id, spill_group=group, timestamp=created_at: _render_record(
625
+ spill_group,
626
+ record_id=new_id,
627
+ absolute_turn=absolute_turn,
628
+ round_number=round_number,
629
+ created_at=timestamp,
630
+ session_id=session_id,
631
+ ),
632
+ regenerate_record_id=lambda: _generate_record_id(quota),
633
+ )
634
+ if written_path != full_path:
635
+ relative_path = written_path.relative_to(root)
636
+ payload = written_path.read_bytes()
637
+ record = CatalogRecord(
638
+ record_id=record_id,
639
+ relative_path=relative_path.as_posix(),
640
+ turn=absolute_turn,
641
+ round=round_number,
642
+ tool=tool,
643
+ bytes=len(payload),
644
+ created_at=created_at,
645
+ )
646
+ _append_catalog_line(root, record.to_catalog_line())
647
+ except OSError, UnicodeError:
648
+ logger.exception("Unexpected spill record/catalog write failure")
649
+ unexpected = True
650
+ quota.release(reserved)
651
+ if written_path is not None:
652
+ try:
653
+ written_path.unlink(missing_ok=True)
654
+ except OSError:
655
+ logger.debug("Failed to remove un-cataloged spill record %s", written_path, exc_info=True)
656
+ entries.extend(
657
+ _group_manifest_items(
658
+ group,
659
+ record_dir=record_dir,
660
+ relative_path="",
661
+ record_id="",
662
+ absolute_turn=absolute_turn,
663
+ round_number=round_number,
664
+ sequence=sequence,
665
+ no_record_reason="I/O failure",
666
+ )
667
+ )
668
+ continue
669
+
670
+ quota.commit(
671
+ reserved,
672
+ len(payload),
673
+ relative_path=relative_path.as_posix(),
674
+ catalog_record=record,
675
+ )
676
+ protected_record_ids.add(record_id)
677
+ round_bytes += len(payload)
678
+ projection_dirty = True
679
+ dirty_projection_dirs.add(relative_path.parent)
680
+ entries.extend(
681
+ _group_manifest_items(
682
+ group,
683
+ record_dir=record_dir,
684
+ relative_path=relative_path.as_posix(),
685
+ record_id=record_id,
686
+ absolute_turn=absolute_turn,
687
+ round_number=round_number,
688
+ sequence=sequence,
689
+ )
690
+ )
691
+
692
+ if superseded_note:
693
+ note_entry = _write_note_record(
694
+ root,
695
+ quota,
696
+ superseded_note,
697
+ record_dir=record_dir,
698
+ sequence=len(groups) + 1,
699
+ absolute_turn=absolute_turn,
700
+ round_number=round_number,
701
+ session_id=session_id,
702
+ )
703
+ if note_entry is not None:
704
+ entries.append(note_entry)
705
+ projection_dirty = True
706
+ dirty_projection_dirs.add(Path(note_entry.relative_path).parent)
707
+
708
+ if projection_dirty:
709
+ try:
710
+ _rebuild_manifest_projections(
711
+ root,
712
+ live_records=quota.catalog_records_for_directories(dirty_projection_dirs),
713
+ relative_dirs=dirty_projection_dirs,
714
+ )
715
+ except OSError, RuntimeError:
716
+ # Records and authoritative catalog lines are already durable.
717
+ # Keep the usable stubs, but signal the unexpected projection
718
+ # failure so the strategy applies its recovery-sidecar policy.
719
+ logger.exception("Unexpected spill manifest projection failure")
720
+ unexpected = True
721
+ durable_directories = {root / CATALOG_RELATIVE_PATH.parent}
722
+ durable_directories.update((root / Path(path)).parent for path in evicted_relative_paths)
723
+ durable_directories.update(
724
+ (root / Path(entry.relative_path)).parent for entry in entries if entry.relative_path
725
+ )
726
+ _fsync_directory_chains(root, durable_directories)
727
+
728
+ return SpillBatchResult(
729
+ entries=tuple(entries),
730
+ unexpected_io_failure=unexpected,
731
+ evicted_relative_paths=frozenset(evicted_relative_paths),
732
+ )
733
+
734
+
735
+ def _write_note_record(
736
+ root: Path,
737
+ quota: SpillQuota,
738
+ note_text: str,
739
+ *,
740
+ record_dir: Path,
741
+ sequence: int,
742
+ absolute_turn: int,
743
+ round_number: int,
744
+ session_id: str,
745
+ ) -> SpillManifestItem | None:
746
+ """Archive one superseded LAST_WORDS note under the round's record dir.
747
+
748
+ Caller holds ``quota.catalog_lock``. Best-effort by contract: the note's
749
+ substance already lives on inside the merged replacement, so every failure
750
+ (ID exhaustion, quota pressure, I/O) is logged and swallowed without
751
+ setting the batch durability signal, and quota pressure never evicts older
752
+ records to make room for a note copy.
753
+ """
754
+ created_at = datetime.now(UTC).isoformat()
755
+ record_id = _generate_record_id(quota)
756
+ if record_id is None:
757
+ logger.warning("Spill record ID space exhausted; skipping LAST_WORDS note record")
758
+ return None
759
+ payload = _render_note_record(
760
+ note_text,
761
+ record_id=record_id,
762
+ absolute_turn=absolute_turn,
763
+ round_number=round_number,
764
+ created_at=created_at,
765
+ session_id=session_id,
766
+ )
767
+ reserved = len(payload)
768
+ if not quota.try_reserve(reserved):
769
+ logger.debug("Session spill quota exhausted; skipping LAST_WORDS note record")
770
+ return None
771
+ written_path: Path | None = None
772
+ try:
773
+ relative_path = record_dir / build_record_filename(sequence, NOTE_RECORD_TOOL_NAME, record_id)
774
+ full_path = root / relative_path
775
+ if not _record_path_within_root(root, relative_path):
776
+ raise OSError(f"Unsafe spill record path: {relative_path}")
777
+ full_path.parent.mkdir(parents=True, exist_ok=True)
778
+ written_path, record_id = _exclusive_write_with_retry(
779
+ full_path,
780
+ sequence=sequence,
781
+ tool=NOTE_RECORD_TOOL_NAME,
782
+ payload=payload,
783
+ original_record_id=record_id,
784
+ rerender=lambda new_id, timestamp=created_at: _render_note_record(
785
+ note_text,
786
+ record_id=new_id,
787
+ absolute_turn=absolute_turn,
788
+ round_number=round_number,
789
+ created_at=timestamp,
790
+ session_id=session_id,
791
+ ),
792
+ regenerate_record_id=lambda: _generate_record_id(quota),
793
+ )
794
+ if written_path != full_path:
795
+ relative_path = written_path.relative_to(root)
796
+ payload = written_path.read_bytes()
797
+ record = CatalogRecord(
798
+ record_id=record_id,
799
+ relative_path=relative_path.as_posix(),
800
+ turn=absolute_turn,
801
+ round=round_number,
802
+ tool=NOTE_RECORD_TOOL_NAME,
803
+ bytes=len(payload),
804
+ created_at=created_at,
805
+ kind=CATALOG_KIND_NOTE,
806
+ )
807
+ _append_catalog_line(root, record.to_catalog_line())
808
+ except OSError, UnicodeError:
809
+ logger.warning("Failed to archive the superseded LAST_WORDS note", exc_info=True)
810
+ quota.release(reserved)
811
+ if written_path is not None:
812
+ try:
813
+ written_path.unlink(missing_ok=True)
814
+ except OSError:
815
+ logger.debug("Failed to remove un-cataloged spill record %s", written_path, exc_info=True)
816
+ return None
817
+ quota.commit(reserved, len(payload), relative_path=relative_path.as_posix(), catalog_record=record)
818
+ return SpillManifestItem(
819
+ record_id=record_id,
820
+ # Synthetic marker: last_words_state.ManifestEntry.from_state requires a
821
+ # non-empty group_id, and an empty one would silently drop the entry
822
+ # from the rendered manifest.
823
+ group_id=NOTE_RECORD_GROUP_ID,
824
+ record_dir=record_dir.as_posix(),
825
+ relative_path=relative_path.as_posix(),
826
+ turn=absolute_turn,
827
+ round=round_number,
828
+ sequence=sequence,
829
+ tool=NOTE_RECORD_TOOL_NAME,
830
+ display_argument="superseded by this round's note",
831
+ outcome="merged",
832
+ size_chars=len(note_text),
833
+ )
834
+
835
+
836
+ def catalog_live_records(session_root: Path, quota: SpillQuota | None = None) -> list[CatalogRecord]:
837
+ """Return live records after applying append-only tombstones."""
838
+ root = session_root.resolve(strict=False)
839
+ if quota is None:
840
+ return [record for record in _read_live_catalog(root) if _record_path_within_root(root, record.relative_path)]
841
+ with quota.catalog_lock:
842
+ return [record for record in _read_live_catalog(root) if _record_path_within_root(root, record.relative_path)]
843
+
844
+
845
+ def catalog_live_record_count(session_root: Path, quota: SpillQuota | None = None) -> int:
846
+ """Return the number of live catalog records for a turn-start snapshot."""
847
+ return len(catalog_live_records(session_root, quota))
848
+
849
+
850
+ def owned_record_directory_relative_path(value: str | Path) -> bool:
851
+ """Return whether *value* is one of the two owned spill directory layouts."""
852
+ path = Path(value)
853
+ return not path.is_absolute() and ".." not in path.parts and _safe_record_directory_relative_path(path)
854
+
855
+
856
+ def owned_record_relative_path(value: str | Path) -> bool:
857
+ """Return whether *value* is an owned session-relative spill record path."""
858
+ path = Path(value)
859
+ return _safe_record_relative_path(path.as_posix())
860
+
861
+
862
+ def available_record_path(session_root: Path, relative_path: str | Path) -> bool:
863
+ """Return whether an owned record currently resolves to a regular session file."""
864
+ try:
865
+ root = session_root.resolve(strict=False)
866
+ except OSError, RuntimeError:
867
+ return False
868
+ if not _record_path_within_root(root, relative_path):
869
+ return False
870
+ try:
871
+ return (root / relative_path).is_file()
872
+ except OSError:
873
+ return False
874
+
875
+
876
+ def catalog_path_for_read(session_root: Path) -> Path | None:
877
+ """Return the readable catalog path only when every component is session-owned."""
878
+ try:
879
+ root = session_root.resolve(strict=False)
880
+ catalog = _validated_catalog_path(root)
881
+ resolved_catalog = catalog.resolve(strict=True)
882
+ if not resolved_catalog.is_file() or resolved_catalog != catalog:
883
+ return None
884
+ except OSError, RuntimeError:
885
+ return None
886
+ return catalog
887
+
888
+
889
+ def reconcile_spill_storage(session_root: Path, quota: SpillQuota) -> SpillReconciliationResult:
890
+ """Delete un-cataloged records, rebuild projections, and initialize quota."""
891
+ root = session_root.resolve(strict=False)
892
+ root.mkdir(parents=True, exist_ok=True)
893
+ with quota.catalog_lock:
894
+ try:
895
+ live = [
896
+ record for record in _read_live_catalog(root) if _record_path_within_root(root, record.relative_path)
897
+ ]
898
+ except _UnsafeCatalogPathError:
899
+ # An unsafe catalog cannot be the source of truth for orphan
900
+ # deletion. Preserve the on-disk records, expose none as
901
+ # available, and disable future spill writes so they cannot follow
902
+ # the link or consume unaccounted quota.
903
+ logger.warning("Ignoring unsafe spill catalog path under %s", root)
904
+ quota.disable_storage()
905
+ return SpillReconciliationResult(0, 0, frozenset())
906
+ live_paths = {record.relative_path for record in live}
907
+ deleted = 0
908
+ compactions = root / COMPACTIONS_DIR_NAME
909
+ if compactions.is_dir():
910
+ for dropped_dir in compactions.rglob(DROPPED_DIR_NAME):
911
+ try:
912
+ relative_dropped_dir = dropped_dir.relative_to(root)
913
+ except ValueError:
914
+ continue
915
+ if not _dropped_directory_within_root(root, relative_dropped_dir) or not dropped_dir.is_dir():
916
+ continue
917
+ for candidate in dropped_dir.rglob(f"*{RECORD_FILE_SUFFIX}"):
918
+ if candidate.name == MANIFEST_FILE_NAME:
919
+ continue
920
+ try:
921
+ relative = candidate.relative_to(root).as_posix()
922
+ except ValueError:
923
+ continue
924
+ if not _record_path_within_root(root, relative):
925
+ continue
926
+ if relative in live_paths:
927
+ continue
928
+ candidate.unlink(missing_ok=True)
929
+ deleted += 1
930
+
931
+ available: set[str] = set()
932
+ committed_bytes_by_path: dict[str, int] = {}
933
+ total = 0
934
+ for record in live:
935
+ path = root / record.relative_path
936
+ try:
937
+ if not path.is_file():
938
+ continue
939
+ size = path.stat().st_size
940
+ except FileNotFoundError:
941
+ continue
942
+ available.add(record.relative_path)
943
+ committed_bytes_by_path[record.relative_path] = size
944
+ total += size
945
+ quota.initialize(
946
+ total,
947
+ available,
948
+ committed_bytes_by_path=committed_bytes_by_path,
949
+ live_catalog_records=live,
950
+ )
951
+ try:
952
+ _rebuild_manifest_projections(root, live_records=live)
953
+ except OSError, RuntimeError:
954
+ # The append-only catalog and records are authoritative. A stale
955
+ # convenience projection must not prevent session hydration.
956
+ logger.warning("Unable to rebuild spill manifest projections under %s", root, exc_info=True)
957
+ return SpillReconciliationResult(
958
+ live_total_bytes=total,
959
+ live_record_count=len(live),
960
+ available_relative_paths=frozenset(available),
961
+ deleted_orphans=deleted,
962
+ )
963
+
964
+
965
+ def _exclusive_write_with_retry(
966
+ path: Path,
967
+ *,
968
+ sequence: int,
969
+ tool: str,
970
+ payload: bytes,
971
+ original_record_id: str,
972
+ rerender: Any,
973
+ regenerate_record_id: Any,
974
+ ) -> tuple[Path, str]:
975
+ record_id = original_record_id
976
+ candidate = path
977
+ candidate_payload = payload
978
+ os_module = cast(Any, os)
979
+ for attempt in range(2):
980
+ try:
981
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
982
+ if get_platform().is_windows:
983
+ flags |= os_module.O_BINARY
984
+ else:
985
+ # O_EXCL rejects existing final components; O_NOFOLLOW makes
986
+ # that intent explicit for platforms that support symlinks.
987
+ flags |= os_module.O_NOFOLLOW
988
+ descriptor = os.open(candidate, flags, 0o600)
989
+ except FileExistsError:
990
+ if attempt:
991
+ raise
992
+ new_id = regenerate_record_id()
993
+ if new_id is None:
994
+ raise
995
+ record_id = new_id
996
+ candidate = path.with_name(build_record_filename(sequence, tool, record_id))
997
+ candidate_payload = rerender(record_id)
998
+ continue
999
+ try:
1000
+ with os.fdopen(descriptor, "wb") as output:
1001
+ descriptor = -1
1002
+ output.write(candidate_payload)
1003
+ output.flush()
1004
+ os.fsync(output.fileno())
1005
+ except BaseException:
1006
+ if descriptor >= 0:
1007
+ os.close(descriptor)
1008
+ try:
1009
+ candidate.unlink(missing_ok=True)
1010
+ except OSError:
1011
+ logger.debug("Failed to remove partial spill record %s", candidate, exc_info=True)
1012
+ raise
1013
+ return candidate, record_id
1014
+ raise RuntimeError("exclusive spill record retry exhausted")
1015
+
1016
+
1017
+ def _render_record(
1018
+ group: ScopedGroup,
1019
+ *,
1020
+ record_id: str,
1021
+ absolute_turn: int,
1022
+ round_number: int,
1023
+ created_at: str,
1024
+ session_id: str,
1025
+ ) -> bytes:
1026
+ lines = [
1027
+ "# Dropped context record",
1028
+ "",
1029
+ f"- Record ID: `{record_id}`",
1030
+ f"- Group ID: `{group.group_id}`",
1031
+ f"- Turn: {absolute_turn}",
1032
+ f"- Round: {round_number}",
1033
+ f"- Timestamp: {created_at}",
1034
+ f"- Session ID: `{session_id}`",
1035
+ "",
1036
+ ]
1037
+ calls = _group_calls(group)
1038
+ assistant_context = _assistant_group_text(group)
1039
+ matched_result_ids: set[int] = set()
1040
+ if calls:
1041
+ if assistant_context:
1042
+ lines.extend(["## Assistant context", "", assistant_context, ""])
1043
+ results = _group_results(group)
1044
+ for call in calls:
1045
+ result = _matching_result(call, results)
1046
+ if result is not None:
1047
+ matched_result_ids.add(id(result))
1048
+ stamp = execution_stamp_from_metadata(result.additional_properties) if result is not None else None
1049
+ requested = _requested_call_arguments(call)
1050
+ effective = stamp.get("effective_args") if stamp is not None else None
1051
+ lines.extend([f"## Call: {_call_tool_name(call)}", ""])
1052
+ if stamp is not None and effective is not None and _effective_arguments_differ(requested, stamp):
1053
+ lines.extend(
1054
+ [
1055
+ "### Arguments (requested)",
1056
+ "```json",
1057
+ _pretty_arguments(requested),
1058
+ "```",
1059
+ "",
1060
+ "### Arguments (effective)",
1061
+ "```json",
1062
+ _pretty_arguments(effective),
1063
+ "```",
1064
+ "",
1065
+ ]
1066
+ )
1067
+ else:
1068
+ label = "### Arguments (as requested)" if stamp is None else "### Arguments (requested/effective)"
1069
+ lines.extend([label, "```json", _pretty_arguments(requested), "```", ""])
1070
+ lines.extend([f"Outcome: `{_structured_outcome(result, stamp)}`", "", "## Result", ""])
1071
+ lines.append(_result_record_text(result) if result is not None else "(missing result)")
1072
+ lines.append("")
1073
+ else:
1074
+ lines.extend(["## Assistant text", "", assistant_context, ""])
1075
+ supplemental = _supplemental_group_contents(group, matched_result_ids)
1076
+ if supplemental:
1077
+ lines.extend(["## Additional group content", ""])
1078
+ for content in supplemental:
1079
+ lines.extend([f"### {content.type}", "", _render_result_value(content) or "(empty)", ""])
1080
+ lines.append(_RECORD_TERMINATOR.rstrip("\n"))
1081
+ return _cap_record_bytes("\n".join(lines) + "\n")
1082
+
1083
+
1084
+ def _render_note_record(
1085
+ note_text: str,
1086
+ *,
1087
+ record_id: str,
1088
+ absolute_turn: int,
1089
+ round_number: int,
1090
+ created_at: str,
1091
+ session_id: str,
1092
+ ) -> bytes:
1093
+ lines = [
1094
+ "# Superseded LAST_WORDS note",
1095
+ "",
1096
+ f"- Record ID: `{record_id}`",
1097
+ f"- Turn: {absolute_turn}",
1098
+ f"- Round: {round_number}",
1099
+ f"- Timestamp: {created_at}",
1100
+ f"- Session ID: `{session_id}`",
1101
+ "",
1102
+ "This compaction round merged the note below into its replacement; this",
1103
+ "file preserves the pre-merge original verbatim.",
1104
+ "",
1105
+ "## Note",
1106
+ "",
1107
+ note_text,
1108
+ "",
1109
+ _RECORD_TERMINATOR.rstrip("\n"),
1110
+ ]
1111
+ return _cap_record_bytes("\n".join(lines) + "\n")
1112
+
1113
+
1114
+ def _cap_record_bytes(text: str) -> bytes:
1115
+ # Tool inputs/results can contain POSIX surrogate-escaped path bytes.
1116
+ # Preserve those as visible escape sequences so every ``.md`` record
1117
+ # remains strict UTF-8 and ``read_file`` never replaces archived detail.
1118
+ raw = text.encode("utf-8", errors="backslashreplace")
1119
+ if len(raw) <= PER_RECORD_CAP_BYTES:
1120
+ return raw
1121
+ terminator = _RECORD_TERMINATOR.encode()
1122
+ marker = _RECORD_TRUNCATION_TEMPLATE.format(size=len(raw)).encode("utf-8")
1123
+ body = raw.removesuffix(terminator)
1124
+ keep = max(0, PER_RECORD_CAP_BYTES - len(marker) - len(terminator))
1125
+ head_size = (keep + 1) // 2
1126
+ tail_size = keep - head_size
1127
+ head = body[:head_size].decode("utf-8", errors="ignore").encode("utf-8")
1128
+ tail = body[len(body) - tail_size :].decode("utf-8", errors="ignore").encode("utf-8") if tail_size else b""
1129
+ capped = head + marker + tail + terminator
1130
+ if len(capped) > PER_RECORD_CAP_BYTES:
1131
+ capped = capped[: PER_RECORD_CAP_BYTES - len(terminator)] + terminator
1132
+ return capped
1133
+
1134
+
1135
+ def _append_catalog_line(root: Path, value: Mapping[str, Any]) -> None:
1136
+ # Validate before mkdir so an existing symlinked parent cannot redirect
1137
+ # directory creation, then validate again immediately before opening.
1138
+ catalog = _validated_catalog_path(root)
1139
+ catalog.parent.mkdir(parents=True, exist_ok=True)
1140
+ catalog = _validated_catalog_path(root)
1141
+ serialized = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
1142
+ if get_platform().is_windows:
1143
+ with catalog.open("a+b") as output:
1144
+ _append_catalog_payload(output, serialized)
1145
+ return
1146
+
1147
+ # O_NOFOLLOW closes the final-component race left by the resolved-path
1148
+ # check on POSIX. Keep the platform-only flag inside the guarded body so
1149
+ # this module remains importable on Windows.
1150
+ posix_os = cast(Any, os)
1151
+ descriptor = posix_os.open(
1152
+ catalog,
1153
+ posix_os.O_APPEND | posix_os.O_CREAT | posix_os.O_RDWR | posix_os.O_NOFOLLOW,
1154
+ 0o600,
1155
+ )
1156
+ try:
1157
+ with os.fdopen(descriptor, "r+b") as output:
1158
+ descriptor = -1
1159
+ _append_catalog_payload(output, serialized)
1160
+ finally:
1161
+ if descriptor >= 0:
1162
+ os.close(descriptor)
1163
+
1164
+
1165
+ def _append_catalog_payload(output: BinaryIO, serialized: bytes) -> None:
1166
+ """Append one isolated JSON line after any crash-torn final fragment."""
1167
+ output.seek(0, os.SEEK_END)
1168
+ size = output.tell()
1169
+ separator = b""
1170
+ if size:
1171
+ output.seek(-1, os.SEEK_END)
1172
+ if output.read(1) != b"\n":
1173
+ separator = b"\n"
1174
+ output.seek(0, os.SEEK_END)
1175
+ output.write(separator + serialized + b"\n")
1176
+ output.flush()
1177
+ os.fsync(output.fileno())
1178
+
1179
+
1180
+ def _fsync_directory_chains(root: Path, directories: Iterable[Path]) -> None:
1181
+ """Persist newly created or removed entries from spill leaves through the session root."""
1182
+ pending: set[Path] = set()
1183
+ for directory in directories:
1184
+ current = directory
1185
+ while current.is_relative_to(root):
1186
+ pending.add(current)
1187
+ if current == root:
1188
+ break
1189
+ current = current.parent
1190
+ for directory in sorted(pending, key=lambda path: len(path.parts), reverse=True):
1191
+ fsync_directory(directory)
1192
+
1193
+
1194
+ def _read_live_catalog(root: Path) -> list[CatalogRecord]:
1195
+ catalog = _validated_catalog_path(root)
1196
+ try:
1197
+ if get_platform().is_windows:
1198
+ payload = catalog.read_bytes()
1199
+ else:
1200
+ posix_os = cast(Any, os)
1201
+ descriptor = posix_os.open(catalog, posix_os.O_RDONLY | posix_os.O_NOFOLLOW)
1202
+ try:
1203
+ with os.fdopen(descriptor, "rb") as input_file:
1204
+ descriptor = -1
1205
+ payload = input_file.read()
1206
+ finally:
1207
+ if descriptor >= 0:
1208
+ os.close(descriptor)
1209
+ except FileNotFoundError:
1210
+ return []
1211
+ lines = payload.splitlines()
1212
+ # ``live`` doubles as the replay order: dict insertion order matches the
1213
+ # catalog semantics exactly — the first add of an ID claims a position, a
1214
+ # tombstone erases it, and a later re-add (including a reused tombstoned
1215
+ # ID) appends at the end as the newest record. This keeps replay O(1) per
1216
+ # line where an explicit order list would make tombstones quadratic.
1217
+ live: dict[str, CatalogRecord] = {}
1218
+ record_id_by_path: dict[str, str] = {}
1219
+ for line_number, raw_line in enumerate(lines, start=1):
1220
+ try:
1221
+ line = raw_line.decode("utf-8")
1222
+ except UnicodeDecodeError:
1223
+ logger.debug("Ignoring invalid UTF-8 spill catalog line %d", line_number)
1224
+ continue
1225
+ try:
1226
+ value = json.loads(line)
1227
+ except TypeError, ValueError:
1228
+ logger.debug("Ignoring malformed spill catalog line %d", line_number)
1229
+ continue
1230
+ if not isinstance(value, Mapping):
1231
+ continue
1232
+ record_id = value.get("record_id")
1233
+ if not isinstance(record_id, str) or not record_id:
1234
+ continue
1235
+ if value.get("tombstone") is True:
1236
+ live.pop(record_id, None)
1237
+ continue
1238
+ if record_id in live:
1239
+ logger.debug(
1240
+ "Ignoring duplicate spill catalog record ID %s on line %d",
1241
+ record_id,
1242
+ line_number,
1243
+ )
1244
+ continue
1245
+ record = _catalog_record_from_mapping(value)
1246
+ if record is None:
1247
+ continue
1248
+ path_owner = record_id_by_path.get(record.relative_path)
1249
+ if path_owner is not None and path_owner != record_id:
1250
+ logger.debug(
1251
+ "Ignoring duplicate spill catalog path on line %d (owned by record %s)",
1252
+ line_number,
1253
+ path_owner,
1254
+ )
1255
+ continue
1256
+ record_id_by_path[record.relative_path] = record_id
1257
+ live[record_id] = record
1258
+ return list(live.values())
1259
+
1260
+
1261
+ def _validated_catalog_path(root: Path) -> Path:
1262
+ """Return the fixed catalog path only when every component is session-owned."""
1263
+ catalog = root / CATALOG_RELATIVE_PATH
1264
+ try:
1265
+ has_link_component = _path_has_link_component(root, CATALOG_RELATIVE_PATH)
1266
+ parent = catalog.parent.resolve(strict=False)
1267
+ resolved = catalog.resolve(strict=False)
1268
+ except (OSError, RuntimeError) as exc:
1269
+ raise _UnsafeCatalogPathError(f"Cannot validate spill catalog path: {catalog}") from exc
1270
+ if has_link_component or not parent.is_relative_to(root) or not resolved.is_relative_to(root):
1271
+ raise _UnsafeCatalogPathError(f"Unsafe spill catalog path: {catalog}")
1272
+ return catalog
1273
+
1274
+
1275
+ def _catalog_record_from_mapping(value: Mapping[str, Any]) -> CatalogRecord | None:
1276
+ record_id = value.get("record_id")
1277
+ relative_path = value.get("relative_path")
1278
+ turn = value.get("turn")
1279
+ round_number = value.get("round")
1280
+ tool = value.get("tool")
1281
+ byte_count = value.get("bytes")
1282
+ created_at = value.get("created_at")
1283
+ if (
1284
+ not isinstance(record_id, str)
1285
+ or not record_id
1286
+ or not isinstance(relative_path, str)
1287
+ or not is_utf8_encodable(relative_path)
1288
+ or not _safe_record_relative_path(relative_path)
1289
+ or not isinstance(turn, int)
1290
+ or turn < 0
1291
+ or not isinstance(round_number, int)
1292
+ or round_number < 0
1293
+ or not isinstance(tool, str)
1294
+ or not is_utf8_encodable(record_id)
1295
+ or not is_utf8_encodable(tool)
1296
+ or not isinstance(byte_count, int)
1297
+ or byte_count < 0
1298
+ or not isinstance(created_at, str)
1299
+ or not is_utf8_encodable(created_at)
1300
+ ):
1301
+ return None
1302
+ # Tolerant by design: catalogs written before the field existed (and any
1303
+ # hand-edited line) fall back to the group kind rather than being dropped.
1304
+ kind = value.get("kind", CATALOG_KIND_GROUP)
1305
+ if not isinstance(kind, str) or not kind or not is_utf8_encodable(kind):
1306
+ kind = CATALOG_KIND_GROUP
1307
+ return CatalogRecord(record_id, relative_path, turn, round_number, tool, byte_count, created_at, kind)
1308
+
1309
+
1310
+ def _safe_record_relative_path(value: str) -> bool:
1311
+ """Return whether a catalog path has one of the two spill-record layouts."""
1312
+ path = Path(value)
1313
+ if not value or path.is_absolute() or ".." in path.parts or path.name == MANIFEST_FILE_NAME:
1314
+ return False
1315
+ return _safe_record_directory_relative_path(path.parent) and path.suffix == RECORD_FILE_SUFFIX
1316
+
1317
+
1318
+ def _safe_record_directory_relative_path(path: Path) -> bool:
1319
+ """Return whether *path* is an owned main/sub-agent turn directory."""
1320
+ parts = path.parts
1321
+ main_layout = (
1322
+ len(parts) == 3
1323
+ and parts[:2] == (COMPACTIONS_DIR_NAME, DROPPED_DIR_NAME)
1324
+ and _TURN_DIRECTORY_RE.fullmatch(parts[2]) is not None
1325
+ )
1326
+ sub_agent_layout = (
1327
+ len(parts) == 6
1328
+ and parts[0] == COMPACTIONS_DIR_NAME
1329
+ and parts[1] == SUB_AGENTS_DIR_NAME
1330
+ and bool(parts[2])
1331
+ and bool(parts[3])
1332
+ and parts[4] == DROPPED_DIR_NAME
1333
+ and _TURN_DIRECTORY_RE.fullmatch(parts[5]) is not None
1334
+ )
1335
+ return main_layout or sub_agent_layout
1336
+
1337
+
1338
+ def _safe_dropped_directory_relative_path(path: Path) -> bool:
1339
+ """Return whether *path* is an owned main/sub-agent dropped-record root."""
1340
+ parts = path.parts
1341
+ main_layout = parts == (COMPACTIONS_DIR_NAME, DROPPED_DIR_NAME)
1342
+ sub_agent_layout = (
1343
+ len(parts) == 5
1344
+ and parts[0] == COMPACTIONS_DIR_NAME
1345
+ and parts[1] == SUB_AGENTS_DIR_NAME
1346
+ and bool(parts[2])
1347
+ and bool(parts[3])
1348
+ and parts[4] == DROPPED_DIR_NAME
1349
+ )
1350
+ return main_layout or sub_agent_layout
1351
+
1352
+
1353
+ def _record_path_within_root(root: Path, relative_path: str | Path) -> bool:
1354
+ """Reject record targets redirected away from their owned lexical layout."""
1355
+ relative = Path(relative_path)
1356
+ value = relative.as_posix()
1357
+ if not _safe_record_relative_path(value):
1358
+ return False
1359
+ try:
1360
+ if _path_has_link_component(root, relative):
1361
+ return False
1362
+ resolved_relative = (root / relative).resolve(strict=False).relative_to(root)
1363
+ except OSError, RuntimeError, ValueError:
1364
+ return False
1365
+ return _safe_record_relative_path(resolved_relative.as_posix())
1366
+
1367
+
1368
+ def _dropped_directory_within_root(root: Path, relative_dir: str | Path) -> bool:
1369
+ """Reject dropped-record roots redirected away from their owned lexical layout."""
1370
+ relative = Path(relative_dir)
1371
+ if not _safe_dropped_directory_relative_path(relative):
1372
+ return False
1373
+ try:
1374
+ if _path_has_link_component(root, relative):
1375
+ return False
1376
+ resolved_relative = (root / relative).resolve(strict=False).relative_to(root)
1377
+ except OSError, RuntimeError, ValueError:
1378
+ return False
1379
+ return _safe_dropped_directory_relative_path(resolved_relative)
1380
+
1381
+
1382
+ def _record_directory_within_root(root: Path, relative_dir: str | Path) -> bool:
1383
+ """Reject spill directories redirected away from their owned lexical layout."""
1384
+ relative = Path(relative_dir)
1385
+ if not owned_record_directory_relative_path(relative):
1386
+ return False
1387
+ try:
1388
+ if _path_has_link_component(root, relative):
1389
+ return False
1390
+ resolved_relative = (root / relative).resolve(strict=False).relative_to(root)
1391
+ except OSError, RuntimeError, ValueError:
1392
+ return False
1393
+ return _safe_record_directory_relative_path(resolved_relative)
1394
+
1395
+
1396
+ def _path_has_link_component(root: Path, relative_path: Path) -> bool:
1397
+ """Return whether an existing relative component is a symlink or junction."""
1398
+ current = root
1399
+ for part in relative_path.parts:
1400
+ current /= part
1401
+ if current.is_symlink() or current.is_junction():
1402
+ return True
1403
+ return False
1404
+
1405
+
1406
+ def _evict_until_reservable(
1407
+ root: Path,
1408
+ quota: SpillQuota,
1409
+ required: int,
1410
+ *,
1411
+ protected_record_ids: set[str],
1412
+ evicted_relative_paths: set[str],
1413
+ ) -> None:
1414
+ for record in quota.live_catalog_records():
1415
+ if record.record_id in protected_record_ids:
1416
+ continue
1417
+ path_is_safe = _record_path_within_root(root, record.relative_path)
1418
+ path = root / record.relative_path
1419
+ _append_catalog_line(
1420
+ root,
1421
+ {
1422
+ "record_id": record.record_id,
1423
+ "tombstone": True,
1424
+ "created_at": datetime.now(UTC).isoformat(),
1425
+ },
1426
+ )
1427
+ try:
1428
+ if path_is_safe:
1429
+ path.unlink(missing_ok=True)
1430
+ except OSError:
1431
+ # The tombstone is already authoritative. Restore the live catalog
1432
+ # entry when physical deletion failed so a later attempt can retry
1433
+ # without hiding the only record copy.
1434
+ try:
1435
+ _append_catalog_line(root, record.to_catalog_line())
1436
+ except OSError:
1437
+ logger.exception("Failed to restore spill catalog entry after eviction unlink failure")
1438
+ raise
1439
+ evicted_relative_paths.add(record.relative_path)
1440
+ quota.reclaim_record(record.relative_path)
1441
+ if quota.try_reserve(required):
1442
+ quota.release(required)
1443
+ return
1444
+
1445
+
1446
+ def _rebuild_manifest_projections(
1447
+ root: Path,
1448
+ *,
1449
+ live_records: Sequence[CatalogRecord] | None = None,
1450
+ relative_dirs: Iterable[Path] | None = None,
1451
+ ) -> None:
1452
+ source = _read_live_catalog(root) if live_records is None else live_records
1453
+ live = [record for record in source if _record_path_within_root(root, record.relative_path)]
1454
+ grouped: dict[Path, list[CatalogRecord]] = {}
1455
+ for record in live:
1456
+ grouped.setdefault(Path(record.relative_path).parent, []).append(record)
1457
+
1458
+ if relative_dirs is None:
1459
+ target_dirs = set(grouped)
1460
+ compactions = root / COMPACTIONS_DIR_NAME
1461
+ if compactions.is_dir():
1462
+ for path in compactions.rglob(MANIFEST_FILE_NAME):
1463
+ try:
1464
+ relative_dir = path.parent.relative_to(root)
1465
+ except ValueError:
1466
+ continue
1467
+ if _record_directory_within_root(root, relative_dir):
1468
+ target_dirs.add(relative_dir)
1469
+ else:
1470
+ target_dirs = {
1471
+ Path(relative_dir)
1472
+ for relative_dir in relative_dirs
1473
+ if _record_directory_within_root(root, Path(relative_dir))
1474
+ }
1475
+
1476
+ for relative_dir in sorted(target_dirs, key=lambda path: path.as_posix()):
1477
+ records = grouped.get(relative_dir, [])
1478
+ directory = root / relative_dir
1479
+ directory.mkdir(parents=True, exist_ok=True)
1480
+ if not records:
1481
+ atomic_write_text(directory / MANIFEST_FILE_NAME, "# Dropped context records\n\n(No live records.)\n")
1482
+ continue
1483
+ lines = ["# Dropped context records", ""]
1484
+ for record in records:
1485
+ filename = Path(record.relative_path).name
1486
+ lines.append(
1487
+ f"- r{record.round} `{record.tool}` — [{filename}]({filename}) — "
1488
+ f"{record.bytes} bytes — {record.created_at}"
1489
+ )
1490
+ lines.append("")
1491
+ atomic_write_text(directory / MANIFEST_FILE_NAME, "\n".join(lines))
1492
+
1493
+
1494
+ def _policy_entries(
1495
+ groups: Sequence[ScopedGroup],
1496
+ *,
1497
+ record_dir: Path,
1498
+ absolute_turn: int,
1499
+ round_number: int,
1500
+ reason: str,
1501
+ ) -> list[SpillManifestItem]:
1502
+ entries: list[SpillManifestItem] = []
1503
+ for sequence, group in enumerate(groups, start=1):
1504
+ entries.extend(
1505
+ _group_manifest_items(
1506
+ group,
1507
+ record_dir=record_dir,
1508
+ relative_path="",
1509
+ record_id="",
1510
+ absolute_turn=absolute_turn,
1511
+ round_number=round_number,
1512
+ sequence=sequence,
1513
+ no_record_reason=reason,
1514
+ )
1515
+ )
1516
+ return entries
1517
+
1518
+
1519
+ def _group_manifest_items(
1520
+ group: ScopedGroup,
1521
+ *,
1522
+ record_dir: Path,
1523
+ relative_path: str,
1524
+ record_id: str,
1525
+ absolute_turn: int,
1526
+ round_number: int,
1527
+ sequence: int,
1528
+ no_record_reason: str = "",
1529
+ ) -> list[SpillManifestItem]:
1530
+ calls = _group_calls(group)
1531
+ if not calls:
1532
+ text = _assistant_group_text(group)
1533
+ return [
1534
+ SpillManifestItem(
1535
+ record_id=record_id,
1536
+ group_id=group.group_id,
1537
+ record_dir=record_dir.as_posix(),
1538
+ relative_path=relative_path,
1539
+ turn=absolute_turn,
1540
+ round=round_number,
1541
+ sequence=sequence,
1542
+ tool="assistant",
1543
+ display_argument="",
1544
+ outcome="",
1545
+ size_chars=len(text),
1546
+ assistant_text=True,
1547
+ available=not no_record_reason,
1548
+ no_record_reason=no_record_reason,
1549
+ )
1550
+ ]
1551
+ results = _group_results(group)
1552
+ items: list[SpillManifestItem] = []
1553
+ for call in calls:
1554
+ result = _matching_result(call, results)
1555
+ stamp = execution_stamp_from_metadata(result.additional_properties) if result is not None else None
1556
+ items.append(
1557
+ SpillManifestItem(
1558
+ record_id=record_id,
1559
+ group_id=group.group_id,
1560
+ record_dir=record_dir.as_posix(),
1561
+ relative_path=relative_path,
1562
+ turn=absolute_turn,
1563
+ round=round_number,
1564
+ sequence=sequence,
1565
+ tool=_call_tool_name(call),
1566
+ display_argument=_display_argument(call, stamp),
1567
+ outcome=_structured_outcome(result, stamp),
1568
+ size_chars=len(_result_record_text(result)) if result is not None else 0,
1569
+ available=not no_record_reason,
1570
+ no_record_reason=no_record_reason,
1571
+ )
1572
+ )
1573
+ return items
1574
+
1575
+
1576
+ def _group_calls(group: ScopedGroup) -> list[Content]:
1577
+ return [
1578
+ content for message in group.messages for content in message.contents if content.type in TOOL_CALL_CONTENT_TYPES
1579
+ ]
1580
+
1581
+
1582
+ def _group_results(group: ScopedGroup) -> list[Content]:
1583
+ return [content for message in group.messages for content in message.contents if content.type.endswith("_result")]
1584
+
1585
+
1586
+ def _matching_result(call: Content, results: Sequence[Content]) -> Content | None:
1587
+ if call.call_id:
1588
+ return next((result for result in results if result.call_id == call.call_id), None)
1589
+ if call.type == "image_generation_tool_call" and call.image_id:
1590
+ return next(
1591
+ (
1592
+ result
1593
+ for result in results
1594
+ if result.type == "image_generation_tool_result" and result.image_id == call.image_id
1595
+ ),
1596
+ None,
1597
+ )
1598
+ return None
1599
+
1600
+
1601
+ def _group_tool_name(group: ScopedGroup) -> str:
1602
+ calls = _group_calls(group)
1603
+ return _call_tool_name(calls[0]) if calls else "assistant"
1604
+
1605
+
1606
+ def _call_tool_name(call: Content) -> str:
1607
+ if call.type == "function_call":
1608
+ value = call.name or "tool"
1609
+ else:
1610
+ value = call.tool_name or call.type.removesuffix("_tool_call") or "tool"
1611
+ return _strict_utf8_text(value)
1612
+
1613
+
1614
+ def _strict_utf8_text(value: str) -> str:
1615
+ """Make provider-controlled labels safe for paths and strict UTF-8 state."""
1616
+ return value.encode("utf-8", errors="backslashreplace").decode("utf-8")
1617
+
1618
+
1619
+ def _requested_call_arguments(call: Content) -> str:
1620
+ """Return the complete provider-visible input for every supported call shape."""
1621
+ if call.type in {"function_call", "hosted_tool_call", "mcp_server_tool_call", "search_tool_call"}:
1622
+ return _canonical_arguments(call.arguments)
1623
+ if call.type == "code_interpreter_tool_call":
1624
+ return _canonical_arguments({"inputs": _record_json_value(call.inputs or [])})
1625
+ if call.type == "image_generation_tool_call":
1626
+ return _canonical_arguments({"image_id": call.image_id})
1627
+ if call.type == "shell_tool_call":
1628
+ return _canonical_arguments(
1629
+ {
1630
+ "commands": call.commands or [],
1631
+ "max_output_length": call.max_output_length,
1632
+ "status": call.status,
1633
+ "timeout_ms": call.timeout_ms,
1634
+ }
1635
+ )
1636
+ return _canonical_arguments(_record_json_value(call.to_dict()))
1637
+
1638
+
1639
+ def _record_json_value(value: Any) -> Any:
1640
+ """Convert nested call input to JSON state without embedding raw images."""
1641
+ if isinstance(value, Content):
1642
+ if is_image_content(value):
1643
+ return _image_placeholder(value)
1644
+ return _record_json_value(value.to_dict())
1645
+ if isinstance(value, Mapping):
1646
+ return {str(key): _record_json_value(item) for key, item in value.items()}
1647
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
1648
+ return [_record_json_value(item) for item in value]
1649
+ return value
1650
+
1651
+
1652
+ def _canonical_arguments(arguments: Any) -> str:
1653
+ if isinstance(arguments, str):
1654
+ try:
1655
+ parsed = json.loads(arguments)
1656
+ except ValueError:
1657
+ return arguments
1658
+ return json.dumps(parsed, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
1659
+ try:
1660
+ return canonicalize_effective_arguments(arguments if arguments is not None else {})
1661
+ except Exception:
1662
+ return json.dumps(arguments, ensure_ascii=False, sort_keys=True, default=str)
1663
+
1664
+
1665
+ def _pretty_arguments(serialized: str) -> str:
1666
+ try:
1667
+ parsed = json.loads(serialized)
1668
+ except ValueError:
1669
+ return serialized
1670
+ return json.dumps(parsed, ensure_ascii=False, indent=2, sort_keys=True, default=str)
1671
+
1672
+
1673
+ def _effective_arguments_differ(requested: str, stamp: Mapping[str, str]) -> bool:
1674
+ digest = hashlib.sha256(requested.encode("utf-8", errors="surrogatepass")).hexdigest()[:16]
1675
+ return digest != stamp.get("effective_args_digest")
1676
+
1677
+
1678
+ def _structured_outcome(result: Content | None, stamp: Mapping[str, str] | None) -> str:
1679
+ if stamp is not None:
1680
+ if stamp.get("outcome") == "ok":
1681
+ return "ok"
1682
+ kind = stamp.get("error_kind")
1683
+ return f"error({kind})" if kind else "error(unknown)"
1684
+ if result is not None:
1685
+ kind = result.additional_properties.get(TOOL_ERROR_KIND_METADATA_KEY)
1686
+ if isinstance(kind, str) and kind:
1687
+ return f"error({kind})"
1688
+ shell_outcome = _shell_result_outcome(result)
1689
+ if shell_outcome is not None:
1690
+ return shell_outcome
1691
+ return "unknown"
1692
+
1693
+
1694
+ def _shell_result_outcome(result: Content) -> str | None:
1695
+ if result.type != "shell_tool_result" or not result.outputs:
1696
+ return None
1697
+ outputs = [output for output in result.outputs if output.type == "shell_command_output"]
1698
+ if not outputs:
1699
+ return None
1700
+ if any(output.timed_out is True for output in outputs):
1701
+ return "error(timeout)"
1702
+ if any(output.exit_code is not None and output.exit_code != 0 for output in outputs):
1703
+ return "error(exit_code)"
1704
+ if all(output.exit_code == 0 for output in outputs):
1705
+ return "ok"
1706
+ return None
1707
+
1708
+
1709
+ def _result_record_text(result: Content | None) -> str:
1710
+ if result is None:
1711
+ return ""
1712
+ if result.type in {"function_result", "hosted_tool_result", "search_tool_result"}:
1713
+ value = result.items or result.result
1714
+ elif result.type == "mcp_server_tool_result":
1715
+ value = result.output
1716
+ else:
1717
+ value = result.outputs
1718
+ return _render_result_value(value)
1719
+
1720
+
1721
+ def _render_result_value(value: Any) -> str:
1722
+ if isinstance(value, Content):
1723
+ if is_image_content(value):
1724
+ return _image_placeholder(value)
1725
+ if value.type == "text":
1726
+ return value.text or ""
1727
+ if value.type == "shell_command_output":
1728
+ return _render_shell_command_output(value)
1729
+ return json.dumps(value.to_dict(), ensure_ascii=False, indent=2, default=str)
1730
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
1731
+ return "\n".join(part for item in value if (part := _render_result_value(item)))
1732
+ if isinstance(value, Mapping | list):
1733
+ return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True, default=str)
1734
+ return "" if value is None else str(value)
1735
+
1736
+
1737
+ def _render_shell_command_output(output: Content) -> str:
1738
+ blocks: list[str] = []
1739
+ if output.stdout is not None:
1740
+ blocks.append(f"[stdout]\n{output.stdout}")
1741
+ if output.stderr is not None:
1742
+ blocks.append(f"[stderr]\n{output.stderr}")
1743
+ if output.exit_code is not None:
1744
+ blocks.append(f"[exit_code]\n{output.exit_code}")
1745
+ if output.timed_out is not None:
1746
+ blocks.append(f"[timed_out]\n{str(output.timed_out).lower()}")
1747
+ return "\n\n".join(blocks)
1748
+
1749
+
1750
+ def _image_placeholder(content: Content) -> str:
1751
+ uri = content.uri or ""
1752
+ media_type = content.media_type or content.additional_properties.get("media_type") or "image"
1753
+ byte_length, identity = hashable_image_identity(uri)
1754
+ if uri.startswith("data:"):
1755
+ encoded = uri.split(",", 1)[1] if "," in uri else uri
1756
+ try:
1757
+ payload = base64.b64decode(encoded, validate=False)
1758
+ except ValueError, TypeError:
1759
+ payload = identity.encode("ascii", errors="ignore")
1760
+ digest = hashlib.sha256(payload).hexdigest()[:16]
1761
+ else:
1762
+ digest = hashlib.sha256(identity.encode("utf-8", errors="surrogatepass")).hexdigest()[:16]
1763
+ size = str(byte_length) if byte_length is not None else "unknown bytes"
1764
+ return f"[image: {media_type}, {size}, sha256:{digest}]"
1765
+
1766
+
1767
+ def _assistant_group_text(group: ScopedGroup) -> str:
1768
+ blocks: list[str] = []
1769
+ for message in group.messages:
1770
+ for content in message.contents:
1771
+ if content.type == "text" and content.text:
1772
+ blocks.append(content.text)
1773
+ elif content.type == "text_reasoning":
1774
+ if content.text:
1775
+ blocks.append(f"[reasoning]\n{content.text}")
1776
+ if content.protected_data:
1777
+ blocks.append(f"[protected reasoning data]\n{content.protected_data}")
1778
+ return "\n".join(blocks)
1779
+
1780
+
1781
+ def _supplemental_group_contents(group: ScopedGroup, matched_result_ids: set[int]) -> list[Content]:
1782
+ return [
1783
+ content
1784
+ for message in group.messages
1785
+ for content in message.contents
1786
+ if content.type not in TOOL_CALL_CONTENT_TYPES
1787
+ and content.type not in {"text", "text_reasoning"}
1788
+ and id(content) not in matched_result_ids
1789
+ ]
1790
+
1791
+
1792
+ _DISPLAY_ARG_KEYS_BY_TOOL: dict[str, tuple[str, ...]] = {
1793
+ "read_file": ("path", "file_path"),
1794
+ "view_image": ("path", "file_path"),
1795
+ "write_file": ("path", "file_path"),
1796
+ "edit_file": ("path", "file_path"),
1797
+ "grep": ("pattern", "path"),
1798
+ "glob": ("pattern", "path"),
1799
+ "execute": ("command",),
1800
+ "run_command": ("command",),
1801
+ "run_shell": ("command",),
1802
+ "bash": ("command",),
1803
+ "cmd": ("command",),
1804
+ "csh": ("command",),
1805
+ "dash": ("command",),
1806
+ "fish": ("command",),
1807
+ "git_bash": ("command",),
1808
+ "ksh": ("command",),
1809
+ "powershell": ("command",),
1810
+ "pwsh": ("command",),
1811
+ "sh": ("command",),
1812
+ "tcsh": ("command",),
1813
+ "zsh": ("command",),
1814
+ "shell": ("commands",),
1815
+ "code_interpreter": ("inputs",),
1816
+ "image_generation": ("image_id",),
1817
+ "web_search": ("query",),
1818
+ "search": ("query",),
1819
+ "fetch": ("url",),
1820
+ "open_url": ("url",),
1821
+ "compress_context": ("marker_id",),
1822
+ "recall_context": ("name", "marker_id"),
1823
+ "load_skill": ("name",),
1824
+ "read_skill_resource": ("name", "path"),
1825
+ "run_skill_script": ("name", "path"),
1826
+ }
1827
+ # Shell-family tools also surface the model-stated intent after the command;
1828
+ # derived, so a newly registered shell name picks it up automatically.
1829
+ _EXTRA_DISPLAY_ARG_KEYS_BY_TOOL: dict[str, tuple[str, ...]] = {
1830
+ tool: ("reason",) for tool, keys in _DISPLAY_ARG_KEYS_BY_TOOL.items() if keys == ("command",)
1831
+ }
1832
+ # Catalog-line argument preview caps, sized to keep whole commands, paths, and
1833
+ # shell intents visible; the LAST_WORDS manifest's overall char/line budget
1834
+ # evicts oldest entries when a long listing overflows, so a longer preview
1835
+ # trades tail entries, not correctness. ``last_words_state`` derives its
1836
+ # stored-field bound from them.
1837
+ DISPLAY_ARGUMENT_DEFAULT_MAX_CHARS = 120
1838
+ # Paths middle-truncate (``D:\Repos\…\test.py``) so the filename tail stays.
1839
+ DISPLAY_ARGUMENT_PATH_MAX_CHARS = 256
1840
+ # Shell tools append the model-stated ``reason="…"`` after the command.
1841
+ DISPLAY_ARGUMENT_REASON_MAX_CHARS = 80
1842
+ _DISPLAY_ARGUMENT_KEY_CAPS = {
1843
+ "path": DISPLAY_ARGUMENT_PATH_MAX_CHARS,
1844
+ "file_path": DISPLAY_ARGUMENT_PATH_MAX_CHARS,
1845
+ "reason": DISPLAY_ARGUMENT_REASON_MAX_CHARS,
1846
+ }
1847
+ _MIDDLE_TRUNCATED_DISPLAY_KEYS = frozenset({"path", "file_path"})
1848
+ _SENSITIVE_KEY_RE = re.compile(r"(?:token|password|passwd|secret|api[_-]?key|authorization|credential)", re.IGNORECASE)
1849
+ _SENSITIVE_VALUE_RE = re.compile(
1850
+ r"(?:bearer\s+[A-Za-z0-9._~+/=-]+|(?:sk|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{12,}|"
1851
+ r"eyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}\.)",
1852
+ re.IGNORECASE,
1853
+ )
1854
+ _SENSITIVE_URL_PARAMETER_NAMES = frozenset(
1855
+ {
1856
+ "accesskey",
1857
+ "accesstoken",
1858
+ "apikey",
1859
+ "auth",
1860
+ "authorization",
1861
+ "clientsecret",
1862
+ "credential",
1863
+ "credentials",
1864
+ "key",
1865
+ "passwd",
1866
+ "password",
1867
+ "privatekey",
1868
+ "secret",
1869
+ "sig",
1870
+ "signature",
1871
+ "token",
1872
+ }
1873
+ )
1874
+
1875
+
1876
+ def _display_argument(call: Content, stamp: Mapping[str, str] | None) -> str:
1877
+ serialized = stamp.get("effective_args") if stamp is not None else _requested_call_arguments(call)
1878
+ if serialized is None:
1879
+ return ""
1880
+ try:
1881
+ arguments = json.loads(serialized)
1882
+ except TypeError, ValueError:
1883
+ return ""
1884
+ if not isinstance(arguments, Mapping):
1885
+ return ""
1886
+ tool_name = _call_tool_name(call)
1887
+ segments: list[str] = []
1888
+ for key in _DISPLAY_ARG_KEYS_BY_TOOL.get(tool_name, ()):
1889
+ segment = _display_argument_segment(arguments, key)
1890
+ if segment is not None:
1891
+ segments.append(segment)
1892
+ break
1893
+ for key in _EXTRA_DISPLAY_ARG_KEYS_BY_TOOL.get(tool_name, ()):
1894
+ segment = _display_argument_segment(arguments, key)
1895
+ if segment is not None:
1896
+ segments.append(segment)
1897
+ return ", ".join(segments)
1898
+
1899
+
1900
+ def _display_argument_segment(arguments: Mapping[str, Any], key: str) -> str | None:
1901
+ """Render one ``key=value`` preview segment; None when absent or filtered."""
1902
+ if key not in arguments or _SENSITIVE_KEY_RE.search(key):
1903
+ return None
1904
+ value = arguments[key]
1905
+ if key == "url" and _url_contains_credentials(value):
1906
+ return None
1907
+ encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str).replace("\n", "\\n")
1908
+ if _SENSITIVE_KEY_RE.search(encoded) or _SENSITIVE_VALUE_RE.search(encoded) or _mapping_has_sensitive_key(value):
1909
+ return None
1910
+ rendered = f"{key}={encoded}"
1911
+ cap = _DISPLAY_ARGUMENT_KEY_CAPS.get(key, DISPLAY_ARGUMENT_DEFAULT_MAX_CHARS)
1912
+ if len(rendered) <= cap:
1913
+ return rendered
1914
+ if key in _MIDDLE_TRUNCATED_DISPLAY_KEYS:
1915
+ # The filename tail is the informative end of a long path — drop the
1916
+ # middle, never the tail.
1917
+ head = (cap - 1) // 2
1918
+ return rendered[:head] + "…" + rendered[len(rendered) - (cap - 1 - head) :]
1919
+ return rendered[: cap - 1] + "…"
1920
+
1921
+
1922
+ def _url_contains_credentials(value: Any) -> bool:
1923
+ """Return whether a display URL contains userinfo or secret-bearing parameters."""
1924
+ if not isinstance(value, str):
1925
+ return False
1926
+ try:
1927
+ parsed = urlsplit(value)
1928
+ except ValueError:
1929
+ return True
1930
+ if parsed.username is not None or parsed.password is not None:
1931
+ return True
1932
+ for component in (parsed.query, parsed.fragment):
1933
+ for name, _value in parse_qsl(component, keep_blank_values=True):
1934
+ normalized = re.sub(r"[^a-z0-9]", "", name.casefold())
1935
+ if _SENSITIVE_KEY_RE.search(name) or normalized in _SENSITIVE_URL_PARAMETER_NAMES:
1936
+ return True
1937
+ return False
1938
+
1939
+
1940
+ def _mapping_has_sensitive_key(value: Any) -> bool:
1941
+ if isinstance(value, Mapping):
1942
+ return any(
1943
+ _SENSITIVE_KEY_RE.search(str(key)) or _mapping_has_sensitive_key(nested) for key, nested in value.items()
1944
+ )
1945
+ if isinstance(value, list | tuple):
1946
+ return any(_mapping_has_sensitive_key(item) for item in value)
1947
+ return False