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,798 @@
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
+ """Session message ledger shared by the Pro operator and Flash runner.
16
+
17
+ Messages are organized into four regions:
18
+
19
+ - **S (stable prefix)**: the static system message — byte-identical for the
20
+ whole session (the plan+history section is moved out by the template split).
21
+ - **F (frozen history)**: restored history and compressed chunks.
22
+ - **A (active window)**: raw per-turn messages, append-only. Each committed
23
+ turn contributes its tail observation HumanMessage, the operator AIMessages
24
+ (tool_calls and native thinking preserved by reference), the in-turn
25
+ ToolMessages, and the turn's result message (every turn on the Pro path;
26
+ only when an action failed on the Flash path). The
27
+ scrub edge (:class:`~artemis.agents.flash.context_compressor.ScrubEdgeCompressor`)
28
+ has two depths: a shallow text edge (UI list, plan recitation and ephemeral
29
+ per-turn blocks leave as soon as a newer observation exists) and a
30
+ screenshot edge that follows the occupancy tier (relaxed below the start
31
+ gate, tightened past it), where the screenshot is replaced in place by its
32
+ visual summary. Every rewrite happens once and is never undone; everything
33
+ newer is byte-identical to what the model was first shown. Messages are
34
+ never removed or reordered, so tool-call/response pairs are never split.
35
+ - **T (current tail)**: built fresh every turn by the operator; passed to
36
+ :meth:`render` and only enters A when the turn is committed.
37
+
38
+ All timestamps inside the transcript use the session-start offset ``T+mm:ss``
39
+ (byte-stable once frozen); "ago" wording is reserved for the auxiliary agents'
40
+ per-call compiled views. When the caller supplies the DataEngine
41
+ ``session_start`` epoch, the ledger clock is anchored to it so the tail
42
+ offsets, the chunk ledger lines and the video analyzer's action timeline all
43
+ share one origin.
44
+
45
+ Profiles: the Pro operator (one committed turn per graph step) and the Flash
46
+ runner (one committed turn per reactive turn; a multi-action turn registers
47
+ every recorded step id via ``extra_step_keys`` so the chunk ledger lists each
48
+ step) build their prompts from the same ledger.
49
+ """
50
+
51
+ import json
52
+ import time
53
+ from typing import Any, Callable
54
+
55
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
56
+
57
+ from artemis.memory.step_memory import StepMemoryService
58
+ from third_party.mobile_use.utils.logger import get_logger
59
+
60
+ logger = get_logger(__name__)
61
+
62
+ #: Marker for the per-turn task-plan recitation block in the observation tail.
63
+ PLAN_RECITATION_MARKER = "--- Task Plan (recited) ---"
64
+
65
+ #: Marker of the Pro observation's UI element list block (see
66
+ #: ``ObservationPromptComponent``).
67
+ PRO_UI_LIST_MARKER = "--- Visible UI Elements ---"
68
+
69
+ #: Marker prefix of a committed turn's validator result message.
70
+ EXECUTION_RESULT_MARKER = "--- Action Execution Result"
71
+
72
+ #: Header prefix of the cold-start restored-history block.
73
+ RESTORED_HISTORY_HEADER = "[Restored history]"
74
+
75
+ #: ``HumanMessage.additional_kwargs`` key listing the indices of content blocks that
76
+ #: are only meaningful for the turn they were built for (reminders, hints, user
77
+ #: guidance, per-turn notices). The scrub edge deletes them at its text edge, so
78
+ #: they never reach the frozen region or a chunk capsule. The indices always
79
+ #: describe the message's *current* block layout: a rewrite that removes blocks
80
+ #: before the text edge (the screenshot swap when the image edge is shallower)
81
+ #: remaps the surviving indices in place, and the key is dropped once the blocks
82
+ #: are deleted. ``additional_kwargs`` is never sent to the provider, so the
83
+ #: marking costs the model nothing.
84
+ EPHEMERAL_BLOCKS_KEY = "ephemeral_blocks"
85
+
86
+
87
+ def mark_ephemeral(message: BaseMessage, indices) -> None:
88
+ """Flag content blocks of ``message`` (by index) as ephemeral, see
89
+ :data:`EPHEMERAL_BLOCKS_KEY`."""
90
+ existing = list(message.additional_kwargs.get(EPHEMERAL_BLOCKS_KEY, []))
91
+ for index in indices:
92
+ index = int(index)
93
+ if index not in existing:
94
+ existing.append(index)
95
+ message.additional_kwargs[EPHEMERAL_BLOCKS_KEY] = existing
96
+
97
+
98
+ def format_session_offset(seconds: float) -> str:
99
+ """Render a session-start offset as ``T+mm:ss`` (minutes never wrap)."""
100
+ total = max(0, int(seconds))
101
+ return f"T+{total // 60:02d}:{total % 60:02d}"
102
+
103
+
104
+ #: Placeholder for an image block that is still inside the scrub edge's
105
+ #: pending-grace window when a turn transcript is rendered.
106
+ SCREENSHOT_PLACEHOLDER = "[screenshot]"
107
+
108
+ _ROLE_LABELS = {
109
+ "human": "observation",
110
+ "ai": "operator",
111
+ "tool": "tool result",
112
+ "system": "system",
113
+ }
114
+
115
+
116
+ def _render_content_text(content: Any) -> str:
117
+ """Flatten message content to plain text.
118
+
119
+ Text blocks pass through verbatim, native thinking blocks are labelled,
120
+ image blocks become :data:`SCREENSHOT_PLACEHOLDER`.
121
+ """
122
+ if content is None:
123
+ return ""
124
+ if isinstance(content, str):
125
+ return content
126
+ if not isinstance(content, list):
127
+ return str(content)
128
+ parts: list[str] = []
129
+ for block in content:
130
+ if isinstance(block, str):
131
+ parts.append(block)
132
+ elif not isinstance(block, dict):
133
+ parts.append(str(block))
134
+ elif block.get("type") == "text":
135
+ parts.append(str(block.get("text", "")))
136
+ elif block.get("type") == "thinking":
137
+ parts.append(f"(thinking) {block.get('thinking', '')}")
138
+ elif block.get("type") in ("image_url", "image"):
139
+ parts.append(SCREENSHOT_PLACEHOLDER)
140
+ else:
141
+ parts.append(json.dumps(block, ensure_ascii=False, default=str))
142
+ return "\n".join(part for part in parts if part)
143
+
144
+
145
+ def render_turn_transcript(messages: list[BaseMessage]) -> str:
146
+ """Mechanical plain-text rendering of one committed turn's messages.
147
+
148
+ Lossless with respect to what the operator model was shown: every text
149
+ block, native thinking block, tool call (name and arguments) and tool
150
+ result is carried verbatim, in message order. Image blocks — present only
151
+ while a screenshot is still inside the scrub edge's pending-grace window —
152
+ become a placeholder; a resolved visual-transition summary is already
153
+ plain text. The segment capsule uses this transcript as its source when
154
+ compressing the turn.
155
+
156
+ A tool result is labelled with its tool's name, resolved through the
157
+ turn's own preceding tool calls when the message carries no name (the Pro
158
+ operator's ToolMessages don't); the opaque call id is shown only when no
159
+ name can be resolved.
160
+ """
161
+ lines: list[str] = []
162
+ names_by_id: dict[str, str] = {}
163
+ for msg in messages:
164
+ kind = str(getattr(msg, "type", "") or "message")
165
+ if isinstance(msg, AIMessage):
166
+ kind = "ai" # streamed replies report type "AIMessageChunk"
167
+ role = _ROLE_LABELS.get(kind, kind)
168
+ if kind == "tool":
169
+ call_id = getattr(msg, "tool_call_id", None)
170
+ name = getattr(msg, "name", None) or names_by_id.get(str(call_id)) or call_id
171
+ if name:
172
+ role = f"{role} {name}"
173
+ status = getattr(msg, "status", None)
174
+ if status and status != "success":
175
+ role = f"{role} ({status})"
176
+ body = _render_content_text(getattr(msg, "content", None))
177
+ if body:
178
+ lines.append(f"[{role}]\n{body}")
179
+ for call in getattr(msg, "tool_calls", None) or []:
180
+ if not isinstance(call, dict):
181
+ continue
182
+ if call.get("id") and call.get("name"):
183
+ names_by_id[str(call["id"])] = str(call["name"])
184
+ args = json.dumps(call.get("args") or {}, ensure_ascii=False, default=str)
185
+ lines.append(f"[tool call] {call.get('name')}({args})")
186
+ return "\n".join(lines)
187
+
188
+
189
+ #: Tokens charged per image block in the provider-less prompt estimate
190
+ #: (Gemini's flat per-image cost; the Flash runner uses the same figure).
191
+ IMAGE_BLOCK_TOKENS = 258
192
+
193
+ #: Characters per token assumed before any calibrated measurement.
194
+ DEFAULT_CHARS_PER_TOKEN = 4.0
195
+
196
+ #: Bounds of the calibrated chars-per-token ratio: ~1 for CJK-heavy prompts,
197
+ #: ~4 for English prose; anything outside is a measurement artefact.
198
+ _CHARS_PER_TOKEN_BOUNDS = (1.0, 6.0)
199
+
200
+ #: Smallest text growth (characters) between two consecutive measured prompts
201
+ #: for their difference to count as a calibration sample (see
202
+ #: :meth:`TranscriptLedger.record_prompt_tokens`). Below this the provider's
203
+ #: rounding and a few hundred tokens of turn-to-turn jitter dominate the
204
+ #: quotient.
205
+ MIN_CALIBRATION_DELTA_CHARS = 2000
206
+
207
+
208
+ def measure_prompt_content(messages: list[Any]) -> tuple[int, int]:
209
+ """Count ``(text_chars, image_count)`` over a prompt's messages.
210
+
211
+ The walk covers what the provider tokenizes as text: string content in
212
+ full; in list content the ``text`` blocks by their text, ``image_url`` /
213
+ ``image`` blocks as one image each, any other block by its ``str()``; and
214
+ each message's ``tool_calls`` by the JSON of their arguments (an
215
+ AIMessage's calls are sent back to the model verbatim, and the ledger's
216
+ turn transcripts size them the same way).
217
+ """
218
+ text_chars = 0
219
+ image_count = 0
220
+ for msg in messages:
221
+ content = getattr(msg, "content", None)
222
+ if isinstance(content, str):
223
+ text_chars += len(content)
224
+ elif isinstance(content, list):
225
+ for block in content:
226
+ if isinstance(block, dict):
227
+ if block.get("type") in ("image_url", "image") or "image_url" in block:
228
+ image_count += 1
229
+ elif block.get("type") == "text":
230
+ text_chars += len(str(block.get("text", "")))
231
+ else:
232
+ text_chars += len(str(block))
233
+ else:
234
+ text_chars += len(str(block))
235
+ elif content is not None:
236
+ text_chars += len(str(content))
237
+ for call in getattr(msg, "tool_calls", None) or []:
238
+ if isinstance(call, dict):
239
+ text_chars += len(
240
+ json.dumps(call.get("args") or {}, ensure_ascii=False, default=str)
241
+ )
242
+ return text_chars, image_count
243
+
244
+
245
+ def estimate_prompt_tokens(messages: list[Any]) -> int:
246
+ """Provider-less prompt size estimate: ``text_chars // 4`` plus
247
+ :data:`IMAGE_BLOCK_TOKENS` per image block (see
248
+ :func:`measure_prompt_content` for what counts). Used as the context base
249
+ when a provider reports no usage metadata; never below 1."""
250
+ text_chars, image_count = measure_prompt_content(messages)
251
+ return max(1, text_chars // 4 + image_count * IMAGE_BLOCK_TOKENS)
252
+
253
+
254
+ class TranscriptLedger:
255
+ """Append-only four-region message ledger for one Pro session.
256
+
257
+ The ledger owns no LLM calls: summary lookups go through the shared
258
+ :class:`StepMemoryService` (``ctx.step_memory``), whose visual-transition
259
+ lens is fed by the Pro SummarizerNode each step.
260
+ """
261
+
262
+ def __init__(
263
+ self,
264
+ *,
265
+ step_memory: StepMemoryService | None = None,
266
+ prune_history_xml: bool = True,
267
+ image_scrub_depth: int = 3,
268
+ pending_grace_steps: int = 3,
269
+ xml_scrub_depth: int | None = None,
270
+ image_scrub_depth_relaxed: int | None = None,
271
+ context_budget_tokens: int | None = None,
272
+ start_ratio: float | None = None,
273
+ clock: Callable[[], float] | None = None,
274
+ session_start: float | None = None,
275
+ ):
276
+ # ``session_start`` is an epoch-seconds origin (the DataEngine's
277
+ # ``session_start_time``); with it the ledger reads the wall clock so
278
+ # every ``T+mm:ss`` label is relative to the recorded session start.
279
+ # Without it the ledger keeps its own monotonic origin.
280
+ if session_start is not None:
281
+ self._clock = clock or time.time
282
+ self._session_start = float(session_start)
283
+ else:
284
+ self._clock = clock or time.monotonic
285
+ self._session_start = self._clock()
286
+
287
+ self._static: list[BaseMessage] = []
288
+ self._restored: list[BaseMessage] = []
289
+ self._active: list[BaseMessage] = []
290
+ self._staged: list[BaseMessage] | None = None
291
+ self._turn_count = 0
292
+
293
+ # L2/L3 chunk compression. ``_turns`` records each committed
294
+ # turn's message span in ``_active``; a compression event advances
295
+ # ``_active_start`` past whole turns (never splitting tool-call pairs)
296
+ # and replaces the frozen chunk blocks wholesale. The underlying
297
+ # ``_active`` list stays append-only so scrub-edge indices stay valid.
298
+ self._turns: list[dict] = []
299
+ self._chunked_turn_count = 0
300
+ self._active_start = 0
301
+ self._frozen_blocks: list[BaseMessage] = []
302
+ self._chunker: Any | None = None
303
+
304
+ # The owning operator's last measured prompt size (tokens). This is
305
+ # the live context base the chunker's start/soft/hard thresholds read;
306
+ # only the operator's own calls write it, so Planner/Checker/lens
307
+ # calls sharing the session can never masquerade as the operator's
308
+ # context (see HistoryChunkManager._context_base_tokens).
309
+ self._last_prompt_tokens: int | None = None
310
+ # Session-calibrated characters-per-token ratio (see
311
+ # record_prompt_tokens): the chunker converts transcript characters
312
+ # to tokens through it, so CJK-heavy sessions (~1 char/token on
313
+ # Gemini) are not under-estimated 4x by the fixed //4 default.
314
+ # Calibrated differentially: ``_calibration_sample`` is the previous
315
+ # measured ``(text_chars, image_count, prompt_tokens)`` and the
316
+ # pooled deltas between consecutive same-image-count prompts give
317
+ # the session's single best ratio.
318
+ self._chars_per_token: float = DEFAULT_CHARS_PER_TOKEN
319
+ self._calibration_sample: tuple[int, int, int] | None = None
320
+ self._pooled_delta_chars = 0
321
+ self._pooled_delta_tokens = 0
322
+
323
+ # Whether the last committed turn carried no visible reasoning text (bare
324
+ # tool calls / thinking only). Set by ``commit_staged``; both runners read
325
+ # it to decide whether to attach the shared reasoning reminder.
326
+ self._last_turn_silent: bool = False
327
+
328
+ # Text edge (default 1): UI list, plan recitation and ephemeral blocks
329
+ # leave as soon as a newer observation exists; screenshots follow K.
330
+ self._xml_scrub_depth = xml_scrub_depth
331
+
332
+ # Occupancy-driven screenshot depth. Below the start gate
333
+ # (``last_prompt_tokens < budget * start_ratio``, or no measurement
334
+ # yet) the scrub edge sits at the relaxed depth so the model keeps
335
+ # more raw screenshots while nothing is being replaced; from the
336
+ # start gate up it tightens to ``image_scrub_depth``. Scrubbing is
337
+ # irreversible, so a change of depth never rewrites a message:
338
+ # tightening scrubs the extra depths once, relaxing lets new images
339
+ # live longer. See :attr:`effective_image_scrub_depth`.
340
+ self._image_scrub_depth = max(1, int(image_scrub_depth))
341
+ self._image_scrub_depth_relaxed = (
342
+ max(self._image_scrub_depth, int(image_scrub_depth_relaxed))
343
+ if image_scrub_depth_relaxed is not None
344
+ else self._image_scrub_depth
345
+ )
346
+ self._context_budget_tokens = int(context_budget_tokens) if context_budget_tokens else None
347
+ self._start_ratio = float(start_ratio) if start_ratio is not None else None
348
+
349
+ # id(message) -> summary-job key (DataEngine step id). A side map keeps
350
+ # the key out of the serialized message payload entirely.
351
+ self._step_keys: dict[int, str] = {}
352
+
353
+ # Imported here, not at module level: context_compressor imports
354
+ # artemis.memory.step_memory, so a top-level import forms a cycle
355
+ # whose failure depends on which side is imported first.
356
+ from artemis.agents.flash.context_compressor import ScrubEdgeCompressor
357
+
358
+ self._compressor = ScrubEdgeCompressor(
359
+ summarizer=step_memory,
360
+ prune_history_xml=prune_history_xml,
361
+ image_scrub_depth=image_scrub_depth,
362
+ pending_grace_steps=pending_grace_steps,
363
+ xml_scrub_depth=xml_scrub_depth,
364
+ summary_key_getter=lambda msg: self._step_keys.get(id(msg)),
365
+ strip_markers=(PRO_UI_LIST_MARKER, PLAN_RECITATION_MARKER),
366
+ tail_offset=1,
367
+ )
368
+
369
+ # ------------------------------------------------------------------
370
+ # Session clock
371
+ # ------------------------------------------------------------------
372
+
373
+ def elapsed_seconds(self) -> float:
374
+ return max(0.0, self._clock() - self._session_start)
375
+
376
+ def elapsed_label(self) -> str:
377
+ """The current session offset as ``T+mm:ss``."""
378
+ return format_session_offset(self.elapsed_seconds())
379
+
380
+ # ------------------------------------------------------------------
381
+ # Context base (operator-measured prompt size)
382
+ # ------------------------------------------------------------------
383
+
384
+ @property
385
+ def last_prompt_tokens(self) -> int | None:
386
+ """The operator's last measured prompt size, or None before the
387
+ first measured call (or when the provider reports no usage)."""
388
+ return self._last_prompt_tokens
389
+
390
+ @property
391
+ def last_turn_silent(self) -> bool:
392
+ """True when the last committed turn had no visible reasoning text.
393
+
394
+ A turn is silent when it carries at least one AI message and none of
395
+ them shows text: string content that is empty or whitespace, or list
396
+ content without a non-empty ``text`` block (native ``thinking`` blocks
397
+ are not visible text). A turn with no AI message at all is not silent.
398
+ """
399
+ return self._last_turn_silent
400
+
401
+ @staticmethod
402
+ def _ai_message_has_visible_text(message: BaseMessage) -> bool:
403
+ content = getattr(message, "content", None)
404
+ if isinstance(content, str):
405
+ return bool(content.strip())
406
+ if isinstance(content, list):
407
+ for block in content:
408
+ if isinstance(block, str):
409
+ if block.strip():
410
+ return True
411
+ elif isinstance(block, dict) and block.get("type") == "text":
412
+ if str(block.get("text") or "").strip():
413
+ return True
414
+ return False
415
+ return False
416
+
417
+ @classmethod
418
+ def _turn_is_silent(cls, messages: list[BaseMessage]) -> bool:
419
+ # Streamed replies are ``AIMessageChunk`` (type "AIMessageChunk"), so
420
+ # the class, not the type string, identifies the model's messages.
421
+ ai_messages = [m for m in messages if isinstance(m, AIMessage)]
422
+ if not ai_messages:
423
+ return False
424
+ return not any(cls._ai_message_has_visible_text(m) for m in ai_messages)
425
+
426
+ @property
427
+ def chars_per_token(self) -> float:
428
+ """Calibrated characters-per-token ratio (4.0 until a measured call
429
+ with its messages is recorded)."""
430
+ return self._chars_per_token
431
+
432
+ def chars_to_tokens(self, chars: int) -> int:
433
+ """Convert transcript characters to tokens through the calibrated
434
+ ratio (identical to ``chars // 4`` at the default ratio)."""
435
+ return int(max(0, int(chars)) / self._chars_per_token)
436
+
437
+ @property
438
+ def occupancy(self) -> float | None:
439
+ """``last_prompt_tokens / context_budget_tokens``, or None when either
440
+ is unknown."""
441
+ if self._last_prompt_tokens is None or not self._context_budget_tokens:
442
+ return None
443
+ return self._last_prompt_tokens / self._context_budget_tokens
444
+
445
+ @property
446
+ def below_start_gate(self) -> bool:
447
+ """Whether the measured occupancy is under the start gate. An unknown
448
+ occupancy (no measured call yet) counts as below: a session starts
449
+ empty."""
450
+ if self._start_ratio is None:
451
+ return False
452
+ ratio = self.occupancy
453
+ return ratio is None or ratio < self._start_ratio
454
+
455
+ @property
456
+ def effective_image_scrub_depth(self) -> int:
457
+ """The screenshot depth the scrub edge uses at the next render: the
458
+ relaxed depth below the start gate, the tight depth from there up."""
459
+ if self.below_start_gate:
460
+ return self._image_scrub_depth_relaxed
461
+ return self._image_scrub_depth
462
+
463
+ def record_prompt_tokens(
464
+ self, prompt_tokens: int | None, messages: list[Any] | None = None
465
+ ) -> None:
466
+ """Record the operator's measured prompt size for one call.
467
+
468
+ Called by the ledger's owning operator (Flash runner / Pro operator)
469
+ after each of its own LLM calls; within a multi-call turn the last
470
+ (largest) call wins. Non-positive or missing values are ignored so a
471
+ provider without usage metadata leaves the base unknown rather than
472
+ zero (and the calibration state below is left untouched).
473
+
474
+ When ``messages`` (the exact list sent to the model) accompanies a
475
+ measured size, the call also calibrates :attr:`chars_per_token`,
476
+ **differentially**: the ledger remembers the previous measured
477
+ sample ``(text_chars, image_count, prompt_tokens)`` and, when the new
478
+ sample carries the same number of images, grows the text by at least
479
+ :data:`MIN_CALIBRATION_DELTA_CHARS` and costs more tokens, it pools
480
+ the pair's differences (``pooled_chars += Δchars``,
481
+ ``pooled_tokens += Δtokens``) and sets the ratio to
482
+ ``pooled_chars / pooled_tokens``, clamped to [1.0, 6.0]. The pooled
483
+ quotient is the maximum-likelihood single ratio for the session (no
484
+ smoothing constant to tune, no last-pair noise). The reference is
485
+ replaced only when it was consumed, when the image count changed, or
486
+ when the prompt shrank in tokens (a compaction or scrub reset the
487
+ baseline); a sample that moved too little keeps the reference, so
488
+ several small turns accumulate into one usable difference instead of
489
+ being discarded one by one (real sessions often grow < 2000 chars
490
+ per turn and would otherwise never calibrate).
491
+
492
+ Why differences: the provider's ``prompt_tokens`` also counts terms
493
+ that are not in ``messages`` or not proportional to their text — the
494
+ bound tool/function schemas, and full-size screenshots that tile to
495
+ ~1100–1800 tokens rather than the flat :data:`IMAGE_BLOCK_TOKENS`.
496
+ A whole-prompt quotient charges all of that to the text, and it
497
+ also averages over the static prefix, whereas the chunker applies
498
+ the ratio to the transcript *tail*, whose density differs. On real
499
+ Gemini Operator traces (10 sessions, 2026-09) the two disagree by
500
+ 5-30% with a session-dependent sign, so the size-based chunk
501
+ trigger fired early in some sessions and late in others. Between
502
+ two prompts with the same image count the constant terms cancel and
503
+ the difference measures the tail's own rate. Until the first
504
+ usable difference exists the whole-prompt quotient
505
+ ``text_chars / (prompt_tokens - images * IMAGE_BLOCK_TOKENS)`` is
506
+ used as a fallback; once a pooled difference exists the pooled ratio
507
+ is authoritative and the fallback is no longer applied.
508
+ """
509
+ try:
510
+ value = int(prompt_tokens) if prompt_tokens is not None else 0
511
+ except (TypeError, ValueError):
512
+ return
513
+ if value <= 0:
514
+ return
515
+ self._last_prompt_tokens = value
516
+ if messages is None:
517
+ return
518
+ try:
519
+ text_chars, image_count = measure_prompt_content(messages)
520
+ except Exception as exc:
521
+ logger.debug(f"Prompt calibration skipped: {exc}", exc_info=True)
522
+ return
523
+ if text_chars <= 0:
524
+ return
525
+
526
+ low, high = _CHARS_PER_TOKEN_BOUNDS
527
+ previous = self._calibration_sample
528
+ sample = (text_chars, image_count, value)
529
+
530
+ if previous is None:
531
+ self._calibration_sample = sample
532
+ else:
533
+ prev_chars, prev_images, prev_tokens = previous
534
+ delta_chars = text_chars - prev_chars
535
+ delta_tokens = value - prev_tokens
536
+ if image_count != prev_images or delta_tokens < 0:
537
+ # Not comparable (image set changed) or the prompt actually
538
+ # shrank (compaction / scrub): start a fresh baseline here.
539
+ self._calibration_sample = sample
540
+ elif delta_chars >= MIN_CALIBRATION_DELTA_CHARS:
541
+ if delta_tokens > 0:
542
+ self._pooled_delta_chars += delta_chars
543
+ self._pooled_delta_tokens += delta_tokens
544
+ pooled = self._pooled_delta_chars / self._pooled_delta_tokens
545
+ self._chars_per_token = min(high, max(low, pooled))
546
+ self._calibration_sample = sample
547
+ return
548
+ self._calibration_sample = sample # text grew at no cost: unusable
549
+ # else: moved too little to measure yet (in either direction) —
550
+ # keep the reference so the movement accumulates against it.
551
+
552
+ if self._pooled_delta_tokens > 0:
553
+ # A pooled difference already exists: it is authoritative, and
554
+ # the whole-prompt quotient (which mis-charges schemas and image
555
+ # tiling to the text) is never applied again.
556
+ return
557
+ text_tokens = max(1, value - image_count * IMAGE_BLOCK_TOKENS)
558
+ self._chars_per_token = min(high, max(low, text_chars / text_tokens))
559
+
560
+ # ------------------------------------------------------------------
561
+ # S region
562
+ # ------------------------------------------------------------------
563
+
564
+ @property
565
+ def has_static_prefix(self) -> bool:
566
+ return bool(self._static)
567
+
568
+ def set_static_prefix(self, messages: list[BaseMessage]) -> None:
569
+ """Install the byte-stable system prefix; only settable once."""
570
+ if self._static:
571
+ raise RuntimeError("TranscriptLedger static prefix is already set.")
572
+ self._static = list(messages)
573
+
574
+ # ------------------------------------------------------------------
575
+ # F region: restored history
576
+ # ------------------------------------------------------------------
577
+
578
+ @property
579
+ def has_restored_history(self) -> bool:
580
+ return bool(self._restored)
581
+
582
+ def set_restored_history(self, text: str) -> None:
583
+ """Install the cold-start frozen history block (empty ledger only)."""
584
+ if self._restored:
585
+ raise RuntimeError("TranscriptLedger restored history is already set.")
586
+ if self._active or self._staged or self._turn_count:
587
+ raise RuntimeError("Restored history can only seed an empty ledger (cold start).")
588
+ self._restored = [HumanMessage(content=[{"type": "text", "text": text}])]
589
+
590
+ # ------------------------------------------------------------------
591
+ # A region (append-only turn commits)
592
+ # ------------------------------------------------------------------
593
+
594
+ @property
595
+ def turn_count(self) -> int:
596
+ return self._turn_count
597
+
598
+ @property
599
+ def has_staged_turn(self) -> bool:
600
+ return self._staged is not None
601
+
602
+ @property
603
+ def active_messages(self) -> tuple[BaseMessage, ...]:
604
+ return tuple(self._active)
605
+
606
+ def stage_turn(self, messages: list[BaseMessage]) -> None:
607
+ """Hold a finished turn's messages until the next build commits them.
608
+
609
+ Committing is deferred because the turn's DataEngine step id and its
610
+ validator result only exist after the operator returns.
611
+ """
612
+ if self._staged is not None:
613
+ logger.warning(
614
+ "TranscriptLedger: previous staged turn was never committed;"
615
+ " committing it without step metadata."
616
+ )
617
+ self.commit_staged()
618
+ self._staged = [m for m in messages if isinstance(m, BaseMessage)]
619
+
620
+ def commit_staged(
621
+ self,
622
+ *,
623
+ step_key: str | None = None,
624
+ validator_result: Any | None = None,
625
+ extra_step_keys: tuple[str, ...] | list[str] = (),
626
+ ) -> None:
627
+ """Move the staged turn into the active region.
628
+
629
+ Args:
630
+ step_key: The DataEngine step id recorded for this turn; keys the
631
+ turn's observation screenshot to its visual-transition summary
632
+ job for the depth-K scrub.
633
+ validator_result: The turn's validator report (``None`` when the
634
+ turn executed no terminal action); appended as a frozen result
635
+ message carrying the ``T+mm:ss`` session offset.
636
+ extra_step_keys: Further DataEngine step ids recorded during the
637
+ same turn (a Flash turn that executed several actions records
638
+ one step per action). They join ``step_key`` in the turn's
639
+ ``step_keys`` so chunk compression lists every step.
640
+ """
641
+ if self._staged is None:
642
+ return
643
+ staged = self._staged
644
+ self._staged = None
645
+ self._last_turn_silent = self._turn_is_silent(staged)
646
+
647
+ if step_key is not None:
648
+ observation = self._first_image_message(staged)
649
+ if observation is not None:
650
+ self._step_keys[id(observation)] = str(step_key)
651
+
652
+ span_start = len(self._active)
653
+ self._active.extend(staged)
654
+
655
+ if validator_result is not None:
656
+ result_message = self._build_result_message(validator_result)
657
+ if result_message is not None:
658
+ self._active.append(result_message)
659
+
660
+ step_keys: list[str] = []
661
+ for key in (step_key, *extra_step_keys):
662
+ if key is not None and str(key) not in step_keys:
663
+ step_keys.append(str(key))
664
+ self._turns.append(
665
+ {
666
+ "step_key": str(step_key) if step_key is not None else None,
667
+ "step_keys": step_keys,
668
+ "start": span_start,
669
+ "end": len(self._active),
670
+ }
671
+ )
672
+ self._turn_count += 1
673
+
674
+ @staticmethod
675
+ def _first_image_message(messages: list[BaseMessage]) -> BaseMessage | None:
676
+ for msg in messages:
677
+ content = getattr(msg, "content", None)
678
+ if not isinstance(content, list):
679
+ continue
680
+ for block in content:
681
+ if isinstance(block, dict) and block.get("type") in ("image_url", "image"):
682
+ return msg
683
+ return None
684
+
685
+ def _build_result_message(self, result: Any) -> HumanMessage | None:
686
+ rendered = None
687
+ if isinstance(result, dict):
688
+ status = result.get("status") or "unknown"
689
+ detail = None
690
+ try:
691
+ from artemis.utils.task_tree import format_result_clean
692
+
693
+ # format_result_clean reports only errors/repairs; a dispatched
694
+ # action renders as the bare status line.
695
+ detail = format_result_clean(result)
696
+ except Exception:
697
+ detail = None
698
+ rendered = f"Status: {status}" + (f"\n{detail}" if detail else "")
699
+ else:
700
+ try:
701
+ rendered = json.dumps(result, ensure_ascii=False, default=str)
702
+ except Exception:
703
+ rendered = str(result)
704
+ if not rendered:
705
+ return None
706
+ return HumanMessage(
707
+ content=[
708
+ {
709
+ "type": "text",
710
+ "text": (f"{EXECUTION_RESULT_MARKER} ({self.elapsed_label()}) ---\n{rendered}"),
711
+ }
712
+ ]
713
+ )
714
+
715
+ # ------------------------------------------------------------------
716
+ # F region: chunk compression
717
+ # ------------------------------------------------------------------
718
+
719
+ def attach_chunker(self, chunker: Any) -> None:
720
+ """Install the L2/L3 :class:`HistoryChunkManager` consulted at render."""
721
+ self._chunker = chunker
722
+
723
+ @property
724
+ def chunker(self) -> Any | None:
725
+ return self._chunker
726
+
727
+ @property
728
+ def frozen_blocks(self) -> tuple[BaseMessage, ...]:
729
+ return tuple(self._frozen_blocks)
730
+
731
+ def unchunked_turns(self) -> list[dict]:
732
+ """Committed turns not yet consumed by a compression event (copies)."""
733
+ return [dict(t) for t in self._turns[self._chunked_turn_count :]]
734
+
735
+ def turn_text_chars(self, turns: list[dict]) -> int:
736
+ """Total characters of the given turns' rendered transcripts (size
737
+ trigger). Measured on the very text the chunk capsule receives, so
738
+ the trigger sizes exactly what gets compressed."""
739
+ return sum(len(self.turn_transcript(turn)) for turn in turns)
740
+
741
+ def turn_transcript(self, turn: dict) -> str:
742
+ """Plain-text rendering of one committed turn exactly as it currently
743
+ stands in the active region (i.e. after the scrub edge), see
744
+ :func:`render_turn_transcript`. Chunk compression feeds this to the
745
+ segment capsule so the capsule digests precisely what it replaces."""
746
+ return render_turn_transcript(self._active[turn["start"] : turn["end"]])
747
+
748
+ def freeze_turns(self, turn_count: int, frozen_blocks: list[BaseMessage]) -> None:
749
+ """Compression event: consume the oldest ``turn_count`` unchunked turns
750
+ and replace the frozen chunk blocks wholesale.
751
+
752
+ The boundary always advances to a committed turn's end, so a tool-call
753
+ message and its responses are never split across the F/A boundary.
754
+ """
755
+ if turn_count <= 0:
756
+ self._frozen_blocks = list(frozen_blocks)
757
+ return
758
+ available = self._turns[self._chunked_turn_count :]
759
+ if turn_count > len(available):
760
+ raise ValueError(
761
+ f"freeze_turns({turn_count}) exceeds the {len(available)}"
762
+ " unchunked committed turns."
763
+ )
764
+ self._chunked_turn_count += turn_count
765
+ self._active_start = self._turns[self._chunked_turn_count - 1]["end"]
766
+ self._frozen_blocks = list(frozen_blocks)
767
+
768
+ # ------------------------------------------------------------------
769
+ # Rendering
770
+ # ------------------------------------------------------------------
771
+
772
+ def render(self, tail: list[BaseMessage]) -> list[BaseMessage]:
773
+ """Advance compression and return ``S + F + A + tail``.
774
+
775
+ Order per turn: the occupancy band is read once (from the operator's
776
+ last measured prompt), the scrub edge advances over the active window
777
+ at that band's screenshot depth, then the chunker runs (a triggered
778
+ compression event deep-mutates the frozen region and advances the F/A
779
+ boundary). Scrub-before-chunk guarantees that the turns a chunk
780
+ closes over — all older than the sliding-window floor — have already
781
+ had their screenshots resolved when the relaxed depth is no deeper
782
+ than the floor plus one. The returned list is a fresh container:
783
+ appending to it (the operator's in-turn tool loop) never mutates the
784
+ ledger regions.
785
+ """
786
+ self._compressor.compress(self._active, image_scrub_depth=self.effective_image_scrub_depth)
787
+ if self._chunker is not None:
788
+ try:
789
+ self._chunker.on_render(self)
790
+ except Exception as e:
791
+ logger.error(f"History chunker render hook failed: {e}")
792
+ return [
793
+ *self._static,
794
+ *self._restored,
795
+ *self._frozen_blocks,
796
+ *self._active[self._active_start :],
797
+ *tail,
798
+ ]