android-midscene-automation 0.2.3 → 1.1.0

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 (525) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/README.md +63 -1
  3. package/artemis-main/.dockerignore +15 -0
  4. package/artemis-main/.env.example +34 -0
  5. package/artemis-main/CONTRIBUTING.md +35 -0
  6. package/artemis-main/Dockerfile +68 -0
  7. package/artemis-main/LICENSE +202 -0
  8. package/artemis-main/Makefile +115 -0
  9. package/artemis-main/README.md +322 -0
  10. package/artemis-main/README_CN.md +320 -0
  11. package/artemis-main/apps/__init__.py +15 -0
  12. package/artemis-main/apps/admin_console/__init__.py +19 -0
  13. package/artemis-main/apps/admin_console/core/__init__.py +13 -0
  14. package/artemis-main/apps/admin_console/core/config.py +43 -0
  15. package/artemis-main/apps/admin_console/core/security.py +178 -0
  16. package/artemis-main/apps/admin_console/core/state.py +243 -0
  17. package/artemis-main/apps/admin_console/database/__init__.py +13 -0
  18. package/artemis-main/apps/admin_console/database/connection.py +60 -0
  19. package/artemis-main/apps/admin_console/database/repositories/__init__.py +13 -0
  20. package/artemis-main/apps/admin_console/database/repositories/session_repository.py +564 -0
  21. package/artemis-main/apps/admin_console/database/repositories/step_repository.py +512 -0
  22. package/artemis-main/apps/admin_console/database/repositories/trace_repository.py +133 -0
  23. package/artemis-main/apps/admin_console/index.html +3787 -0
  24. package/artemis-main/apps/admin_console/replay_manager.py +2585 -0
  25. package/artemis-main/apps/admin_console/routers/__init__.py +13 -0
  26. package/artemis-main/apps/admin_console/routers/media.py +198 -0
  27. package/artemis-main/apps/admin_console/routers/replay.py +91 -0
  28. package/artemis-main/apps/admin_console/routers/sessions.py +239 -0
  29. package/artemis-main/apps/admin_console/routers/steps.py +109 -0
  30. package/artemis-main/apps/admin_console/routers/stream.py +56 -0
  31. package/artemis-main/apps/admin_console/routers/system.py +761 -0
  32. package/artemis-main/apps/admin_console/routers/tasks.py +517 -0
  33. package/artemis-main/apps/admin_console/schemas/__init__.py +13 -0
  34. package/artemis-main/apps/admin_console/schemas/task_schema.py +47 -0
  35. package/artemis-main/apps/admin_console/server.py +508 -0
  36. package/artemis-main/apps/admin_console/services/__init__.py +13 -0
  37. package/artemis-main/apps/admin_console/services/device_stream_service.py +137 -0
  38. package/artemis-main/apps/admin_console/services/ipc_service.py +262 -0
  39. package/artemis-main/apps/admin_console/services/media_service.py +623 -0
  40. package/artemis-main/apps/admin_console/services/model_service.py +148 -0
  41. package/artemis-main/apps/admin_console/services/task_preset_catalog.py +514 -0
  42. package/artemis-main/apps/admin_console/services/task_queue_service.py +1719 -0
  43. package/artemis-main/apps/admin_console/services/worker_process_io.py +144 -0
  44. package/artemis-main/apps/showcase_ui/README.md +31 -0
  45. package/artemis-main/apps/showcase_ui/angular.json +132 -0
  46. package/artemis-main/apps/showcase_ui/package-lock.json +9472 -0
  47. package/artemis-main/apps/showcase_ui/package.json +39 -0
  48. package/artemis-main/apps/showcase_ui/proxy.conf.json +23 -0
  49. package/artemis-main/apps/showcase_ui/public/favicon.ico +0 -0
  50. package/artemis-main/apps/showcase_ui/public/logo.png +0 -0
  51. package/artemis-main/apps/showcase_ui/public/logo.svg +56 -0
  52. package/artemis-main/apps/showcase_ui/src/app/app.component.html +24 -0
  53. package/artemis-main/apps/showcase_ui/src/app/app.component.scss +49 -0
  54. package/artemis-main/apps/showcase_ui/src/app/app.component.spec.ts +42 -0
  55. package/artemis-main/apps/showcase_ui/src/app/app.component.ts +32 -0
  56. package/artemis-main/apps/showcase_ui/src/app/app.config.ts +29 -0
  57. package/artemis-main/apps/showcase_ui/src/app/app.routes.ts +27 -0
  58. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts +108 -0
  59. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +1436 -0
  60. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +3973 -0
  61. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +2235 -0
  62. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html +380 -0
  63. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.scss +1171 -0
  64. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts +270 -0
  65. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +433 -0
  66. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss +987 -0
  67. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +632 -0
  68. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.html +130 -0
  69. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.scss +406 -0
  70. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.ts +74 -0
  71. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.scss +115 -0
  72. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts +52 -0
  73. package/artemis-main/apps/showcase_ui/src/app/core/data/smart-tasks.data.ts +334 -0
  74. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.spec.ts +55 -0
  75. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.ts +61 -0
  76. package/artemis-main/apps/showcase_ui/src/app/core/i18n/translate.pipe.ts +11 -0
  77. package/artemis-main/apps/showcase_ui/src/app/core/i18n/zh-cn.ts +651 -0
  78. package/artemis-main/apps/showcase_ui/src/app/core/models/markdown.model.ts +43 -0
  79. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.spec.ts +47 -0
  80. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.ts +165 -0
  81. package/artemis-main/apps/showcase_ui/src/app/core/models/session.model.ts +85 -0
  82. package/artemis-main/apps/showcase_ui/src/app/core/models/stream.model.ts +246 -0
  83. package/artemis-main/apps/showcase_ui/src/app/core/models/system.model.ts +118 -0
  84. package/artemis-main/apps/showcase_ui/src/app/core/services/task-recommendation.service.ts +111 -0
  85. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.html +2130 -0
  86. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.scss +5204 -0
  87. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.ts +1189 -0
  88. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.html +39 -0
  89. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.scss +151 -0
  90. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.ts +58 -0
  91. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +201 -0
  92. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.scss +886 -0
  93. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts +324 -0
  94. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.spec.ts +418 -0
  95. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.ts +2012 -0
  96. package/artemis-main/apps/showcase_ui/src/app/services/system.service.spec.ts +173 -0
  97. package/artemis-main/apps/showcase_ui/src/app/services/system.service.ts +620 -0
  98. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.spec.ts +513 -0
  99. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.ts +1015 -0
  100. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.spec.ts +85 -0
  101. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.ts +662 -0
  102. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts +70 -0
  103. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.ts +383 -0
  104. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.spec.ts +107 -0
  105. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.ts +123 -0
  106. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.spec.ts +92 -0
  107. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.ts +116 -0
  108. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts +640 -0
  109. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts +1021 -0
  110. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts +375 -0
  111. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +1419 -0
  112. package/artemis-main/apps/showcase_ui/src/index.html +37 -0
  113. package/artemis-main/apps/showcase_ui/src/main.ts +22 -0
  114. package/artemis-main/apps/showcase_ui/src/styles.scss +60 -0
  115. package/artemis-main/apps/showcase_ui/tsconfig.app.json +23 -0
  116. package/artemis-main/apps/showcase_ui/tsconfig.json +27 -0
  117. package/artemis-main/apps/showcase_ui/tsconfig.spec.json +23 -0
  118. package/artemis-main/artemis/__init__.py +44 -0
  119. package/artemis-main/artemis/__main__.py +18 -0
  120. package/artemis-main/artemis/_legacy_imports.py +94 -0
  121. package/artemis-main/artemis/_version.py +30 -0
  122. package/artemis-main/artemis/agents/__init__.py +17 -0
  123. package/artemis-main/artemis/agents/checker/checker.json +9 -0
  124. package/artemis-main/artemis/agents/checker/checker.py +859 -0
  125. package/artemis-main/artemis/agents/diagnoser/diagnoser.json +23 -0
  126. package/artemis-main/artemis/agents/diagnoser/diagnoser.py +608 -0
  127. package/artemis-main/artemis/agents/explorer/constants.py +52 -0
  128. package/artemis-main/artemis/agents/explorer/explorer.json +57 -0
  129. package/artemis-main/artemis/agents/explorer/explorer.py +473 -0
  130. package/artemis-main/artemis/agents/explorer/geometry.py +73 -0
  131. package/artemis-main/artemis/agents/explorer/native_runner.py +878 -0
  132. package/artemis-main/artemis/agents/explorer/perception_tools.py +632 -0
  133. package/artemis-main/artemis/agents/explorer/run_setup.py +403 -0
  134. package/artemis-main/artemis/agents/explorer/screen_index.py +266 -0
  135. package/artemis-main/artemis/agents/explorer/tiers.py +110 -0
  136. package/artemis-main/artemis/agents/explorer/tool_declarations.py +394 -0
  137. package/artemis-main/artemis/agents/explorer/universal_runner.py +317 -0
  138. package/artemis-main/artemis/agents/flash/context_compressor.py +452 -0
  139. package/artemis-main/artemis/agents/flash/flash_runner.md +51 -0
  140. package/artemis-main/artemis/agents/flash/flash_summarizer.md +53 -0
  141. package/artemis-main/artemis/agents/flash/flash_summarizer_single.md +54 -0
  142. package/artemis-main/artemis/agents/flash/runner.py +1167 -0
  143. package/artemis-main/artemis/agents/flash/summarizer.py +511 -0
  144. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.md +8 -0
  145. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.py +182 -0
  146. package/artemis-main/artemis/agents/image_processor/image_processor.md +74 -0
  147. package/artemis-main/artemis/agents/image_processor/image_processor.py +322 -0
  148. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.md +15 -0
  149. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.py +403 -0
  150. package/artemis-main/artemis/agents/log_analyzer/log_reader_sub_agent.md +8 -0
  151. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.md +11 -0
  152. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.py +163 -0
  153. package/artemis-main/artemis/agents/object_detector/object_detector.json +9 -0
  154. package/artemis-main/artemis/agents/object_detector/object_detector.py +211 -0
  155. package/artemis-main/artemis/agents/operator/operator.json +3 -0
  156. package/artemis-main/artemis/agents/operator/operator.py +1394 -0
  157. package/artemis-main/artemis/agents/operator/prompts.py +1064 -0
  158. package/artemis-main/artemis/agents/outputter/outputter.md +36 -0
  159. package/artemis-main/artemis/agents/outputter/outputter.py +552 -0
  160. package/artemis-main/artemis/agents/planner/planner.json +32 -0
  161. package/artemis-main/artemis/agents/planner/planner.py +436 -0
  162. package/artemis-main/artemis/agents/prompt_assembly.py +79 -0
  163. package/artemis-main/artemis/agents/summarizer/summarizer.py +190 -0
  164. package/artemis-main/artemis/agents/validator/action_execution.py +98 -0
  165. package/artemis-main/artemis/agents/validator/categories.py +33 -0
  166. package/artemis-main/artemis/agents/validator/execution_loop.py +488 -0
  167. package/artemis-main/artemis/agents/validator/incidents.py +120 -0
  168. package/artemis-main/artemis/agents/validator/pixel_safety_net.md +15 -0
  169. package/artemis-main/artemis/agents/validator/precondition_pixel.py +320 -0
  170. package/artemis-main/artemis/agents/validator/precondition_xml.py +672 -0
  171. package/artemis-main/artemis/agents/validator/tool_declarations.py +332 -0
  172. package/artemis-main/artemis/agents/validator/validator.py +188 -0
  173. package/artemis-main/artemis/agents/video_analyzer/__init__.py +15 -0
  174. package/artemis-main/artemis/agents/video_analyzer/audio_native.py +543 -0
  175. package/artemis-main/artemis/agents/video_analyzer/blackboard.py +821 -0
  176. package/artemis-main/artemis/agents/video_analyzer/chunk_agentic.py +455 -0
  177. package/artemis-main/artemis/agents/video_analyzer/chunk_conversation.py +491 -0
  178. package/artemis-main/artemis/agents/video_analyzer/chunk_native.py +437 -0
  179. package/artemis-main/artemis/agents/video_analyzer/conflict_resolution.py +137 -0
  180. package/artemis-main/artemis/agents/video_analyzer/gemini_files.py +162 -0
  181. package/artemis-main/artemis/agents/video_analyzer/native_coordinator.py +541 -0
  182. package/artemis-main/artemis/agents/video_analyzer/reliability.py +128 -0
  183. package/artemis-main/artemis/agents/video_analyzer/universal_engine.py +487 -0
  184. package/artemis-main/artemis/agents/video_analyzer/universal_tools.py +323 -0
  185. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.md +51 -0
  186. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.py +875 -0
  187. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent.md +15 -0
  188. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent_audio.md +12 -0
  189. package/artemis-main/artemis/clients/accessibility_client.py +438 -0
  190. package/artemis-main/artemis/clients/screen_client_factory.py +403 -0
  191. package/artemis-main/artemis/config/__init__.py +309 -0
  192. package/artemis-main/artemis/config/agent.py +1098 -0
  193. package/artemis-main/artemis/config/constants.py +151 -0
  194. package/artemis-main/artemis/config/core/__init__.py +24 -0
  195. package/artemis-main/artemis/config/core/engine.py +118 -0
  196. package/artemis-main/artemis/config/core/interpolator.py +78 -0
  197. package/artemis-main/artemis/config/llm.py +267 -0
  198. package/artemis-main/artemis/config/paths.py +244 -0
  199. package/artemis-main/artemis/config/runtime.py +197 -0
  200. package/artemis-main/artemis/config/settings.py +315 -0
  201. package/artemis-main/artemis/constants.py +47 -0
  202. package/artemis-main/artemis/context.py +289 -0
  203. package/artemis-main/artemis/controllers/__init__.py +41 -0
  204. package/artemis-main/artemis/controllers/controller_factory.py +41 -0
  205. package/artemis-main/artemis/controllers/device_controller.py +29 -0
  206. package/artemis-main/artemis/controllers/types.py +37 -0
  207. package/artemis-main/artemis/controllers/unified_controller.py +649 -0
  208. package/artemis-main/artemis/core/__init__.py +21 -0
  209. package/artemis-main/artemis/core/diagnostics/__init__.py +90 -0
  210. package/artemis-main/artemis/core/diagnostics/adb_keys.py +270 -0
  211. package/artemis-main/artemis/core/diagnostics/adb_server_connection.py +330 -0
  212. package/artemis-main/artemis/core/diagnostics/device_smoke.py +364 -0
  213. package/artemis-main/artemis/core/diagnostics/emulator_manager.py +501 -0
  214. package/artemis-main/artemis/core/diagnostics/engine.py +438 -0
  215. package/artemis-main/artemis/core/diagnostics/hierarchy_parity.py +216 -0
  216. package/artemis-main/artemis/core/diagnostics/probes/adb_probe.py +887 -0
  217. package/artemis-main/artemis/core/diagnostics/probes/base.py +45 -0
  218. package/artemis-main/artemis/core/diagnostics/probes/credentials_probe.py +296 -0
  219. package/artemis-main/artemis/core/diagnostics/probes/host_probe.py +427 -0
  220. package/artemis-main/artemis/core/diagnostics/probes/runtime_probe.py +169 -0
  221. package/artemis-main/artemis/core/diagnostics/probes/toolchain_probe.py +139 -0
  222. package/artemis-main/artemis/core/diagnostics/readiness.py +83 -0
  223. package/artemis-main/artemis/core/diagnostics/schema.py +118 -0
  224. package/artemis-main/artemis/core/tool_declaration.py +43 -0
  225. package/artemis-main/artemis/core/tool_failure.py +56 -0
  226. package/artemis-main/artemis/data_engine/context_vars.py +24 -0
  227. package/artemis-main/artemis/data_engine/engine.py +1615 -0
  228. package/artemis-main/artemis/data_engine/history_reader.py +142 -0
  229. package/artemis-main/artemis/data_engine/models.py +131 -0
  230. package/artemis-main/artemis/data_engine/storage.py +1245 -0
  231. package/artemis-main/artemis/data_engine/trace.py +727 -0
  232. package/artemis-main/artemis/drivers/__init__.py +48 -0
  233. package/artemis-main/artemis/drivers/android/adb_driver.py +507 -0
  234. package/artemis-main/artemis/drivers/android/hierarchy.py +64 -0
  235. package/artemis-main/artemis/drivers/android/input_ime.py +120 -0
  236. package/artemis-main/artemis/drivers/android/recorder.py +57 -0
  237. package/artemis-main/artemis/drivers/base.py +290 -0
  238. package/artemis-main/artemis/drivers/cloud/cloud_driver.py +117 -0
  239. package/artemis-main/artemis/drivers/factory.py +80 -0
  240. package/artemis-main/artemis/drivers/mock/mock_driver.py +198 -0
  241. package/artemis-main/artemis/drivers/types.py +67 -0
  242. package/artemis-main/artemis/graph/checkpoints.py +1243 -0
  243. package/artemis-main/artemis/graph/graph.py +1055 -0
  244. package/artemis-main/artemis/graph/perception.py +234 -0
  245. package/artemis-main/artemis/graph/state.py +151 -0
  246. package/artemis-main/artemis/graph/visibility.py +238 -0
  247. package/artemis-main/artemis/interfaces/cli/__init__.py +19 -0
  248. package/artemis-main/artemis/interfaces/cli/commands/__init__.py +15 -0
  249. package/artemis-main/artemis/interfaces/cli/commands/batch.py +278 -0
  250. package/artemis-main/artemis/interfaces/cli/commands/doctor.py +468 -0
  251. package/artemis-main/artemis/interfaces/cli/commands/helper.py +226 -0
  252. package/artemis-main/artemis/interfaces/cli/commands/init.py +193 -0
  253. package/artemis-main/artemis/interfaces/cli/commands/mcp.py +862 -0
  254. package/artemis-main/artemis/interfaces/cli/commands/run.py +463 -0
  255. package/artemis-main/artemis/interfaces/cli/commands/server.py +151 -0
  256. package/artemis-main/artemis/interfaces/cli/commands/server_lifecycle.py +328 -0
  257. package/artemis-main/artemis/interfaces/cli/commands/trace.py +111 -0
  258. package/artemis-main/artemis/interfaces/cli/commands/ui.py +404 -0
  259. package/artemis-main/artemis/interfaces/cli/main.py +109 -0
  260. package/artemis-main/artemis/interfaces/sdk/__init__.py +31 -0
  261. package/artemis-main/artemis/interfaces/sdk/client.py +19 -0
  262. package/artemis-main/artemis/interfaces/sdk/task.py +72 -0
  263. package/artemis-main/artemis/llm/__init__.py +25 -0
  264. package/artemis-main/artemis/llm/fake_model.py +179 -0
  265. package/artemis-main/artemis/llm/google/__init__.py +57 -0
  266. package/artemis-main/artemis/llm/google/provider.py +185 -0
  267. package/artemis-main/artemis/llm/google/usage.py +148 -0
  268. package/artemis-main/artemis/llm/parser.py +32 -0
  269. package/artemis-main/artemis/llm/reliability.py +267 -0
  270. package/artemis-main/artemis/llm/router.py +389 -0
  271. package/artemis-main/artemis/llm/schemas.py +36 -0
  272. package/artemis-main/artemis/llm/structured.py +196 -0
  273. package/artemis-main/artemis/main.py +58 -0
  274. package/artemis-main/artemis/mcp/__init__.py +20 -0
  275. package/artemis-main/artemis/mcp/action_executor.py +760 -0
  276. package/artemis-main/artemis/mcp/action_manifest.py +320 -0
  277. package/artemis-main/artemis/mcp/action_names.py +172 -0
  278. package/artemis-main/artemis/mcp/action_server.py +202 -0
  279. package/artemis-main/artemis/mcp/action_session.py +245 -0
  280. package/artemis-main/artemis/mcp/action_specs.py +868 -0
  281. package/artemis-main/artemis/mcp/action_types.py +86 -0
  282. package/artemis-main/artemis/mcp/actuators/__init__.py +21 -0
  283. package/artemis-main/artemis/mcp/actuators/adb.py +414 -0
  284. package/artemis-main/artemis/mcp/actuators/base.py +113 -0
  285. package/artemis-main/artemis/mcp/actuators/mock.py +70 -0
  286. package/artemis-main/artemis/mcp/adb_server.py +490 -0
  287. package/artemis-main/artemis/mcp/observation.py +133 -0
  288. package/artemis-main/artemis/mcp/xml_search_server.py +297 -0
  289. package/artemis-main/artemis/memory/__init__.py +90 -0
  290. package/artemis-main/artemis/memory/chunking.py +1843 -0
  291. package/artemis-main/artemis/memory/context_policy.py +226 -0
  292. package/artemis-main/artemis/memory/step_capsule.md +73 -0
  293. package/artemis-main/artemis/memory/step_memory.py +281 -0
  294. package/artemis-main/artemis/memory/transcript.py +798 -0
  295. package/artemis-main/artemis/platform/__init__.py +61 -0
  296. package/artemis-main/artemis/platform/base.py +104 -0
  297. package/artemis-main/artemis/platform/darwin.py +184 -0
  298. package/artemis-main/artemis/platform/linux.py +207 -0
  299. package/artemis-main/artemis/platform/windows.py +193 -0
  300. package/artemis-main/artemis/resources/__init__.py +46 -0
  301. package/artemis-main/artemis/runtime/__init__.py +108 -0
  302. package/artemis-main/artemis/runtime/adb_endpoint.py +173 -0
  303. package/artemis-main/artemis/runtime/awake_lease.py +283 -0
  304. package/artemis-main/artemis/runtime/awake_service.py +343 -0
  305. package/artemis-main/artemis/runtime/cancel_requests.py +254 -0
  306. package/artemis-main/artemis/runtime/daemon_client.py +422 -0
  307. package/artemis-main/artemis/runtime/device_lock.py +1013 -0
  308. package/artemis-main/artemis/runtime/device_pool.py +478 -0
  309. package/artemis-main/artemis/runtime/helper_manager.py +1030 -0
  310. package/artemis-main/artemis/runtime/process_probe.py +98 -0
  311. package/artemis-main/artemis/runtime/server_lifecycle.py +435 -0
  312. package/artemis-main/artemis/runtime/supervisor.py +121 -0
  313. package/artemis-main/artemis/runtime/trace_store.py +375 -0
  314. package/artemis-main/artemis/sdk/__init__.py +26 -0
  315. package/artemis-main/artemis/sdk/agent.py +485 -0
  316. package/artemis-main/artemis/sdk/builders/__init__.py +29 -0
  317. package/artemis-main/artemis/sdk/builders/agent_config_builder.py +474 -0
  318. package/artemis-main/artemis/sdk/constants.py +15 -0
  319. package/artemis-main/artemis/sdk/run_outcome.py +58 -0
  320. package/artemis-main/artemis/sdk/types/__init__.py +61 -0
  321. package/artemis-main/artemis/sdk/types/agent.py +89 -0
  322. package/artemis-main/artemis/sdk/utils.py +28 -0
  323. package/artemis-main/artemis/services/llm.py +1040 -0
  324. package/artemis-main/artemis/services/token_meter.py +267 -0
  325. package/artemis-main/artemis/telemetry/__init__.py +35 -0
  326. package/artemis-main/artemis/telemetry/models.py +64 -0
  327. package/artemis-main/artemis/telemetry/replay.py +34 -0
  328. package/artemis-main/artemis/telemetry/storage/json_storage.py +47 -0
  329. package/artemis-main/artemis/telemetry/tracer.py +85 -0
  330. package/artemis-main/artemis/toolchain/__init__.py +52 -0
  331. package/artemis-main/artemis/toolchain/descriptors.py +92 -0
  332. package/artemis-main/artemis/toolchain/resolver.py +185 -0
  333. package/artemis-main/artemis/tools/base.py +232 -0
  334. package/artemis-main/artemis/tools/command_tool.py +1240 -0
  335. package/artemis-main/artemis/tools/committee_prompts.json +5 -0
  336. package/artemis-main/artemis/tools/committee_tool.py +422 -0
  337. package/artemis-main/artemis/tools/diagnoser_submit_answer_tool.py +87 -0
  338. package/artemis-main/artemis/tools/diagnostic_tool.py +120 -0
  339. package/artemis-main/artemis/tools/explorer_tool.py +581 -0
  340. package/artemis-main/artemis/tools/history/__init__.py +398 -0
  341. package/artemis-main/artemis/tools/history/replay.py +120 -0
  342. package/artemis-main/artemis/tools/history/screenshot.py +139 -0
  343. package/artemis-main/artemis/tools/history/search.py +563 -0
  344. package/artemis-main/artemis/tools/image_processor_tool.py +124 -0
  345. package/artemis-main/artemis/tools/index.py +47 -0
  346. package/artemis-main/artemis/tools/log_tool.py +108 -0
  347. package/artemis-main/artemis/tools/mobile/launch_app.py +132 -0
  348. package/artemis-main/artemis/tools/mobile/log_utils.py +138 -0
  349. package/artemis-main/artemis/tools/mobile/ocr.py +202 -0
  350. package/artemis-main/artemis/tools/mobile/read_hierarchy.py +108 -0
  351. package/artemis-main/artemis/tools/mobile/read_logs.py +116 -0
  352. package/artemis-main/artemis/tools/mobile/search_logs.py +260 -0
  353. package/artemis-main/artemis/tools/object_detection_tool.py +244 -0
  354. package/artemis-main/artemis/tools/scratchpad.py +819 -0
  355. package/artemis-main/artemis/tools/system/adb_shell.py +31 -0
  356. package/artemis-main/artemis/tools/tool_wrapper.py +270 -0
  357. package/artemis-main/artemis/tools/video_tool.py +261 -0
  358. package/artemis-main/artemis/tools/wait_tool.py +97 -0
  359. package/artemis-main/artemis/utils/app_launch_utils.py +207 -0
  360. package/artemis-main/artemis/utils/coordinates.py +606 -0
  361. package/artemis-main/artemis/utils/credentials_validator.py +201 -0
  362. package/artemis-main/artemis/utils/cv_canvas.py +189 -0
  363. package/artemis-main/artemis/utils/cython_compat.py +45 -0
  364. package/artemis-main/artemis/utils/element_hit_test.py +80 -0
  365. package/artemis-main/artemis/utils/image_diff.py +335 -0
  366. package/artemis-main/artemis/utils/image_hash.py +80 -0
  367. package/artemis-main/artemis/utils/notes.py +470 -0
  368. package/artemis-main/artemis/utils/ocr_api.py +105 -0
  369. package/artemis-main/artemis/utils/ocr_xml_fusion.py +318 -0
  370. package/artemis-main/artemis/utils/plan_grammar.py +513 -0
  371. package/artemis-main/artemis/utils/python_executor.py +213 -0
  372. package/artemis-main/artemis/utils/startup_progress.py +60 -0
  373. package/artemis-main/artemis/utils/task_tree.py +1174 -0
  374. package/artemis-main/artemis/utils/text.py +37 -0
  375. package/artemis-main/artemis/utils/ui_filter.py +651 -0
  376. package/artemis-main/artemis/utils/video.py +685 -0
  377. package/artemis-main/artemis/utils/visualization.py +802 -0
  378. package/artemis-main/config/artemis.example.jsonc +12 -0
  379. package/artemis-main/mcp_server/README.md +111 -0
  380. package/artemis-main/mcp_server/__init__.py +21 -0
  381. package/artemis-main/mcp_server/__main__.py +20 -0
  382. package/artemis-main/mcp_server/background/__init__.py +15 -0
  383. package/artemis-main/mcp_server/background/task_runner.py +453 -0
  384. package/artemis-main/mcp_server/base.py +49 -0
  385. package/artemis-main/mcp_server/notifiers/__init__.py +74 -0
  386. package/artemis-main/mcp_server/notifiers/agentapi.py +289 -0
  387. package/artemis-main/mcp_server/notifiers/base.py +45 -0
  388. package/artemis-main/mcp_server/notifiers/composite.py +87 -0
  389. package/artemis-main/mcp_server/notifiers/desktop.py +108 -0
  390. package/artemis-main/mcp_server/notifiers/file.py +69 -0
  391. package/artemis-main/mcp_server/notifiers/script.py +90 -0
  392. package/artemis-main/mcp_server/notifiers/webhook.py +87 -0
  393. package/artemis-main/mcp_server/rules.md +68 -0
  394. package/artemis-main/mcp_server/server.py +106 -0
  395. package/artemis-main/mcp_server/tools/__init__.py +29 -0
  396. package/artemis-main/mcp_server/tools/device_state.py +107 -0
  397. package/artemis-main/mcp_server/tools/diagnose.py +1249 -0
  398. package/artemis-main/mcp_server/tools/inspect_trace.py +394 -0
  399. package/artemis-main/mcp_server/tools/task_manager.py +627 -0
  400. package/artemis-main/mcp_server/tools/task_runner.py +548 -0
  401. package/artemis-main/mcp_server/utils/__init__.py +15 -0
  402. package/artemis-main/mcp_server/utils/device_utils.py +180 -0
  403. package/artemis-main/mcp_server/utils/env_utils.py +65 -0
  404. package/artemis-main/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk +0 -0
  405. package/artemis-main/packages/artemis-accessibility-helper/app/build.gradle.kts +35 -0
  406. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml +49 -0
  407. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java +274 -0
  408. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java +227 -0
  409. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java +463 -0
  410. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java +111 -0
  411. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java +232 -0
  412. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java +832 -0
  413. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenReceiver.java +36 -0
  414. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenStore.java +38 -0
  415. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java +102 -0
  416. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml +4 -0
  417. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml +10 -0
  418. package/artemis-main/packages/artemis-accessibility-helper/build.gradle.kts +3 -0
  419. package/artemis-main/packages/artemis-accessibility-helper/build_apk.sh +215 -0
  420. package/artemis-main/packages/artemis-accessibility-helper/debug.keystore +0 -0
  421. package/artemis-main/packages/artemis-accessibility-helper/gradle.properties +2 -0
  422. package/artemis-main/packages/artemis-accessibility-helper/helper_manifest.json +7 -0
  423. package/artemis-main/packages/artemis-accessibility-helper/settings.gradle.kts +17 -0
  424. package/artemis-main/packages/artemis-client/DESIGN.md +89 -0
  425. package/artemis-main/packages/artemis-client/README.md +112 -0
  426. package/artemis-main/packages/artemis-client/examples/basic.py +30 -0
  427. package/artemis-main/packages/artemis-client/pyproject.toml +36 -0
  428. package/artemis-main/packages/artemis-client/src/artemis_client/__init__.py +45 -0
  429. package/artemis-main/packages/artemis-client/src/artemis_client/client.py +401 -0
  430. package/artemis-main/packages/artemis-client/src/artemis_client/errors.py +66 -0
  431. package/artemis-main/packages/artemis-client/src/artemis_client/models.py +212 -0
  432. package/artemis-main/packages/artemis-client/src/artemis_client/py.typed +1 -0
  433. package/artemis-main/packages/artemis-client/src/artemis_client/transport.py +134 -0
  434. package/artemis-main/packages/artemis-client/tests/test_client.py +289 -0
  435. package/artemis-main/packages/artemis-client/tests/test_transport.py +85 -0
  436. package/artemis-main/pyproject.toml +259 -0
  437. package/artemis-main/pyright-core.json +17 -0
  438. package/artemis-main/scripts/check_dependency_sources.py +68 -0
  439. package/artemis-main/scripts/install_deps.ps1 +520 -0
  440. package/artemis-main/scripts/install_deps.sh +533 -0
  441. package/artemis-main/scripts/quality_ratchet.py +97 -0
  442. package/artemis-main/scripts/real_device_video_acceptance.py +483 -0
  443. package/artemis-main/scripts/run_edge_case_part.py +327 -0
  444. package/artemis-main/scripts/run_ui.py +242 -0
  445. package/artemis-main/scripts/start.ps1 +500 -0
  446. package/artemis-main/scripts/test_all_edge_cases.py +269 -0
  447. package/artemis-main/scripts/verify_concurrency_and_devices.py +385 -0
  448. package/artemis-main/setup.py +164 -0
  449. package/artemis-main/start.bat +35 -0
  450. package/artemis-main/start.sh +423 -0
  451. package/artemis-main/third_party/mobile_use/LICENSE +190 -0
  452. package/artemis-main/third_party/mobile_use/METADATA +27 -0
  453. package/artemis-main/third_party/mobile_use/NOTICE +12 -0
  454. package/artemis-main/third_party/mobile_use/__init__.py +19 -0
  455. package/artemis-main/third_party/mobile_use/agents/__init__.py +13 -0
  456. package/artemis-main/third_party/mobile_use/agents/hopper/__init__.py +13 -0
  457. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.md +22 -0
  458. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.py +80 -0
  459. package/artemis-main/third_party/mobile_use/clients/__init__.py +13 -0
  460. package/artemis-main/third_party/mobile_use/clients/ui_automator_client.py +482 -0
  461. package/artemis-main/third_party/mobile_use/config/__init__.py +13 -0
  462. package/artemis-main/third_party/mobile_use/config/llm.py +183 -0
  463. package/artemis-main/third_party/mobile_use/config/output.py +184 -0
  464. package/artemis-main/third_party/mobile_use/context.py +119 -0
  465. package/artemis-main/third_party/mobile_use/controllers/__init__.py +13 -0
  466. package/artemis-main/third_party/mobile_use/controllers/platform_specific_commands_controller.py +151 -0
  467. package/artemis-main/third_party/mobile_use/controllers/types.py +92 -0
  468. package/artemis-main/third_party/mobile_use/controllers/unified_controller.py +183 -0
  469. package/artemis-main/third_party/mobile_use/graph.py +54 -0
  470. package/artemis-main/third_party/mobile_use/llm_service.py +193 -0
  471. package/artemis-main/third_party/mobile_use/main.py +130 -0
  472. package/artemis-main/third_party/mobile_use/sdk/__init__.py +13 -0
  473. package/artemis-main/third_party/mobile_use/sdk/agent.py +1060 -0
  474. package/artemis-main/third_party/mobile_use/sdk/builders/__init__.py +13 -0
  475. package/artemis-main/third_party/mobile_use/sdk/builders/agent_config_builder.py +225 -0
  476. package/artemis-main/third_party/mobile_use/sdk/builders/index.py +33 -0
  477. package/artemis-main/third_party/mobile_use/sdk/builders/task_request_builder.py +243 -0
  478. package/artemis-main/third_party/mobile_use/sdk/types/__init__.py +13 -0
  479. package/artemis-main/third_party/mobile_use/sdk/types/agent.py +98 -0
  480. package/artemis-main/third_party/mobile_use/sdk/types/exceptions.py +109 -0
  481. package/artemis-main/third_party/mobile_use/sdk/types/task.py +234 -0
  482. package/artemis-main/third_party/mobile_use/testing.py +59 -0
  483. package/artemis-main/third_party/mobile_use/tools/__init__.py +13 -0
  484. package/artemis-main/third_party/mobile_use/tools/index.py +44 -0
  485. package/artemis-main/third_party/mobile_use/tools/mobile/__init__.py +13 -0
  486. package/artemis-main/third_party/mobile_use/tools/mobile/launch_app.py +89 -0
  487. package/artemis-main/third_party/mobile_use/tools/tool_wrapper.py +52 -0
  488. package/artemis-main/third_party/mobile_use/utils/__init__.py +13 -0
  489. package/artemis-main/third_party/mobile_use/utils/app_launch_utils.py +217 -0
  490. package/artemis-main/third_party/mobile_use/utils/cli_helpers.py +60 -0
  491. package/artemis-main/third_party/mobile_use/utils/decorators.py +158 -0
  492. package/artemis-main/third_party/mobile_use/utils/file.py +31 -0
  493. package/artemis-main/third_party/mobile_use/utils/logger.py +216 -0
  494. package/artemis-main/third_party/mobile_use/utils/media.py +213 -0
  495. package/artemis-main/third_party/mobile_use/utils/shell_utils.py +39 -0
  496. package/artemis-main/third_party/mobile_use/utils/video.py +398 -0
  497. package/artemis-main/uv.lock +4908 -0
  498. package/bin/android-midscene-automation.js +1 -5
  499. package/bin/artemis-config.mjs +15 -0
  500. package/bin/artemis-embedded.py +56 -0
  501. package/bin/start-artemis.mjs +30 -0
  502. package/bin/start-dev.mjs +112 -0
  503. package/bin/test_artemis_embedded.py +44 -0
  504. package/package.json +10 -8
  505. package/server/artemis.ts +34 -0
  506. package/server/http-api.ts +13 -1
  507. package/src/App.vue +72 -23
  508. package/src/api.ts +5 -0
  509. package/src/appium-recorder/AppiumPage.vue +1 -1
  510. package/src/appium-recorder/components/ComponentInspector.vue +1 -1
  511. package/src/appium-recorder/components/FlowNodeSearch.vue +3 -3
  512. package/src/appium-recorder/components/ImageCheckDialog.vue +3 -3
  513. package/src/appium-recorder/components/RecorderWorkspace.vue +4 -4
  514. package/src/appium-recorder/flow-appearance.ts +4 -4
  515. package/src/components/AppSidebar.vue +8 -7
  516. package/src/components/MidsceneMaintenanceNotice.vue +17 -0
  517. package/src/components/analytics/AnalysisCharts.vue +4 -4
  518. package/src/components/analytics/ReportAnalysis.vue +1 -1
  519. package/src/components/generator/TestCaseFileUpload.vue +1 -1
  520. package/src/components/help/HelpCenter.vue +56 -37
  521. package/src/pages/AiGeneratorPage.vue +1 -1
  522. package/src/pages/ArtemisPage.vue +72 -0
  523. package/src/pages/ConfigPage.vue +18 -19
  524. package/src/style.css +369 -186
  525. package/src/types.ts +1 -1
@@ -0,0 +1,1843 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """History compression into segment chunks, eras, and recall-only periods.
16
+
17
+ Each chunk keeps three bands: synopsis and effects, interval summaries, and a
18
+ per-step action ledger. Capsule generation reads the operator's turn transcripts
19
+ alongside the recorded step facts. Milestone changes, segment size, and token
20
+ limits determine chunk boundaries.
21
+
22
+ Original messages remain in context until their capsule is ready. Failed capsule
23
+ jobs retain their source text for retry. At the hard token limit, ready chunks
24
+ swap as ordinary L2 blocks first; only when that cannot bring the context back
25
+ under the line does everything closed force-swap (pending chunks included) and
26
+ the whole frozen region fold into a recall-only era — L3: the period paragraph
27
+ plus the per-step minimal index (step number, session offset, action phrase).
28
+ The fold is monotonic: later L2 swaps append new chunk blocks after the folded
29
+ eras and never restore the folded chunks. Older chunks also fold into eras and
30
+ then recall-only periods by count overflow; full step records remain available
31
+ through the history tools.
32
+
33
+ Checkpoint annotations persist independently of capsule generation. Frozen
34
+ context is rebuilt only when a compression event replaces or folds turns.
35
+ """
36
+
37
+ import asyncio
38
+ from dataclasses import dataclass, field
39
+ import json
40
+ from pathlib import Path
41
+ import re
42
+ import time
43
+ from typing import Any, Callable
44
+ from uuid import uuid4
45
+
46
+ from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
47
+
48
+ from artemis.llm.google import is_google_provider
49
+ from artemis.memory.step_memory import JobKey, StepLens, StepMemoryService
50
+ from artemis.memory.transcript import format_session_offset
51
+ from third_party.mobile_use.utils.logger import get_logger
52
+
53
+ logger = get_logger(__name__)
54
+
55
+ #: Recall guidance line rendered under an extreme-layer period paragraph
56
+ #: (an era whose per-step ledger overflowed to recall-only).
57
+ RECALL_GUIDANCE_TEMPLATE = " (Step-level ledger via search_history for steps {start}–{end})"
58
+
59
+ CHUNK_PENDING_NOTE = (
60
+ "①/② capsule pending (background generation); the mechanical"
61
+ " ledger below is complete. Original step records remain recallable"
62
+ " via search_history / replay_steps."
63
+ )
64
+
65
+ _NOTE_TOOLS = ("save_note", "update_note", "append_note")
66
+
67
+ # Reuse one tool trace per chunk across running, success, and failed states.
68
+ COMPRESSION_TRACE_NAME = "compress_history"
69
+
70
+ #: Plain-language phases carried in the trace payload (``args.phase``) so the
71
+ #: timeline can say what the compression is *doing* independently of the
72
+ #: trace ``status`` (running/success/failed), which other code keys on.
73
+ #: summarizing — the segment closed and its capsule is generating (running)
74
+ #: ready — the capsule is on hand but the start gate holds the swap (running)
75
+ #: applied — the compressed block replaced the raw turns (success)
76
+ #: failed — capsule generation gave up; the full record is kept (failed)
77
+ COMPRESSION_PHASE_SUMMARIZING = "summarizing"
78
+ COMPRESSION_PHASE_READY = "ready"
79
+ COMPRESSION_PHASE_APPLIED = "applied"
80
+ COMPRESSION_PHASE_FAILED = "failed"
81
+ COMPRESSION_PHASES = (
82
+ COMPRESSION_PHASE_SUMMARIZING,
83
+ COMPRESSION_PHASE_READY,
84
+ COMPRESSION_PHASE_APPLIED,
85
+ COMPRESSION_PHASE_FAILED,
86
+ )
87
+ _STATUS_TO_PHASE = {
88
+ "running": COMPRESSION_PHASE_SUMMARIZING,
89
+ "success": COMPRESSION_PHASE_APPLIED,
90
+ "failed": COMPRESSION_PHASE_FAILED,
91
+ }
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Band ③ — mechanical per-step action ledger
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def _result_phrase(result: Any) -> str:
100
+ """Controller/validator result phrase for one ledger line (mechanical)."""
101
+ if not isinstance(result, dict) or not result:
102
+ return "no terminal action"
103
+ from artemis.utils.task_tree import format_result_clean
104
+
105
+ detail = None
106
+ try:
107
+ detail = format_result_clean(result)
108
+ except Exception:
109
+ detail = None
110
+ if detail:
111
+ return detail
112
+ status = result.get("status")
113
+ return str(status) if status else "dispatched"
114
+
115
+
116
+ def _action_phrase(step: dict) -> str:
117
+ """Semantic action phrase for one step (``format_actions_clean``).
118
+
119
+ A fast-action burst (2+ actions in one turn) lists every member so the
120
+ ledger stays zero-distortion.
121
+ """
122
+ from artemis.utils.task_tree import format_actions_clean
123
+
124
+ return format_actions_clean(step.get("action_taken"))
125
+
126
+
127
+ def step_offset_label(step: dict, session_start: float | None) -> str:
128
+ """``T+mm:ss`` session-start offset of a step (byte-stable once frozen)."""
129
+ ts = step.get("timestamp")
130
+ if session_start is None or not isinstance(ts, (int, float)):
131
+ return "T+??:??"
132
+ return format_session_offset(float(ts) - float(session_start))
133
+
134
+
135
+ def injected_instruction_line(step: dict) -> str | None:
136
+ """The never-evict verbatim user-injection line for a step, if any."""
137
+ instr = (step.get("extra_metadata") or {}).get("injected_instruction")
138
+ if not instr:
139
+ return None
140
+ return f' User @ Step {step.get("step_number")}: "{instr}"'
141
+
142
+
143
+ def build_action_ledger(
144
+ steps: list[dict],
145
+ session_start: float | None,
146
+ *,
147
+ minimal: bool = False,
148
+ ) -> str:
149
+ """Assemble band ③ mechanically (1:1, never elided, no LLM involved).
150
+
151
+ ``minimal=True`` renders the L3 minimum-width index (step number + offset
152
+ + action phrase). User-injected instruction lines are preserved verbatim
153
+ at every width — they are never evicted at any compression level.
154
+ """
155
+ lines: list[str] = []
156
+ for step in steps:
157
+ number = step.get("step_number")
158
+ offset = step_offset_label(step, session_start)
159
+ action = _action_phrase(step)
160
+ if minimal:
161
+ lines.append(f"- Step {number} ({offset}): {action}")
162
+ else:
163
+ lines.append(
164
+ f"- Step {number} ({offset}): {action} -> {_result_phrase(step.get('last_execution_result'))}"
165
+ )
166
+ user_line = injected_instruction_line(step)
167
+ if user_line:
168
+ lines.append(user_line)
169
+ return "\n".join(lines)
170
+
171
+
172
+ def extract_note_writes(step: dict) -> list[dict[str, str]]:
173
+ """Notes written during a step (band ① input; task_plan writes excluded)."""
174
+ writes: list[dict[str, str]] = []
175
+ for event in step.get("interleaved_events") or []:
176
+ if event.get("type") != "tool_call" or event.get("name") not in _NOTE_TOOLS:
177
+ continue
178
+ args = event.get("args") or {}
179
+ key = args.get("key")
180
+ if not key or key == "task_plan":
181
+ continue
182
+ gist = args.get("content") or args.get("replacement") or ""
183
+ writes.append({"tool": event["name"], "key": str(key), "gist": str(gist)[:300]})
184
+ return writes
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Band ①+② — StepCapsuleLens (single LLM call) + machine-checked coverage
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ def validate_interval_coverage(intervals: Any, start_step: int, end_step: int) -> bool:
193
+ """Band ② hard constraint: the interval union covers [start, end] without gaps or overlaps."""
194
+ if not isinstance(intervals, list) or not intervals:
195
+ return False
196
+ expected = start_step
197
+ for interval in intervals:
198
+ if not isinstance(interval, dict):
199
+ return False
200
+ try:
201
+ s = int(interval.get("start_step"))
202
+ e = int(interval.get("end_step"))
203
+ except (TypeError, ValueError):
204
+ return False
205
+ if s != expected or e < s or not str(interval.get("text") or "").strip():
206
+ return False
207
+ expected = e + 1
208
+ return expected == end_step + 1
209
+
210
+
211
+ _CAPSULE_REQUIRED_KEYS = ("doing", "did", "effect", "intervals")
212
+ _CAPSULE_LIST_KEYS = ("verified_facts", "unresolved", "failed_paths", "important_entities")
213
+
214
+ #: Coordinate literals belong in the action ledger (band 3), not the
215
+ #: interval summary (band 2).
216
+ _COORDINATE_LITERAL_RE = re.compile(
217
+ r"\[\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?(?:\s*,\s*-?\d+(?:\.\d+)?){0,2}\s*\]"
218
+ )
219
+
220
+
221
+ def band2_intervals_carry_coordinates(intervals: Any) -> bool:
222
+ """True when any band-② interval text contains a coordinate literal."""
223
+ if not isinstance(intervals, list):
224
+ return False
225
+ return any(
226
+ isinstance(interval, dict)
227
+ and _COORDINATE_LITERAL_RE.search(str(interval.get("text") or ""))
228
+ for interval in intervals
229
+ )
230
+
231
+
232
+ def collect_note_keys(payload: dict[str, Any]) -> list[str]:
233
+ """Deduplicated note keys written during the segment (payload order)."""
234
+ keys: list[str] = []
235
+ seen: set[str] = set()
236
+ for step in payload.get("steps") or []:
237
+ for write in step.get("note_writes") or []:
238
+ key = str(write.get("key") or "").strip()
239
+ if key and key not in seen:
240
+ seen.add(key)
241
+ keys.append(key)
242
+ return keys
243
+
244
+
245
+ def _strip_code_fences(text: str) -> str:
246
+ text = text.strip()
247
+ match = re.search(r"```(?:json)?\s*(.*?)\s*```", text, flags=re.DOTALL)
248
+ return match.group(1) if match else text
249
+
250
+
251
+ def _visual_summary_marker() -> str:
252
+ """Header the scrub edge writes above a resolved visual summary.
253
+
254
+ Imported lazily: ``context_compressor`` imports ``artemis.memory``, so a
255
+ module-level import here would close an import cycle.
256
+ """
257
+ from artemis.agents.flash.context_compressor import HISTORY_SUMMARY_PREFIX
258
+
259
+ return HISTORY_SUMMARY_PREFIX.strip()
260
+
261
+
262
+ class StepCapsuleLens(StepLens):
263
+ """Chunk-level lens producing bands ①+② in one call.
264
+
265
+ The payload carries the segment turn by turn: for each committed turn
266
+ the mechanical step lines (step number, session offset, action,
267
+ controller result, visual transition, notes written, injected
268
+ instruction) followed by the turn's transcript exactly as the operator
269
+ saw it (:func:`artemis.memory.transcript.render_turn_transcript`). The
270
+ flat ``steps`` list is kept beside ``turns`` for the machine checks and
271
+ as the rendering fallback when a segment carries no transcript (e.g. a
272
+ chunk built without a live ledger). The rendered summary string is the
273
+ JSON capsule; an attempt whose interval union fails the mechanical
274
+ coverage check returns ``None`` so the bounded service retry regenerates
275
+ it, and on exhaustion the chunk simply stays pending (③ is independently
276
+ usable).
277
+ """
278
+
279
+ name = "step_capsule"
280
+
281
+ _PROMPT_PATH = Path(__file__).parent / "step_capsule.md"
282
+
283
+ #: Per-turn transcript cap inside one capsule request — a runaway tool
284
+ #: result must not sink the whole segment. The ledger and the DataEngine
285
+ #: keep the full text; the cut is announced in place.
286
+ MAX_TURN_TRANSCRIPT_CHARS = 16000
287
+
288
+ #: Tail of a capped transcript kept intact (the validator result closes
289
+ #: every turn); bounded to a quarter of the cap.
290
+ TRANSCRIPT_TAIL_KEEP_CHARS = 2000
291
+
292
+ def __init__(
293
+ self,
294
+ model_name: str | None = None,
295
+ llm: Any | None = None,
296
+ *,
297
+ ctx: Any = None,
298
+ fallback_model_name: str | None = None,
299
+ fallback_llm: Any | None = None,
300
+ ):
301
+ self._model_name = model_name or "gemini-3.8-flash"
302
+ self._llm = llm
303
+ self._ctx = ctx
304
+ # Availability hardening: `chunking.model` is a dedicated model with no
305
+ # gateway behind it — when that one endpoint is down (e.g. a day-long
306
+ # 503), every capsule dies and chunk headers stay pending forever. A
307
+ # configured fallback model turns a provider outage into a degraded
308
+ # attempt instead of a dead loop.
309
+ self._fallback_model_name = (
310
+ fallback_model_name if fallback_model_name != self._model_name else None
311
+ )
312
+ self._fallback_llm = fallback_llm
313
+ self._use_config_model = False
314
+ role = getattr(getattr(ctx, "llm_config", None), "summarizer", None)
315
+ if isinstance(getattr(role, "provider", None), str) and role.provider != "google":
316
+ self._use_config_model = True
317
+ self._model_name = role.model
318
+ self._fallback_model_name = role.fallback.model
319
+ try:
320
+ self._prompt = self._PROMPT_PATH.read_text(encoding="utf-8")
321
+ except Exception:
322
+ self._prompt = (
323
+ "You compress one segment of executed steps into a JSON capsule with"
324
+ " keys doing/did/effect/entry_state/exit_state/verified_facts/"
325
+ "unresolved/failed_paths/important_entities/intervals. Never use"
326
+ " verdict words (successfully, completed, failed, ...); intervals"
327
+ " must cover the full step range without gaps or overlaps. Return only JSON."
328
+ )
329
+
330
+ def _get_llm(self):
331
+ if self._llm is None:
332
+ from artemis.services.llm import get_google_llm, get_llm
333
+ if self._use_config_model:
334
+ self._llm = get_llm(self._ctx, name="summarizer", temperature=0.0)
335
+ else:
336
+ self._llm = get_google_llm(model_name=self._model_name, temperature=0.0)
337
+ return self._llm
338
+
339
+ def _get_fallback_llm(self):
340
+ if self._fallback_llm is None and self._fallback_model_name:
341
+ from artemis.services.llm import get_google_llm, get_llm
342
+ if self._use_config_model:
343
+ self._fallback_llm = get_llm(self._ctx, name="summarizer", use_fallback=True, temperature=0.0)
344
+ else:
345
+ self._fallback_llm = get_google_llm(
346
+ model_name=self._fallback_model_name, temperature=0.0
347
+ )
348
+ return self._fallback_llm
349
+
350
+ @property
351
+ def has_fallback(self) -> bool:
352
+ return self._fallback_llm is not None or bool(self._fallback_model_name)
353
+
354
+ @property
355
+ def model_name(self) -> str:
356
+ return self._model_name
357
+
358
+ def build_messages(self, payload: dict[str, Any]) -> list[BaseMessage]:
359
+ start = payload.get("start_step")
360
+ end = payload.get("end_step")
361
+ label = payload.get("milestone_label")
362
+ header = [f"SEGMENT: Steps {start}–{end}"]
363
+ if label:
364
+ header.append(f"MILESTONE: {label}")
365
+
366
+ blocks: list[str] = ["\n".join(header)]
367
+ turns = payload.get("turns")
368
+ if turns:
369
+ for turn in turns:
370
+ blocks.append(self._render_turn_block(turn))
371
+ else:
372
+ for step in payload.get("steps", []):
373
+ blocks.append(self._render_step_block(step))
374
+
375
+ return [
376
+ SystemMessage(content=self._prompt),
377
+ HumanMessage(content="\n\n".join(blocks)),
378
+ ]
379
+
380
+ @staticmethod
381
+ def _step_record_lines(step: dict[str, Any], *, include_visual: bool = True) -> list[str]:
382
+ """Mechanical facts of one step (the same facts band ③ is built from).
383
+
384
+ ``include_visual=False`` skips the visual transition line when the
385
+ turn transcript already carries the resolved summary verbatim.
386
+ """
387
+ lines = [
388
+ f"- Step {step.get('step_number')} ({step.get('offset')}):"
389
+ f" {step.get('action')} -> {step.get('outcome')}"
390
+ ]
391
+ if include_visual and step.get("visual_summary"):
392
+ lines.append(f" Visual transition: {step['visual_summary']}")
393
+ for write in step.get("note_writes") or []:
394
+ lines.append(f" Note written ({write['tool']} -> {write['key']}): {write['gist']}")
395
+ if step.get("injected_instruction"):
396
+ lines.append(f' User injected instruction: "{step["injected_instruction"]}"')
397
+ return lines
398
+
399
+ @classmethod
400
+ def _render_turn_block(cls, turn: dict[str, Any]) -> str:
401
+ """One committed turn: its recorded step lines, then the transcript the
402
+ operator saw during that turn (verbatim, capped in place)."""
403
+ steps = turn.get("steps") or []
404
+ if not steps:
405
+ header = "## Turn without a recorded step"
406
+ elif len(steps) == 1:
407
+ header = f"## Step {steps[0].get('step_number')} ({steps[0].get('offset')})"
408
+ else:
409
+ header = (
410
+ f"## Steps {steps[0].get('step_number')}–{steps[-1].get('step_number')}"
411
+ f" ({steps[0].get('offset')} → {steps[-1].get('offset')})"
412
+ )
413
+ lines = [header]
414
+ transcript = str(turn.get("transcript") or "").strip()
415
+ # The resolved visual summary is already verbatim in the transcript
416
+ # unless the screenshot was still inside the pending-grace window.
417
+ include_visual = not transcript or _visual_summary_marker() not in transcript
418
+ if steps:
419
+ lines.append("Recorded steps:")
420
+ for step in steps:
421
+ lines.extend(cls._step_record_lines(step, include_visual=include_visual))
422
+ if transcript:
423
+ lines.append("Transcript as seen by the operator during this turn:")
424
+ lines.append(cls._cap_transcript(transcript))
425
+ else:
426
+ for step in steps:
427
+ if step.get("thinking_excerpt"):
428
+ lines.append(
429
+ f"Reasoning excerpt (Step {step.get('step_number')}):"
430
+ f" {step['thinking_excerpt']}"
431
+ )
432
+ return "\n".join(lines)
433
+
434
+ @classmethod
435
+ def _cap_transcript(cls, transcript: str) -> str:
436
+ """Cap one turn transcript in place, keeping its head and its tail.
437
+
438
+ The tail is kept intact because the turn's last messages (the final
439
+ action's tool result, or the failure result message that closes a
440
+ failed turn) outrank everything above them (fact priority); the cut
441
+ lands in the middle and is announced with its size.
442
+ """
443
+ cap = cls.MAX_TURN_TRANSCRIPT_CHARS
444
+ if len(transcript) <= cap:
445
+ return transcript
446
+ tail_keep = min(cls.TRANSCRIPT_TAIL_KEEP_CHARS, cap // 4)
447
+ head, tail = transcript[: cap - tail_keep], transcript[-tail_keep:]
448
+ cut = len(transcript) - len(head) - len(tail)
449
+ return (
450
+ f"{head}\n[… {cut} characters cut here; the full step records remain"
451
+ f" recallable via replay_steps …]\n{tail}"
452
+ )
453
+
454
+ @classmethod
455
+ def _render_step_block(cls, step: dict[str, Any]) -> str:
456
+ """Fallback rendering for a payload without turn transcripts."""
457
+ lines = [f"## Step {step.get('step_number')} ({step.get('offset')})"]
458
+ lines.extend(cls._step_record_lines(step))
459
+ if step.get("thinking_excerpt"):
460
+ lines.append(f"Reasoning excerpt: {step['thinking_excerpt']}")
461
+ return "\n".join(lines)
462
+
463
+ def parse_capsule(self, text: str, payload: dict[str, Any]) -> dict[str, Any] | None:
464
+ """Parse + machine-check one capsule; None on any violation."""
465
+ try:
466
+ parsed = json.loads(_strip_code_fences(text))
467
+ except Exception:
468
+ return None
469
+ if not isinstance(parsed, dict):
470
+ return None
471
+ for key in _CAPSULE_REQUIRED_KEYS:
472
+ if key not in parsed:
473
+ return None
474
+ for key in _CAPSULE_LIST_KEYS:
475
+ value = parsed.get(key)
476
+ parsed[key] = [str(v) for v in value] if isinstance(value, list) else []
477
+ parsed.setdefault("entry_state", "")
478
+ parsed.setdefault("exit_state", "")
479
+ if not validate_interval_coverage(
480
+ parsed.get("intervals"), int(payload["start_step"]), int(payload["end_step"])
481
+ ):
482
+ logger.warning(
483
+ "StepCapsuleLens: interval union does not cover Steps"
484
+ f" {payload['start_step']}–{payload['end_step']}; regenerating."
485
+ )
486
+ return None
487
+ # Band ② must not restate band ③: a coordinate literal in an interval
488
+ # text is the ledger written twice, and it fails the attempt the same
489
+ # way a coverage gap does.
490
+ if band2_intervals_carry_coordinates(parsed.get("intervals")):
491
+ logger.warning(
492
+ "StepCapsuleLens: band ② interval text carries coordinates for Steps"
493
+ f" {payload['start_step']}–{payload['end_step']}; regenerating."
494
+ )
495
+ return None
496
+ # Regenerate the capsule if band 1 omits any note key written in the segment.
497
+ note_keys = collect_note_keys(payload)
498
+ if note_keys:
499
+ searchable = " ".join(
500
+ [
501
+ str(parsed.get(field) or "")
502
+ for field in ("doing", "did", "effect", "entry_state", "exit_state")
503
+ ]
504
+ + [v for key in _CAPSULE_LIST_KEYS for v in parsed.get(key) or []]
505
+ )
506
+ missing = [key for key in note_keys if key not in searchable]
507
+ if missing:
508
+ logger.warning(
509
+ f"StepCapsuleLens: capsule omits note key(s) {missing}"
510
+ " written during the segment; regenerating."
511
+ )
512
+ return None
513
+ return parsed
514
+
515
+ async def _invoke(self, llm: Any, messages: list[BaseMessage], model_label: str):
516
+ response = await asyncio.wait_for(llm.ainvoke(messages), timeout=90.0)
517
+ # Raw-model bypass metering (gateway-wrapped models meter themselves);
518
+ # capsule prompts are small and are kept out of the session meter's
519
+ # last_prompt_tokens (the compaction base itself is operator-owned on
520
+ # the transcript ledger and never reads the session meter).
521
+ try:
522
+ from artemis.services.llm import RobustChatModelWrapper
523
+ from artemis.services.token_meter import record_llm_usage
524
+
525
+ if not isinstance(llm, RobustChatModelWrapper):
526
+ engine = getattr(self._ctx, "data_engine", None) if self._ctx else None
527
+ record_llm_usage(
528
+ engine,
529
+ response,
530
+ source=f"lens:step_capsule:{model_label}",
531
+ update_last_prompt=False,
532
+ )
533
+ except Exception as exc:
534
+ logger.debug(f"Capsule LLM usage metering skipped: {exc}", exc_info=True)
535
+ return response
536
+
537
+ async def render(self, key: JobKey, payload: dict[str, Any]) -> str | None:
538
+ messages = self.build_messages(payload)
539
+ if self.has_fallback:
540
+ from artemis.services.llm import with_fallback
541
+
542
+ response = await with_fallback(
543
+ lambda: self._invoke(self._get_llm(), messages, self._model_name),
544
+ lambda: self._invoke(
545
+ self._get_fallback_llm(),
546
+ messages,
547
+ self._fallback_model_name or "fallback",
548
+ ),
549
+ none_should_fallback=False,
550
+ )
551
+ else:
552
+ response = await self._invoke(self._get_llm(), messages, self._model_name)
553
+ content = getattr(response, "content", "")
554
+ if isinstance(content, list):
555
+ content = "".join(
556
+ b.get("text", "") for b in content if isinstance(b, dict) and "text" in b
557
+ )
558
+ parsed = self.parse_capsule(str(content), payload)
559
+ if parsed is None:
560
+ return None
561
+ capsule = json.dumps(parsed, ensure_ascii=False)
562
+ # Measure the capsule size against the transcript it replaces.
563
+ source_chars = len(str(messages[-1].content))
564
+ logger.info(
565
+ f"Chunk capsule {key}: source {source_chars} chars -> capsule"
566
+ f" {len(capsule)} chars ({len(capsule) / max(1, source_chars):.0%})."
567
+ )
568
+ return capsule
569
+
570
+
571
+ class ChunkCapsuleService(StepMemoryService):
572
+ """Background runtime for chunk capsules (bands ①②) on the shared skeleton."""
573
+
574
+ def __init__(self, ctx: Any, lens: StepCapsuleLens, **kwargs):
575
+ super().__init__(ctx, lens=lens, **kwargs)
576
+ # Report exhausted retries without waiting for the next render.
577
+ self.failure_hook: Callable[[JobKey], None] | None = None
578
+
579
+ def _on_status(self, key: JobKey, status: str) -> None:
580
+ if status == "failed" and self.failure_hook is not None:
581
+ try:
582
+ self.failure_hook(key)
583
+ except Exception as e:
584
+ logger.warning(f"Capsule failure hook for {key} raised: {e}")
585
+
586
+ def _on_ready(self, key: JobKey, summary: str) -> None:
587
+ # The turn transcripts only serve generation. A ready capsule is never
588
+ # regenerated (only failed ones are re-dispatched), so release the
589
+ # bulk of the payload instead of holding every segment's source text
590
+ # for the rest of the session.
591
+ payload = self._step_inputs.get(key)
592
+ if payload:
593
+ payload.pop("turns", None)
594
+
595
+ @property
596
+ def lens(self) -> StepCapsuleLens:
597
+ return self._lens # type: ignore[return-value]
598
+
599
+
600
+ # ---------------------------------------------------------------------------
601
+ # In-memory chunk / era mirrors
602
+ # ---------------------------------------------------------------------------
603
+
604
+
605
+ @dataclass
606
+ class ChunkState:
607
+ """In-memory mirror of one chunk (authoritative for rendering & versions)."""
608
+
609
+ ordinal: int
610
+ start_step_number: int
611
+ end_step_number: int
612
+ start_step_id: str | None
613
+ end_step_id: str | None
614
+ source_step_ids: list[str]
615
+ subgoal_hash: str | None
616
+ milestone_label: str | None
617
+ start_offset: str
618
+ end_offset: str
619
+ band3: str
620
+ minimal_index: str
621
+ user_lines: list[str]
622
+ version: int = 1
623
+ status: str = "pending"
624
+ band1: dict[str, Any] = field(default_factory=dict)
625
+ band2: str | None = None
626
+ annotations: list[dict[str, Any]] = field(default_factory=list)
627
+ # Segment closure reason: milestone, size, or pressure.
628
+ trigger: str | None = None
629
+ trace_id: Any = None
630
+ trace_step_id: Any = None
631
+ announced_at: float | None = None
632
+ # Measure source at close and summary at swap; tokens are derived through
633
+ # the ledger's calibrated chars-per-token ratio (chars // 4 by default).
634
+ # Forced swaps without a capsule leave summary_chars unset.
635
+ source_chars: int = 0
636
+ summary_chars: int | None = None
637
+
638
+ @property
639
+ def capsule_key(self) -> str:
640
+ return f"chunk:{self.start_step_number}-{self.end_step_number}"
641
+
642
+ @property
643
+ def step_range_label(self) -> str:
644
+ return f"Steps {self.start_step_number}–{self.end_step_number}"
645
+
646
+
647
+ @dataclass
648
+ class EraState:
649
+ """A group of merged chunks: ① set-merged, ② title lines, ③ retained."""
650
+
651
+ ordinal: int
652
+ chunks: list[ChunkState]
653
+ recall_only: bool = False
654
+
655
+ @property
656
+ def start_step_number(self) -> int:
657
+ return self.chunks[0].start_step_number
658
+
659
+ @property
660
+ def end_step_number(self) -> int:
661
+ return self.chunks[-1].end_step_number
662
+
663
+
664
+ def merge_structured_fields(band1_list: list[dict[str, Any]]) -> dict[str, list[str]]:
665
+ """Order-preserving set-merge of the chunks' structured fields (no re-summarizing)."""
666
+ merged: dict[str, list[str]] = {key: [] for key in _CAPSULE_LIST_KEYS}
667
+ for band1 in band1_list:
668
+ for key in _CAPSULE_LIST_KEYS:
669
+ for value in band1.get(key) or []:
670
+ if value not in merged[key]:
671
+ merged[key].append(value)
672
+ return merged
673
+
674
+
675
+ # ---------------------------------------------------------------------------
676
+ # Rendering
677
+ # ---------------------------------------------------------------------------
678
+
679
+
680
+ def render_band2(band1: dict[str, Any]) -> str:
681
+ lines = []
682
+ for interval in band1.get("intervals") or []:
683
+ s, e = interval.get("start_step"), interval.get("end_step")
684
+ span = f"Step {s}" if s == e else f"Steps {s}–{e}"
685
+ lines.append(f" - {span}: {interval.get('text')}")
686
+ return "\n".join(lines)
687
+
688
+
689
+ def _render_annotations(chunk: ChunkState) -> list[str]:
690
+ if not chunk.annotations:
691
+ return []
692
+ lines = [" Post-hoc check results:"]
693
+ for ann in chunk.annotations:
694
+ lines.append(
695
+ f" - [{ann.get('kind', 'check')}] '{ann.get('item_text', '')}'"
696
+ f" → {ann.get('status', '?')} ({ann.get('evidence', '')})"
697
+ )
698
+ return lines
699
+
700
+
701
+ def render_chunk_block(chunk: ChunkState) -> str:
702
+ """Full three-band chunk block, in order (① → ② → ③)."""
703
+ milestone = f' | Milestone "{chunk.milestone_label}"' if chunk.milestone_label else ""
704
+ header = (
705
+ f"[Chunk {chunk.ordinal}{milestone} | {chunk.step_range_label}"
706
+ f" | {chunk.start_offset} → {chunk.end_offset}]"
707
+ )
708
+ parts = [header, ""]
709
+ if chunk.status == "ready" and chunk.band1:
710
+ b1 = chunk.band1
711
+ parts.append("① Synopsis & effects")
712
+ parts.append(f" What this segment was doing: {b1.get('doing', '')}")
713
+ parts.append(f" What was actually done: {b1.get('did', '')}")
714
+ parts.append(f" Effects / left behind: {b1.get('effect', '')}")
715
+ parts.append(f" Entry: {b1.get('entry_state', '')} Exit: {b1.get('exit_state', '')}")
716
+ parts.append(f" Verified: {'; '.join(b1.get('verified_facts') or []) or '-'}")
717
+ parts.append(f" Unresolved: {'; '.join(b1.get('unresolved') or []) or '-'}")
718
+ parts.append(f" Failed paths: {'; '.join(b1.get('failed_paths') or []) or '-'}")
719
+ parts.append(f" Entities: {'; '.join(b1.get('important_entities') or []) or '-'}")
720
+ parts.extend(_render_annotations(chunk))
721
+ parts.append("")
722
+ parts.append("② Compressed step summary")
723
+ parts.append(chunk.band2 or render_band2(chunk.band1))
724
+ else:
725
+ parts.append(CHUNK_PENDING_NOTE)
726
+ parts.extend(_render_annotations(chunk))
727
+ parts.append("")
728
+ parts.append("③ Step action ledger")
729
+ parts.append(chunk.band3)
730
+ return "\n".join(parts)
731
+
732
+
733
+ _SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?。!?])\s+")
734
+
735
+ #: Note references inside a band-① ``effect`` (note keys are file-shaped:
736
+ #: ``notes/<key>`` paths or ``*.md`` files) — mechanical extraction only.
737
+ _NOTE_REF_RE = re.compile(r"\bnotes/[\w\-.]+|\b[\w\-./]+\.md\b")
738
+
739
+ #: Cap on merged verified_facts quoted inside a period paragraph.
740
+ _PERIOD_FACTS_LIMIT = 4
741
+
742
+
743
+ def _first_sentence(text: str) -> str:
744
+ text = (text or "").strip()
745
+ if not text:
746
+ return ""
747
+ return _SENTENCE_SPLIT_RE.split(text, 1)[0].strip().rstrip(".。")
748
+
749
+
750
+ def render_era_period_paragraph(era: EraState) -> str:
751
+ """One-paragraph period synopsis for a recall-only era.
752
+
753
+ Mechanically assembled from the member chunks' band-① fields — never an
754
+ extra LLM call: the first non-empty ``doing``, each chunk's first ``did``
755
+ sentence chained in order (a band-①-less/pending chunk falls back to its
756
+ milestone label or step range), the merged note references found in the
757
+ ``effect`` texts, and the first few merged ``verified_facts``. When no
758
+ chunk has a band ① at all, it degrades to the milestone-label list with
759
+ step ranges — the paragraph is never empty.
760
+ """
761
+ ready = [c for c in era.chunks if c.band1]
762
+ if not ready:
763
+ labels = [f"{c.milestone_label or 'segment'} ({c.step_range_label})" for c in era.chunks]
764
+ return "Milestones: " + "; ".join(labels) + "."
765
+
766
+ sentences: list[str] = []
767
+ doing = next(
768
+ (
769
+ str(c.band1.get("doing") or "").strip()
770
+ for c in era.chunks
771
+ if c.band1 and str(c.band1.get("doing") or "").strip()
772
+ ),
773
+ "",
774
+ )
775
+ if doing:
776
+ sentences.append(
777
+ doing if doing.endswith((".", "!", "?", "。", "!", "?")) else doing + "."
778
+ )
779
+
780
+ did_parts: list[str] = []
781
+ for chunk in era.chunks:
782
+ did = _first_sentence(str(chunk.band1.get("did") or "")) if chunk.band1 else ""
783
+ if not did:
784
+ did = chunk.milestone_label or (
785
+ f"steps {chunk.start_step_number}–{chunk.end_step_number}"
786
+ )
787
+ did_parts.append(did)
788
+ sentences.append("Did: " + "; ".join(did_parts) + ".")
789
+
790
+ note_refs: list[str] = []
791
+ for chunk in ready:
792
+ for ref in _NOTE_REF_RE.findall(str(chunk.band1.get("effect") or "")):
793
+ if ref not in note_refs:
794
+ note_refs.append(ref)
795
+ if note_refs:
796
+ sentences.append("Notes left: " + ", ".join(note_refs) + ".")
797
+
798
+ facts = merge_structured_fields([c.band1 for c in ready])["verified_facts"]
799
+ if facts:
800
+ sentences.append("Verified: " + "; ".join(facts[:_PERIOD_FACTS_LIMIT]) + ".")
801
+ return " ".join(sentences)
802
+
803
+
804
+ def render_era_block(era: EraState) -> str:
805
+ """Era block: ① merged headers, ② degraded to titles, ③ per-chunk ledgers.
806
+
807
+ A recall-only era (L3) includes the step range and session offsets for
808
+ video alignment, a synopsis, recall guidance, and the per-step minimal
809
+ index of every member chunk (step number, session offset, action phrase
810
+ — results only via search_history). Pending chunks contribute their
811
+ index too. User-injected instruction lines are never evicted: they sit
812
+ inside the index at their step, or are appended when a chunk carries no
813
+ index text.
814
+ """
815
+ if era.recall_only:
816
+ lines = [
817
+ (
818
+ f"[Era {era.ordinal} | Steps {era.start_step_number}–{era.end_step_number}"
819
+ f" | {era.chunks[0].start_offset} → {era.chunks[-1].end_offset}]"
820
+ f" {render_era_period_paragraph(era)}"
821
+ ),
822
+ RECALL_GUIDANCE_TEMPLATE.format(start=era.start_step_number, end=era.end_step_number),
823
+ ]
824
+ for chunk in era.chunks:
825
+ if chunk.minimal_index:
826
+ lines.append(chunk.minimal_index)
827
+ rendered = "\n".join(lines)
828
+ for chunk in era.chunks:
829
+ lines.extend(line for line in chunk.user_lines if line not in rendered)
830
+ return "\n".join(lines)
831
+
832
+ merged = merge_structured_fields([c.band1 for c in era.chunks if c.band1])
833
+ entry = next((c.band1.get("entry_state") for c in era.chunks if c.band1), "") or ""
834
+ exit_state = ""
835
+ for chunk in reversed(era.chunks):
836
+ if chunk.band1:
837
+ exit_state = chunk.band1.get("exit_state") or ""
838
+ break
839
+
840
+ parts = [
841
+ (
842
+ f"[Era {era.ordinal} | Steps {era.start_step_number}–{era.end_step_number}"
843
+ f" | {era.chunks[0].start_offset} → {era.chunks[-1].end_offset}"
844
+ f" | merged from {len(era.chunks)} chunks]"
845
+ ),
846
+ "",
847
+ "① Merged synopsis (structured fields, set-merged)",
848
+ f" Entry: {entry} Exit: {exit_state}",
849
+ f" Verified: {'; '.join(merged['verified_facts']) or '-'}",
850
+ f" Unresolved: {'; '.join(merged['unresolved']) or '-'}",
851
+ f" Failed paths: {'; '.join(merged['failed_paths']) or '-'}",
852
+ f" Entities: {'; '.join(merged['important_entities']) or '-'}",
853
+ "",
854
+ "② Segment titles",
855
+ ]
856
+ for chunk in era.chunks:
857
+ title = chunk.milestone_label
858
+ if not title and chunk.band1:
859
+ title = str(chunk.band1.get("did") or "").split(".")[0]
860
+ parts.append(f" - {chunk.step_range_label}: {title or 'segment'}")
861
+ parts.extend(_render_annotations(chunk))
862
+ parts.append("")
863
+ parts.append("③ Step action ledger")
864
+ for chunk in era.chunks:
865
+ parts.append(chunk.band3)
866
+ return "\n".join(parts)
867
+
868
+
869
+ # ---------------------------------------------------------------------------
870
+ # HistoryChunkManager — triggers, compression events, F-region rendering
871
+ # ---------------------------------------------------------------------------
872
+
873
+
874
+ class HistoryChunkManager:
875
+ """Owner of L2/L3 compression policy over a :class:`TranscriptLedger`.
876
+
877
+ Fed by the graph (stamped subgoal hashes, plan-write boundary hints,
878
+ checkpoint harvests) and consulted by the ledger at render time
879
+ (:meth:`on_render`). All deep mutations of the frozen region happen inside
880
+ a compression event; between events the frozen blocks are byte-stable.
881
+ """
882
+
883
+ def __init__(
884
+ self,
885
+ *,
886
+ engine: Any = None,
887
+ ctx: Any = None,
888
+ chunking_config: Any = None,
889
+ transcript_config: Any = None,
890
+ capsule_service: StepMemoryService | None = None,
891
+ meter_getter: Callable[[], int | None] | None = None,
892
+ goal: str | None = None,
893
+ ):
894
+ self._engine = engine
895
+ self._ctx = ctx
896
+ # Accepted for the callers' wiring; the frozen region no longer
897
+ # recites the goal (the prompt carries it elsewhere).
898
+ self._goal = goal
899
+
900
+ cc = chunking_config
901
+ self._max_steps = int(getattr(cc, "max_steps", 12) or 12)
902
+ self._min_steps = max(1, min(self._max_steps, int(getattr(cc, "min_steps", 3) or 3)))
903
+ self._target_source_tokens = int(getattr(cc, "target_source_tokens", 2000) or 2000)
904
+ self._model_name = getattr(cc, "model", None) or "gemini-3.8-flash"
905
+ self._max_chunks = int(getattr(cc, "max_chunks", 8) or 8)
906
+ # None uses max_chunks as the era cap.
907
+ self._max_eras = int(getattr(cc, "max_eras", None) or self._max_chunks)
908
+
909
+ tc = transcript_config
910
+ self._budget = int(getattr(tc, "context_budget_tokens", 80000) or 80000)
911
+ start_ratio = getattr(tc, "start_ratio", None)
912
+ self._start_ratio = float(0.35 if start_ratio is None else start_ratio)
913
+ self._soft_ratio = float(getattr(tc, "soft_ratio", 0.7) or 0.7)
914
+ self._hard_ratio = float(getattr(tc, "hard_ratio", 0.9) or 0.9)
915
+ self._min_active_steps = int(getattr(tc, "min_active_steps", 5) or 5)
916
+
917
+ self._meter_getter = meter_getter
918
+ self._capsule_service = capsule_service or self._build_capsule_service(ctx)
919
+ if isinstance(self._capsule_service, ChunkCapsuleService):
920
+ self._capsule_service.failure_hook = self._on_capsule_failed
921
+
922
+ # Trigger state (fed from the graph).
923
+ self._step_hashes: dict[str, str] = {}
924
+ self._last_hash: str | None = None
925
+ self._boundary_hint_pending = False
926
+
927
+ # Mirrors. ``_chunks``/``_eras`` hold SWAPPED chunks (their turns are
928
+ # frozen out of the transcript); ``_awaiting`` holds closed segments
929
+ # whose original turns still live in the transcript until their
930
+ # capsule header is ready (ready-gated swap). An entry's ``chunk`` is
931
+ # None when the segment had no step records — it can then only leave
932
+ # the queue through the hard-threshold force-swap.
933
+ self._chunks: list[ChunkState] = []
934
+ self._eras: list[EraState] = []
935
+ self._awaiting: list[dict[str, Any]] = []
936
+ self._chunk_counter = 0
937
+ self._era_counter = 0
938
+
939
+ def _build_capsule_service(self, ctx: Any) -> StepMemoryService:
940
+ kwargs: dict[str, Any] = {}
941
+ try:
942
+ from artemis.config import load_agent_config
943
+
944
+ runtime = load_agent_config().memory.runtime
945
+ kwargs = {
946
+ "retry_limit": runtime.retry_limit,
947
+ "max_concurrency": runtime.max_concurrency,
948
+ "flush_timeout_s": runtime.flush_timeout_s,
949
+ }
950
+ except Exception as exc:
951
+ logger.debug(
952
+ f"Memory runtime config unavailable; using capsule service defaults: {exc}",
953
+ exc_info=True,
954
+ )
955
+ lens = StepCapsuleLens(
956
+ self._model_name,
957
+ ctx=ctx,
958
+ fallback_model_name=self._resolve_capsule_fallback_model(ctx),
959
+ )
960
+ return ChunkCapsuleService(ctx, lens, **kwargs)
961
+
962
+ def _resolve_capsule_fallback_model(self, ctx: Any) -> str | None:
963
+ """Fallback model for capsule generation when `chunking.model` is down.
964
+
965
+ Resolved from the LLM config's summarizer role (which inherits the
966
+ global default fallback unless overridden). Only same-provider (google)
967
+ fallbacks apply — the capsule lens rides the raw google model path.
968
+ """
969
+ try:
970
+ llm_cfg = getattr(ctx, "llm_config", None) if ctx is not None else None
971
+ if llm_cfg is None:
972
+ from artemis.config.llm import get_default_llm_config
973
+
974
+ llm_cfg = get_default_llm_config()
975
+ fallback = getattr(getattr(llm_cfg, "summarizer", None), "fallback", None)
976
+ provider = str(getattr(fallback, "provider", "") or "")
977
+ model = getattr(fallback, "model", None)
978
+ if model and is_google_provider(provider) and model != self._model_name:
979
+ return str(model)
980
+ except Exception as exc:
981
+ logger.debug(f"Capsule fallback model resolution skipped: {exc}", exc_info=True)
982
+ return None
983
+
984
+ @property
985
+ def capsule_service(self) -> StepMemoryService:
986
+ return self._capsule_service
987
+
988
+ @property
989
+ def chunks(self) -> tuple[ChunkState, ...]:
990
+ return tuple(self._chunks)
991
+
992
+ @property
993
+ def eras(self) -> tuple[EraState, ...]:
994
+ return tuple(self._eras)
995
+
996
+ @property
997
+ def awaiting_chunks(self) -> tuple[ChunkState, ...]:
998
+ """Closed-but-unswapped chunks (their original turns are still live)."""
999
+ return tuple(e["chunk"] for e in self._awaiting if e["chunk"] is not None)
1000
+
1001
+ @property
1002
+ def boundary_hint_pending(self) -> bool:
1003
+ return self._boundary_hint_pending
1004
+
1005
+ # ------------------------------------------------------------------
1006
+ # Graph-fed trigger events
1007
+ # ------------------------------------------------------------------
1008
+
1009
+ def queue_boundary_hint(self) -> None:
1010
+ """A plan write completed a top-level milestone: queue an *unconfirmed*
1011
+ boundary. Only the next stamped step confirms it (a hash change); a
1012
+ stamp without a hash change (e.g. the write was vetoed and rolled
1013
+ back) discards the hint — pseudo-switch protection."""
1014
+ self._boundary_hint_pending = True
1015
+
1016
+ def on_step_stamped(self, step_id: str, subgoal_hash: str | None) -> None:
1017
+ """Record one executed step's stamped subgoal hash (the sole milestone
1018
+ fact source: a change between consecutive stamps IS the switch)."""
1019
+ stamped = subgoal_hash or "default"
1020
+ self._step_hashes[str(step_id)] = stamped
1021
+ if self._last_hash is not None and stamped != self._last_hash:
1022
+ if self._boundary_hint_pending:
1023
+ logger.info("HistoryChunkManager: queued boundary confirmed by stamp change.")
1024
+ self._boundary_hint_pending = False
1025
+ elif self._boundary_hint_pending:
1026
+ logger.info(
1027
+ "HistoryChunkManager: queued boundary NOT confirmed by the next"
1028
+ " stamped step (plan write likely rolled back); discarding."
1029
+ )
1030
+ self._boundary_hint_pending = False
1031
+ self._last_hash = stamped
1032
+
1033
+ def annotate_from_checkpoint(self, checkpoint_id: str, verdicts: list[dict[str, Any]]) -> bool:
1034
+ """Post-hoc annotation: a harvested checkpoint verdict landed after its
1035
+ segment was chunked. The matching chunk gains an annotation and a
1036
+ version bump (DB immediately; the frozen text re-renders only at the
1037
+ next compression event)."""
1038
+ if not verdicts:
1039
+ return False
1040
+ aliases = self._subgoal_aliases(checkpoint_id)
1041
+ annotated = False
1042
+ for chunk in self._all_chunks():
1043
+ if chunk.subgoal_hash and chunk.subgoal_hash in aliases:
1044
+ chunk.annotations.extend(verdicts)
1045
+ chunk.version += 1
1046
+ self._persist(chunk)
1047
+ annotated = True
1048
+ return annotated
1049
+
1050
+ def _subgoal_aliases(self, checkpoint_id: str) -> set[str]:
1051
+ aliases = {checkpoint_id}
1052
+ try:
1053
+ from artemis.utils.task_tree import get_all_subgoal_aliases
1054
+
1055
+ base_dir = getattr(self._engine, "base_dir", None)
1056
+ if base_dir:
1057
+ aliases |= set(get_all_subgoal_aliases(checkpoint_id, base_dir))
1058
+ except Exception as exc:
1059
+ logger.debug(f"Subgoal alias lookup for {checkpoint_id} skipped: {exc}", exc_info=True)
1060
+ return aliases
1061
+
1062
+ def _all_chunks(self) -> list[ChunkState]:
1063
+ return (
1064
+ [c for era in self._eras for c in era.chunks]
1065
+ + self._chunks
1066
+ + [e["chunk"] for e in self._awaiting if e["chunk"] is not None]
1067
+ )
1068
+
1069
+ # ------------------------------------------------------------------
1070
+ # Render-time compression events
1071
+ # ------------------------------------------------------------------
1072
+
1073
+ def on_render(self, ledger) -> None:
1074
+ """Evaluate triggers and, when one fires, run one compression event."""
1075
+ try:
1076
+ self._on_render_inner(ledger)
1077
+ except Exception as e:
1078
+ logger.error(f"History chunk compression event failed: {e}")
1079
+
1080
+ def _on_render_inner(self, ledger) -> None:
1081
+ base_tokens = self._context_base_tokens(ledger)
1082
+ soft = base_tokens is not None and base_tokens >= self._budget * self._soft_ratio
1083
+ hard = base_tokens is not None and base_tokens >= self._budget * self._hard_ratio
1084
+
1085
+ closed_any = self._close_new_segments(ledger, soft, hard)
1086
+ if closed_any or soft or hard:
1087
+ # Failure rung of the degradation ladder: exhausted capsules are
1088
+ # re-dispatched (the lens retries a fallback model per attempt);
1089
+ # the original text stays until one attempt lands. Cadence is
1090
+ # bounded to trigger/pressure renders, never every render.
1091
+ self._redispatch_failed_capsules()
1092
+ self._harvest_capsules()
1093
+ self._swap_ready_segments(ledger, hard=hard, base_tokens=base_tokens)
1094
+
1095
+ def _close_new_segments(self, ledger, soft: bool, hard: bool = False) -> bool:
1096
+ """Trigger evaluation: close due segments and dispatch their capsules.
1097
+
1098
+ Closing NEVER freezes turns (ready-gated swap): the segment's original
1099
+ messages stay live in the transcript; a closed segment joins the
1100
+ ``_awaiting`` queue until its capsule header is ready.
1101
+
1102
+ Minimum chunk length: the size and pressure triggers only close once
1103
+ at least ``min_steps`` eligible turns have accumulated in the open
1104
+ segment — turns age past the sliding-window floor one per render, so
1105
+ without it a heavy session closes a one-turn chunk (one capsule call)
1106
+ every render. A milestone close is exempt (a complete segment is a
1107
+ unit, however short) and the hard threshold waives it (emergency:
1108
+ whatever is eligible closes so the swap has material).
1109
+ """
1110
+ turns = ledger.unchunked_turns()
1111
+ if len(turns) <= self._min_active_steps:
1112
+ return False # sliding-window floor: nothing is eligible
1113
+ eligible_count = len(turns) - self._min_active_steps
1114
+ claimed = sum(len(e["turns"]) for e in self._awaiting)
1115
+ if eligible_count <= claimed:
1116
+ return False
1117
+ remaining = turns[claimed:]
1118
+ remaining_eligible = eligible_count - claimed
1119
+
1120
+ segments = self._partition(remaining)
1121
+ if not segments:
1122
+ return False
1123
+
1124
+ # Milestone trigger: a *complete* closed segment — one that ended (a
1125
+ # later segment follows, or its hash differs from the current stamp)
1126
+ # and has fully aged past the sliding-window floor — closes whole
1127
+ # (the previous segment becomes one HistoryChunk; the floor only
1128
+ # delays the event, it never splits the segment). The trailing segment
1129
+ # is subject to the size/soft triggers over its eligible portion only.
1130
+ selected: list[tuple[str | None, list[dict], str]] = []
1131
+ open_hash = self._last_hash
1132
+
1133
+ consumed_prefix = 0
1134
+ tail_segment: tuple[str | None, list[dict]] | None = None
1135
+ for idx, segment in enumerate(segments):
1136
+ seg_hash, seg_turns = segment
1137
+ is_last = idx == len(segments) - 1
1138
+ closed = (not is_last) or seg_hash != open_hash
1139
+ if closed and consumed_prefix + len(seg_turns) <= remaining_eligible:
1140
+ selected.append((seg_hash, seg_turns, "milestone"))
1141
+ consumed_prefix += len(seg_turns)
1142
+ continue
1143
+ tail_segment = segment
1144
+ break
1145
+
1146
+ milestone_event = bool(selected)
1147
+
1148
+ tail_portion: list[dict] = []
1149
+ if tail_segment is not None:
1150
+ tail_portion = tail_segment[1][: max(0, remaining_eligible - consumed_prefix)]
1151
+
1152
+ long_enough = hard or len(tail_portion) >= self._min_steps
1153
+
1154
+ size_event = False
1155
+ if tail_segment is not None and tail_portion and long_enough:
1156
+ tail_chars = ledger.turn_text_chars(tail_portion)
1157
+ size_event = (
1158
+ len(tail_segment[1]) >= self._max_steps
1159
+ or self._chars_to_tokens(tail_chars, ledger) >= self._target_source_tokens
1160
+ )
1161
+
1162
+ if size_event:
1163
+ selected.append((tail_segment[0], tail_portion, "size"))
1164
+ elif not milestone_event and soft and tail_portion and long_enough:
1165
+ # Soft threshold with nothing else due: close the oldest open
1166
+ # segment's eligible portion (bounded by the chunk size cap).
1167
+ selected.append((tail_segment[0], tail_portion[: self._max_steps], "pressure"))
1168
+
1169
+ if not selected:
1170
+ return False
1171
+
1172
+ steps_by_id = self._load_steps_by_id()
1173
+ for seg_hash, seg_turns, trigger in selected:
1174
+ for slice_turns in self._slices(seg_turns):
1175
+ chunk = self._create_chunk(
1176
+ slice_turns, seg_hash, steps_by_id, ledger, trigger=trigger
1177
+ )
1178
+ self._awaiting.append({"chunk": chunk, "turns": list(slice_turns)})
1179
+ logger.info(
1180
+ f"History segments closed (ready-gated): {len(self._awaiting)} awaiting"
1181
+ " capsule headers; original turns retained until ready."
1182
+ )
1183
+ return True
1184
+
1185
+ def _redispatch_failed_capsules(self) -> None:
1186
+ for entry in self._awaiting:
1187
+ chunk = entry["chunk"]
1188
+ if chunk is None or chunk.status != "pending":
1189
+ continue
1190
+ key = chunk.capsule_key
1191
+ try:
1192
+ if not self._capsule_service.has_failed(key):
1193
+ continue
1194
+ payload = self._capsule_service.get_job_payload(key)
1195
+ if payload is None:
1196
+ continue
1197
+ logger.info(
1198
+ f"Re-dispatching failed capsule {key}; original text is"
1199
+ " retained until a capsule lands."
1200
+ )
1201
+ self._capsule_service.submit(key, payload)
1202
+ # A fresh attempt: if the chunk readies again while the start
1203
+ # gate still holds it, the timeline gets a fresh ``held`` note
1204
+ # instead of staying on ``retrying``.
1205
+ entry["held_announced"] = False
1206
+ self._announce(chunk, "running", note="retrying")
1207
+ except Exception as e:
1208
+ logger.error(f"Capsule re-dispatch for {key} failed: {e}")
1209
+
1210
+ def _swap_ready_segments(self, ledger, *, hard: bool, base_tokens: int | None = None) -> None:
1211
+ """Swap the ready prefix of awaiting segments into the frozen region.
1212
+
1213
+ Swaps consume the transcript's oldest unchunked turns, so only a
1214
+ contiguous READY prefix may swap — an older pending segment keeps its
1215
+ (and every younger segment's) original text live.
1216
+
1217
+ Start gate: closing a segment (milestone/size triggers) only *prepares*
1218
+ its capsule in the background; the original text is replaced only once
1219
+ the operator's measured context has reached ``budget * start_ratio``.
1220
+ Below that the ready chunks are held in the awaiting queue, so at low
1221
+ occupancy the model keeps reading the full transcript while capsules
1222
+ are already on hand for when pressure arrives. An unknown base (no
1223
+ measured operator call yet, or a provider without usage metadata)
1224
+ cannot gate and falls through to swap-on-ready.
1225
+
1226
+ Hard threshold ladder (L2 first, L3 last): at ``budget * hard_ratio``
1227
+ the ready prefix swaps as ordinary L2 chunk blocks when the estimated
1228
+ context after that swap drops back under the hard line (rung 1). Only
1229
+ when no ready prefix exists, or the L2 swap would still leave the
1230
+ context at or above the line, does everything closed force-swap —
1231
+ pending chunks included — and the whole frozen region fold into a
1232
+ recall-only era (rung 2: L3 = period paragraph + per-step minimal
1233
+ index). With nothing awaiting at all, the last resort is folding the
1234
+ existing frozen region the same way without consuming turns (rung 3).
1235
+ The fold is a monotonic state change: later L2 swaps append new chunk
1236
+ blocks after the folded eras and never restore the folded chunks;
1237
+ repeated hard renders with nothing left to fold leave the frozen
1238
+ blocks byte-identical.
1239
+
1240
+ ``base_tokens`` is the prompt size before the swap, used to estimate
1241
+ the remaining context on the last swapped chunk's trace.
1242
+ """
1243
+ hard_line = self._budget * self._hard_ratio
1244
+ if not self._awaiting:
1245
+ if hard and self._collapse_frozen_region():
1246
+ ledger.freeze_turns(0, self._render_frozen_blocks())
1247
+ logger.info(
1248
+ "History compression: hard threshold with nothing awaiting;"
1249
+ " frozen region folded into the recall-only period."
1250
+ )
1251
+ return
1252
+
1253
+ # An entry without a chunk (turns with no step records: nothing to
1254
+ # summarize, nothing to render) is consumable at once — it must not
1255
+ # pin the queue, or no L2 swap ever happens and the hard line jumps
1256
+ # straight to the fold.
1257
+ ready_prefix: list[dict[str, Any]] = []
1258
+ for entry in self._awaiting:
1259
+ chunk = entry["chunk"]
1260
+ if chunk is not None and chunk.status != "ready":
1261
+ break
1262
+ ready_prefix.append(entry)
1263
+
1264
+ swap: list[dict[str, Any]] = []
1265
+ fold = False
1266
+ if hard:
1267
+ estimate = self._estimate_context_after(base_tokens, ready_prefix, ledger)
1268
+ if ready_prefix and estimate is not None and estimate < hard_line:
1269
+ swap = ready_prefix # rung 1: L2 is enough
1270
+ else:
1271
+ swap = list(self._awaiting) # rung 2: force everything, fold
1272
+ fold = True
1273
+ elif base_tokens is not None and base_tokens < self._budget * self._start_ratio:
1274
+ self._announce_held(base_tokens, ledger)
1275
+ return
1276
+ else:
1277
+ swap = ready_prefix
1278
+ if not swap:
1279
+ return
1280
+ self._awaiting = self._awaiting[len(swap) :]
1281
+
1282
+ for entry in swap:
1283
+ if entry["chunk"] is not None:
1284
+ self._chunks.append(entry["chunk"])
1285
+ if fold:
1286
+ self._collapse_frozen_region()
1287
+ else:
1288
+ self._fold_eras()
1289
+ blocks = self._render_frozen_blocks()
1290
+ consumed = sum(len(e["turns"]) for e in swap)
1291
+ ledger.freeze_turns(consumed, blocks)
1292
+ logger.info(
1293
+ f"History compression swap: froze {consumed} turns"
1294
+ f" ({len(self._chunks)} chunks, {len(self._eras)} eras,"
1295
+ f" {len(self._awaiting)} still awaiting, hard={hard}, folded={fold})."
1296
+ )
1297
+ # A folded chunk contributes only its minimal index (its ① fields are
1298
+ # merged into the period paragraph); a pending chunk has no summary
1299
+ # size to report, so its trace carries no compression ratio.
1300
+ swapped = [e for e in swap if e["chunk"] is not None]
1301
+ source_tokens_total = 0
1302
+ replacement_tokens_total = 0
1303
+ for entry in swapped:
1304
+ chunk = entry["chunk"]
1305
+ if chunk.status == "ready":
1306
+ chunk.summary_chars = len(render_chunk_block(chunk))
1307
+ else:
1308
+ chunk.summary_chars = None
1309
+ source_tokens_total += self._chars_to_tokens(chunk.source_chars, ledger)
1310
+ replacement_tokens_total += self._chars_to_tokens(
1311
+ self._replacement_chars(chunk, folded=fold), ledger
1312
+ )
1313
+ context_tokens: int | None = None
1314
+ if base_tokens is not None:
1315
+ context_tokens = max(
1316
+ 0, int(base_tokens) - source_tokens_total + replacement_tokens_total
1317
+ )
1318
+
1319
+ for index, entry in enumerate(swapped):
1320
+ chunk = entry["chunk"]
1321
+ turns = len(entry["turns"])
1322
+ plural = "s" if turns != 1 else ""
1323
+ forced = fold and chunk.status != "ready"
1324
+ if forced:
1325
+ result = (
1326
+ "Context budget reached before the summary was ready;"
1327
+ f" {turns} turn{plural} folded into the period summary."
1328
+ )
1329
+ elif fold:
1330
+ result = (
1331
+ f"Replaced {turns} turn{plural} with the summary, folded into"
1332
+ " the period summary (context budget reached)."
1333
+ )
1334
+ else:
1335
+ result = f"Replaced {turns} turn{plural} with the summary."
1336
+ extra: dict[str, Any] = {
1337
+ "source_tokens": self._chars_to_tokens(chunk.source_chars, ledger),
1338
+ "summary_tokens": (
1339
+ self._chars_to_tokens(chunk.summary_chars, ledger)
1340
+ if chunk.summary_chars is not None
1341
+ else None
1342
+ ),
1343
+ }
1344
+ # Show the context estimate once per swap, on its last trace.
1345
+ if context_tokens is not None and index == len(swapped) - 1:
1346
+ extra["context_tokens"] = context_tokens
1347
+ extra["context_budget"] = self._budget
1348
+ extra["context_estimated"] = True
1349
+ self._announce(chunk, "success", result=result, forced=forced, extra=extra)
1350
+
1351
+ def _collapse_frozen_region(self) -> bool:
1352
+ """Fold the whole frozen region into recall-only eras (hard-threshold
1353
+ L3). Every existing era becomes recall-only and the loose chunks fold
1354
+ into one new recall-only era (a later fold appends another era, so
1355
+ ordinals and step order stay monotonic). Returns whether anything
1356
+ changed — False means the region is already fully folded, so the
1357
+ caller can leave the frozen blocks untouched (byte-stable)."""
1358
+ changed = False
1359
+ for era in self._eras:
1360
+ if not era.recall_only:
1361
+ era.recall_only = True
1362
+ changed = True
1363
+ if self._chunks:
1364
+ self._era_counter += 1
1365
+ self._eras.append(
1366
+ EraState(ordinal=self._era_counter, chunks=self._chunks, recall_only=True)
1367
+ )
1368
+ self._chunks = []
1369
+ changed = True
1370
+ return changed
1371
+
1372
+ @staticmethod
1373
+ def _chars_to_tokens(chars: int, ledger: Any = None) -> int:
1374
+ """Characters → tokens through the ledger's session-calibrated ratio
1375
+ (:meth:`TranscriptLedger.chars_to_tokens`); ``chars // 4`` without a
1376
+ ledger. Identical to ``// 4`` at the default ratio."""
1377
+ convert = getattr(ledger, "chars_to_tokens", None)
1378
+ if callable(convert):
1379
+ try:
1380
+ return int(convert(chars))
1381
+ except (TypeError, ValueError, ZeroDivisionError):
1382
+ pass
1383
+ return max(0, int(chars)) // 4
1384
+
1385
+ @staticmethod
1386
+ def _replacement_chars(chunk: ChunkState, *, folded: bool = False) -> int:
1387
+ """Characters a swapped chunk contributes in place of its turns: the
1388
+ full block when ready and rendered as a chunk block, only the minimal
1389
+ index when still pending or folded into a recall-only era."""
1390
+ if chunk.status == "ready" and not folded:
1391
+ return len(render_chunk_block(chunk))
1392
+ return len(chunk.minimal_index or "")
1393
+
1394
+ def _estimate_context_after(
1395
+ self, base_tokens: int | None, entries: list[dict[str, Any]], ledger: Any = None
1396
+ ) -> int | None:
1397
+ """Estimated prompt size after swapping ``entries`` as L2 blocks."""
1398
+ if base_tokens is None:
1399
+ return None
1400
+ delta = 0
1401
+ for entry in entries:
1402
+ chunk = entry["chunk"]
1403
+ if chunk is None:
1404
+ continue
1405
+ delta += self._chars_to_tokens(
1406
+ self._replacement_chars(chunk), ledger
1407
+ ) - self._chars_to_tokens(chunk.source_chars, ledger)
1408
+ return max(0, int(base_tokens) + delta)
1409
+
1410
+ def _announce_held(self, base_tokens: int, ledger: Any = None) -> None:
1411
+ """Once per chunk: its capsule is ready but the start gate holds the
1412
+ original text live. The timeline line stays ``running`` with a
1413
+ ``held`` note so the UI can say the summary is ready and waiting."""
1414
+ threshold = int(self._budget * self._start_ratio)
1415
+ for entry in self._awaiting:
1416
+ chunk = entry["chunk"]
1417
+ if chunk is None or chunk.status != "ready" or entry.get("held_announced"):
1418
+ continue
1419
+ entry["held_announced"] = True
1420
+ self._announce(
1421
+ chunk,
1422
+ "running",
1423
+ phase=COMPRESSION_PHASE_READY,
1424
+ note="held",
1425
+ extra={
1426
+ "summary_tokens": self._chars_to_tokens(len(render_chunk_block(chunk)), ledger),
1427
+ "context_tokens": int(base_tokens),
1428
+ "context_budget": self._budget,
1429
+ "swap_at_tokens": threshold,
1430
+ },
1431
+ )
1432
+
1433
+ def _partition(self, eligible: list[dict]) -> list[tuple[str | None, list[dict]]]:
1434
+ """Split eligible turns into consecutive same-hash segments.
1435
+
1436
+ A turn without a stamp (no recorded step — a reply without a tool
1437
+ call, a helper-only turn — or a step not stamped yet) joins the
1438
+ running segment. Leading unstamped turns have no running segment to
1439
+ join: they are held and become the head of the first stamped segment
1440
+ instead of forming a stampless segment of their own, which would
1441
+ close as a chunk with no step records (``_create_chunk`` returns
1442
+ ``None`` for it) and sit at the head of the awaiting queue.
1443
+ """
1444
+ segments: list[tuple[str | None, list[dict]]] = []
1445
+ current_hash: str | None = None
1446
+ current: list[dict] = []
1447
+ started = False
1448
+ for turn in eligible:
1449
+ key = turn.get("step_key")
1450
+ stamped = self._step_hashes.get(str(key)) if key is not None else None
1451
+ if stamped is None and not started:
1452
+ current.append(turn) # leading unstamped turns wait for a segment
1453
+ continue
1454
+ if stamped is None:
1455
+ stamped = current_hash # unknown stamps join the running segment
1456
+ if not started:
1457
+ started = True
1458
+ current_hash = stamped
1459
+ current.append(turn)
1460
+ elif stamped == current_hash:
1461
+ current.append(turn)
1462
+ else:
1463
+ segments.append((current_hash, current))
1464
+ current_hash = stamped
1465
+ current = [turn]
1466
+ if current:
1467
+ segments.append((current_hash, current))
1468
+ return segments
1469
+
1470
+ def _slices(self, seg_turns: list[dict]) -> list[list[dict]]:
1471
+ return [
1472
+ seg_turns[i : i + self._max_steps] for i in range(0, len(seg_turns), self._max_steps)
1473
+ ]
1474
+
1475
+ def _context_base_tokens(self, ledger=None) -> int | None:
1476
+ """The live context base for the start/soft/hard thresholds.
1477
+
1478
+ An injected ``meter_getter`` wins (tests / custom wiring); otherwise
1479
+ the base is the owning operator's last measured prompt size recorded
1480
+ on the ledger (:meth:`TranscriptLedger.record_prompt_tokens`). The
1481
+ session-wide token meter is deliberately NOT consulted: its
1482
+ ``last_prompt_tokens`` is overwritten by whichever agent called last
1483
+ (Planner, Validator, Checker, sub-agents, lenses), so it does not
1484
+ describe the operator's context at all.
1485
+ """
1486
+ if self._meter_getter is not None:
1487
+ try:
1488
+ return self._meter_getter()
1489
+ except Exception:
1490
+ return None
1491
+ try:
1492
+ last = getattr(ledger, "last_prompt_tokens", None)
1493
+ return int(last) if last else None
1494
+ except (TypeError, ValueError):
1495
+ return None
1496
+
1497
+ # ------------------------------------------------------------------
1498
+ # Chunk creation
1499
+ # ------------------------------------------------------------------
1500
+
1501
+ def _load_steps_by_id(self) -> dict[str, dict]:
1502
+ steps: list[dict] = []
1503
+ try:
1504
+ if self._engine is not None:
1505
+ steps = self._engine.get_agent_friendly_steps() or []
1506
+ except Exception as e:
1507
+ logger.error(f"Failed to load steps for chunking: {e}")
1508
+ return {str(s.get("step_id")): s for s in steps}
1509
+
1510
+ def _session_start(self) -> float | None:
1511
+ return getattr(self._engine, "session_start_time", None)
1512
+
1513
+ def _create_chunk(
1514
+ self,
1515
+ slice_turns: list[dict],
1516
+ seg_hash: str | None,
1517
+ steps_by_id: dict[str, dict],
1518
+ ledger: Any = None,
1519
+ *,
1520
+ trigger: str | None = None,
1521
+ ) -> ChunkState | None:
1522
+ # A turn carries every step id it recorded (``step_keys``, Flash
1523
+ # multi-action turns); older ledgers only expose ``step_key``. Each
1524
+ # turn's post-scrub transcript (what the operator saw) rides along
1525
+ # into the capsule payload when a live ledger is available.
1526
+ step_keys: list[str] = []
1527
+ turn_records: list[dict[str, Any]] = []
1528
+ for turn in slice_turns:
1529
+ keys = turn.get("step_keys") or ([turn.get("step_key")] if turn.get("step_key") else [])
1530
+ turn_keys: list[str] = []
1531
+ for key in keys:
1532
+ if key and str(key) not in step_keys:
1533
+ step_keys.append(str(key))
1534
+ if key and str(key) in steps_by_id and str(key) not in turn_keys:
1535
+ turn_keys.append(str(key))
1536
+ transcript = None
1537
+ if ledger is not None:
1538
+ try:
1539
+ transcript = ledger.turn_transcript(turn)
1540
+ except Exception as e:
1541
+ logger.warning(f"Turn transcript unavailable for chunk capsule: {e}")
1542
+ turn_records.append({"step_ids": turn_keys, "transcript": transcript})
1543
+ steps = [steps_by_id[k] for k in step_keys if k in steps_by_id]
1544
+ steps.sort(key=lambda s: s.get("step_number") or 0)
1545
+ if not steps:
1546
+ logger.warning(
1547
+ "History chunk skipped: no DataEngine step records for the"
1548
+ f" selected turns ({len(slice_turns)} turns)."
1549
+ )
1550
+ return None
1551
+
1552
+ session_start = self._session_start()
1553
+ start_step, end_step = steps[0], steps[-1]
1554
+ self._chunk_counter += 1
1555
+ chunk = ChunkState(
1556
+ ordinal=self._chunk_counter,
1557
+ start_step_number=int(start_step.get("step_number")),
1558
+ end_step_number=int(end_step.get("step_number")),
1559
+ start_step_id=str(start_step.get("step_id")),
1560
+ end_step_id=str(end_step.get("step_id")),
1561
+ source_step_ids=[str(s.get("step_id")) for s in steps],
1562
+ subgoal_hash=seg_hash,
1563
+ milestone_label=self._resolve_label(seg_hash),
1564
+ start_offset=step_offset_label(start_step, session_start),
1565
+ end_offset=step_offset_label(end_step, session_start),
1566
+ band3=build_action_ledger(steps, session_start),
1567
+ minimal_index=build_action_ledger(steps, session_start, minimal=True),
1568
+ user_lines=[line for line in (injected_instruction_line(s) for s in steps) if line],
1569
+ trigger=trigger,
1570
+ )
1571
+ # Ready gating: the caller queues the chunk as awaiting — it only
1572
+ # enters self._chunks (and the frozen region) once its capsule is
1573
+ # ready, or through the hard-threshold emergency swap.
1574
+ self._persist(chunk)
1575
+ self._dispatch_capsule(chunk, steps, session_start, turn_records)
1576
+ # Attach the trace to the step allocated before this prompt render.
1577
+ chunk.trace_id = uuid4()
1578
+ chunk.trace_step_id = getattr(self._engine, "current_step_id", None)
1579
+ chunk.announced_at = time.time()
1580
+ if ledger is not None:
1581
+ try:
1582
+ chunk.source_chars = int(ledger.turn_text_chars(slice_turns))
1583
+ except Exception as e:
1584
+ logger.debug(f"Chunk source size unavailable for {chunk.capsule_key}: {e}")
1585
+ self._announce(
1586
+ chunk,
1587
+ "running",
1588
+ extra={"source_tokens": self._chars_to_tokens(chunk.source_chars, ledger)},
1589
+ )
1590
+ return chunk
1591
+
1592
+ def _announce(
1593
+ self,
1594
+ chunk: ChunkState,
1595
+ status: str,
1596
+ *,
1597
+ result: str | None = None,
1598
+ error: str | None = None,
1599
+ note: str | None = None,
1600
+ forced: bool = False,
1601
+ extra: dict[str, Any] | None = None,
1602
+ phase: str | None = None,
1603
+ ) -> None:
1604
+ """Update the chunk's timeline trace without interrupting compression.
1605
+
1606
+ ``phase`` is the machine-readable plain-language phase written to
1607
+ ``args.phase`` (one of ``COMPRESSION_PHASES``). It defaults from the
1608
+ trace ``status`` — running → summarizing, success → applied,
1609
+ failed → failed — and the held path passes ``ready`` explicitly.
1610
+ """
1611
+ if chunk.trace_id is None:
1612
+ return
1613
+ engine = self._engine
1614
+ if engine is None or not hasattr(engine, "record_trace"):
1615
+ return
1616
+ if phase is None:
1617
+ phase = _STATUS_TO_PHASE.get(status, COMPRESSION_PHASE_SUMMARIZING)
1618
+ args: dict[str, Any] = {
1619
+ "start_step": chunk.start_step_number,
1620
+ "end_step": chunk.end_step_number,
1621
+ "steps": len(chunk.source_step_ids),
1622
+ "milestone": chunk.milestone_label,
1623
+ "trigger": chunk.trigger,
1624
+ "phase": phase,
1625
+ }
1626
+ if note:
1627
+ args["note"] = note
1628
+ if forced:
1629
+ args["forced"] = True
1630
+ if extra:
1631
+ args.update(extra)
1632
+ payload: dict[str, Any] = {"args": args}
1633
+ if result is not None:
1634
+ payload["result"] = result
1635
+ if error is not None:
1636
+ payload["error"] = error
1637
+ duration = None
1638
+ if status != "running" and chunk.announced_at is not None:
1639
+ duration = max(0.0, time.time() - chunk.announced_at)
1640
+ try:
1641
+ engine.record_trace(
1642
+ type="tool",
1643
+ name=COMPRESSION_TRACE_NAME,
1644
+ payload=payload,
1645
+ step_id=chunk.trace_step_id,
1646
+ status=status,
1647
+ duration=duration,
1648
+ trace_id=chunk.trace_id,
1649
+ )
1650
+ except Exception as e:
1651
+ logger.debug(f"Compression announcement skipped for {chunk.capsule_key}: {e}")
1652
+
1653
+ def _on_capsule_failed(self, key: JobKey) -> None:
1654
+ """Mark the trace failed; keep the chunk pending for a later retry.
1655
+
1656
+ Only a chunk still awaiting can be retried (:meth:`_redispatch_failed_capsules`
1657
+ walks the awaiting queue); a pending chunk that was already force-swapped
1658
+ keeps its swap trace, since no retry follows for it.
1659
+ """
1660
+ for entry in self._awaiting:
1661
+ chunk = entry["chunk"]
1662
+ if chunk is None or chunk.status != "pending" or chunk.capsule_key != key:
1663
+ continue
1664
+ self._announce(
1665
+ chunk,
1666
+ "failed",
1667
+ error="Summary attempts exhausted; the full record is kept and"
1668
+ " the summary will be retried later.",
1669
+ )
1670
+ return
1671
+
1672
+ @staticmethod
1673
+ def _step_payload(step: dict, session_start: float | None) -> dict[str, Any]:
1674
+ """Mechanical per-step facts handed to the capsule lens."""
1675
+ return {
1676
+ "step_number": step.get("step_number"),
1677
+ "offset": step_offset_label(step, session_start),
1678
+ "action": _action_phrase(step),
1679
+ "outcome": _result_phrase(step.get("last_execution_result")),
1680
+ "visual_summary": step.get("summary"),
1681
+ # Fallback only: rendered when a turn carries no transcript.
1682
+ "thinking_excerpt": (step.get("operator_raw_thinking") or "")[:1200] or None,
1683
+ "note_writes": extract_note_writes(step),
1684
+ "injected_instruction": (step.get("extra_metadata") or {}).get("injected_instruction"),
1685
+ }
1686
+
1687
+ def _dispatch_capsule(
1688
+ self,
1689
+ chunk: ChunkState,
1690
+ steps: list[dict],
1691
+ session_start: float | None,
1692
+ turn_records: list[dict[str, Any]] | None = None,
1693
+ ) -> None:
1694
+ """Submit the capsule job: flat step facts plus, per committed turn,
1695
+ the transcript the operator saw (the text this chunk will replace)."""
1696
+ payload_steps = [self._step_payload(step, session_start) for step in steps]
1697
+ by_id = {str(step.get("step_id")): entry for step, entry in zip(steps, payload_steps)}
1698
+ payload_turns: list[dict[str, Any]] = []
1699
+ for record in turn_records or []:
1700
+ turn_steps = [by_id[k] for k in record.get("step_ids") or [] if k in by_id]
1701
+ transcript = record.get("transcript")
1702
+ if not turn_steps and not transcript:
1703
+ continue
1704
+ payload_turns.append({"steps": turn_steps, "transcript": transcript})
1705
+ payload = {
1706
+ "start_step": chunk.start_step_number,
1707
+ "end_step": chunk.end_step_number,
1708
+ "step_number": chunk.start_step_number, # service log display
1709
+ "milestone_label": chunk.milestone_label,
1710
+ "steps": payload_steps,
1711
+ }
1712
+ if any(t.get("transcript") for t in payload_turns):
1713
+ payload["turns"] = payload_turns
1714
+ try:
1715
+ self._capsule_service.submit(chunk.capsule_key, payload)
1716
+ except Exception as e:
1717
+ logger.error(f"Failed to dispatch chunk capsule {chunk.capsule_key}: {e}")
1718
+
1719
+ def _resolve_label(self, seg_hash: str | None) -> str | None:
1720
+ if not seg_hash or seg_hash == "default":
1721
+ return None
1722
+ try:
1723
+ from artemis.utils.notes import get_note_file_path
1724
+ from artemis.utils.plan_grammar import parse_plan, subgoal_hash
1725
+ from artemis.utils.task_tree import get_all_subgoal_aliases
1726
+
1727
+ base_dir = getattr(self._engine, "base_dir", None)
1728
+ if not base_dir:
1729
+ return None
1730
+ plan_path = get_note_file_path(base_dir, "task_plan")
1731
+ if not plan_path.exists():
1732
+ return None
1733
+ for item in parse_plan(plan_path.read_text(encoding="utf-8")).items:
1734
+ item_hash = subgoal_hash(item.text)
1735
+ if item_hash == seg_hash or seg_hash in get_all_subgoal_aliases(
1736
+ item_hash, base_dir
1737
+ ):
1738
+ return item.text
1739
+ except Exception:
1740
+ return None
1741
+ return None
1742
+
1743
+ # ------------------------------------------------------------------
1744
+ # Capsule harvest / persistence
1745
+ # ------------------------------------------------------------------
1746
+
1747
+ def _harvest_capsules(self) -> None:
1748
+ """Fold ready capsule results into pending chunk mirrors (event-time)."""
1749
+ for chunk in self._all_chunks():
1750
+ if chunk.status != "pending":
1751
+ continue
1752
+ raw = self._capsule_service.get_summary(chunk.capsule_key)
1753
+ if not raw:
1754
+ continue
1755
+ try:
1756
+ band1 = json.loads(raw)
1757
+ except Exception:
1758
+ continue
1759
+ chunk.band1 = band1
1760
+ chunk.band2 = render_band2(band1)
1761
+ chunk.status = "ready"
1762
+ chunk.version += 1
1763
+ self._persist(chunk)
1764
+
1765
+ def _persist(self, chunk: ChunkState) -> None:
1766
+ try:
1767
+ if self._engine is None or not hasattr(self._engine, "record_history_chunk"):
1768
+ return
1769
+ band1 = dict(chunk.band1)
1770
+ if chunk.annotations:
1771
+ band1["annotations"] = chunk.annotations
1772
+ self._engine.record_history_chunk(
1773
+ start_step_number=chunk.start_step_number,
1774
+ end_step_number=chunk.end_step_number,
1775
+ version=chunk.version,
1776
+ status=chunk.status,
1777
+ start_step_id=chunk.start_step_id,
1778
+ end_step_id=chunk.end_step_id,
1779
+ source_step_ids=chunk.source_step_ids,
1780
+ subgoal_hash=chunk.subgoal_hash,
1781
+ band1=band1,
1782
+ band2=chunk.band2,
1783
+ band3=chunk.band3,
1784
+ rendered_text=render_chunk_block(chunk),
1785
+ )
1786
+ except Exception as e:
1787
+ logger.error(f"Failed to persist history chunk: {e}")
1788
+
1789
+ # ------------------------------------------------------------------
1790
+ # Era folding + frozen-region rendering
1791
+ # ------------------------------------------------------------------
1792
+
1793
+ def _fold_eras(self) -> None:
1794
+ """Count overflow: the oldest loose chunks merge into an era; the
1795
+ oldest ledger-bearing eras beyond ``max_eras`` become recall-only
1796
+ (eras already folded by a hard event do not count against the cap)."""
1797
+ overflow = len(self._chunks) - self._max_chunks
1798
+ if overflow > 0:
1799
+ folded, self._chunks = self._chunks[:overflow], self._chunks[overflow:]
1800
+ self._era_counter += 1
1801
+ self._eras.append(EraState(ordinal=self._era_counter, chunks=folded))
1802
+ with_ledger = [era for era in self._eras if not era.recall_only]
1803
+ era_overflow = len(with_ledger) - self._max_eras
1804
+ if era_overflow > 0:
1805
+ for era in with_ledger[:era_overflow]:
1806
+ era.recall_only = True
1807
+ logger.info(
1808
+ f"Era {era.ordinal} overflowed to the recall-only period"
1809
+ f" paragraph (Steps {era.start_step_number}–"
1810
+ f"{era.end_step_number})."
1811
+ )
1812
+
1813
+ def _render_frozen_blocks(self) -> list[BaseMessage]:
1814
+ """Frozen region, oldest first: era blocks (recall-only or merged),
1815
+ then the loose chunk blocks."""
1816
+ blocks: list[BaseMessage] = []
1817
+ for era in self._eras:
1818
+ blocks.append(HumanMessage(content=[{"type": "text", "text": render_era_block(era)}]))
1819
+ for chunk in self._chunks:
1820
+ blocks.append(
1821
+ HumanMessage(content=[{"type": "text", "text": render_chunk_block(chunk)}])
1822
+ )
1823
+ return blocks
1824
+
1825
+ # ------------------------------------------------------------------
1826
+ # Draining
1827
+ # ------------------------------------------------------------------
1828
+
1829
+ async def flush(self, timeout_seconds: float | None = None) -> None:
1830
+ """Drain in-flight capsule jobs, then persist any harvested results.
1831
+
1832
+ Frozen transcript text is deliberately NOT re-rendered here (deep
1833
+ mutations only happen at compression events); this keeps the DB copy
1834
+ complete at session end.
1835
+ """
1836
+ try:
1837
+ await self._capsule_service.flush(timeout_seconds)
1838
+ except Exception as exc:
1839
+ logger.debug(f"Capsule service flush failed; skipped: {exc}", exc_info=True)
1840
+ try:
1841
+ self._harvest_capsules()
1842
+ except Exception as e:
1843
+ logger.debug(f"Chunk capsule harvest at flush skipped: {e}")