android-midscene-automation 0.2.4 → 1.1.1

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 +10 -0
  2. package/README.md +159 -29
  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 +4 -2
  505. package/server/artemis.ts +34 -0
  506. package/server/http-api.ts +12 -0
  507. package/src/App.vue +10 -5
  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 +340 -121
  525. package/src/types.ts +1 -1
@@ -0,0 +1,1615 @@
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
+ import asyncio
16
+ from collections.abc import Callable
17
+ import functools
18
+ import hashlib
19
+ import json
20
+ import os
21
+ from pathlib import Path
22
+ import re
23
+ import socket
24
+ import sqlite3
25
+ import threading
26
+ import time
27
+ from typing import Any
28
+ from uuid import UUID, uuid4
29
+
30
+ from artemis.config import PAUSE_FILE, get_ipc_port_file, read_ipc_port, settings
31
+ from artemis.context import ArtemisContext
32
+ from artemis.data_engine.models import (
33
+ BackgroundTaskRecord,
34
+ FailedOutputRecord,
35
+ HistoryChunkRecord,
36
+ ImageRecord,
37
+ SessionMetadata,
38
+ StepRecord,
39
+ TraceRecord,
40
+ VideoRecordingRecord,
41
+ )
42
+ from artemis.data_engine.storage import StorageManager
43
+ from artemis.data_engine.trace import CURRENT_TRACE_ID
44
+ from artemis.utils.coordinates import (
45
+ normalize_any_structure,
46
+ normalize_step_actions,
47
+ )
48
+ from artemis.utils.text import safe_extract_text
49
+ from third_party.mobile_use.utils.logger import get_logger
50
+
51
+ logger = get_logger(__name__)
52
+
53
+ _CURRENT_DATA_ENGINE = None
54
+
55
+ # Overlay packages that never identify the app the user is working in.
56
+ _FOREGROUND_APP_IGNORED = {"com.android.systemui"}
57
+
58
+
59
+ def _derive_foreground_app(ui_tree: Any | None) -> str | None:
60
+ """Best-effort foreground package from a perception UI tree (M5).
61
+
62
+ Counts ``package``/``packageName`` attributes across the node list
63
+ (recursing into ``children``), ignores system-overlay packages, and
64
+ returns the most frequent value. Pure string work — never raises, never
65
+ touches the device.
66
+ """
67
+ counts: dict[str, int] = {}
68
+
69
+ def _visit(node: Any, depth: int = 0) -> None:
70
+ if depth > 50:
71
+ return
72
+ if isinstance(node, dict):
73
+ pkg = node.get("package") or node.get("packageName")
74
+ if isinstance(pkg, str) and pkg and pkg not in _FOREGROUND_APP_IGNORED:
75
+ counts[pkg] = counts.get(pkg, 0) + 1
76
+ children = node.get("children")
77
+ if isinstance(children, (list, tuple)):
78
+ for child in children:
79
+ _visit(child, depth + 1)
80
+ elif isinstance(node, (list, tuple)):
81
+ for child in node:
82
+ _visit(child, depth + 1)
83
+
84
+ try:
85
+ _visit(ui_tree)
86
+ except Exception:
87
+ return None
88
+ if not counts:
89
+ return None
90
+ return max(counts.items(), key=lambda kv: kv[1])[0]
91
+
92
+
93
+ #: ``smart_serialize`` stores any image payload inside a trace as this
94
+ #: reference; the hash is the SHA-256 of the decoded image bytes — the same
95
+ #: scheme ``record_step`` uses for ``image_name`` — so a referenced image can
96
+ #: be resolved back to the step screenshot it came from.
97
+ _IMAGE_REF_RE = re.compile(r"<ImageRef: sha256=([0-9a-fA-F]{64})")
98
+
99
+ #: Text stand-in for an image whose origin is unknown (e.g. an annotated
100
+ #: Explorer overlay that was never stored as a step screenshot).
101
+ GENERIC_IMAGE_LABEL = "[image attached]"
102
+
103
+
104
+ def image_ref_hash(value: Any) -> str | None:
105
+ """The SHA-256 named by an ``<ImageRef: …>`` placeholder, if any."""
106
+ if not isinstance(value, str):
107
+ return None
108
+ match = _IMAGE_REF_RE.search(value)
109
+ return match.group(1).lower() if match else None
110
+
111
+
112
+ def build_image_describer(steps: list[Any]) -> Callable[[str | None], dict[str, Any]]:
113
+ """Describer that resolves an image hash to the step screenshot it is.
114
+
115
+ Returns a callable producing a text block: ``[screenshot: pre-action of
116
+ Step 3]`` (with the ``image_name`` attached so search can pull that
117
+ screenshot's OCR/XML as the image's description) or the generic label
118
+ when the hash matches no recorded step screenshot.
119
+ """
120
+ index: dict[str, tuple[int, str]] = {}
121
+ for step in steps:
122
+ number = getattr(step, "step_number", None)
123
+ if number is None and isinstance(step, dict):
124
+ number = step.get("step_number")
125
+ for which in ("pre", "post"):
126
+ name = getattr(step, f"{which}_image_name", None)
127
+ if name is None and isinstance(step, dict):
128
+ name = step.get(f"{which}_image_name")
129
+ if name and name not in index:
130
+ index[str(name).lower()] = (number, which)
131
+
132
+ def describe(image_name: str | None) -> dict[str, Any]:
133
+ if image_name and image_name.lower() in index:
134
+ number, which = index[image_name.lower()]
135
+ return {
136
+ "type": "text",
137
+ "text": f"[screenshot: {which}-action of Step {number}]",
138
+ "image_name": image_name.lower(),
139
+ }
140
+ return {"type": "text", "text": GENERIC_IMAGE_LABEL}
141
+
142
+ return describe
143
+
144
+
145
+ def describe_result_images(result: Any, describer: Callable[[str | None], dict[str, Any]]) -> Any:
146
+ """Rewrites image blocks inside a stored tool result into text blocks.
147
+
148
+ Images are never dropped silently from the text views: a block becomes
149
+ the description the describer gives it (which step screenshot it is, or
150
+ a generic label), so replay and search both see that an image was there
151
+ and, when known, what it showed.
152
+ """
153
+ if isinstance(result, list):
154
+ out = []
155
+ for block in result:
156
+ if isinstance(block, dict) and block.get("type") in ("image_url", "image"):
157
+ url = block.get("image_url")
158
+ if isinstance(url, dict):
159
+ url = url.get("url")
160
+ if url is None:
161
+ url = block.get("source") or block.get("data")
162
+ out.append(describer(image_ref_hash(url)))
163
+ else:
164
+ out.append(describe_result_images(block, describer))
165
+ return out
166
+ if isinstance(result, dict):
167
+ return {k: describe_result_images(v, describer) for k, v in result.items()}
168
+ return result
169
+
170
+
171
+ def build_interleaved_events(
172
+ traces: list[Any],
173
+ image_describer: Callable[[str | None], dict[str, Any]] | None = None,
174
+ ) -> list[dict[str, Any]]:
175
+ """Turns one step's raw trace rows into the agent-facing event stream.
176
+
177
+ Operator LLM calls become ``thought`` / ``native_thought`` events; top-level
178
+ tool and sub-agent traces become ``tool_call`` events carrying ``name``,
179
+ ``args`` and ``result``. Nested calls made *inside* a kept tool/agent trace
180
+ (e.g. the Explorer's own perception calls) are dropped so only the
181
+ conclusion the Operator actually saw is replayed. Shared by the live
182
+ DataEngine and the offline MCP trace inspector so both replay the same
183
+ events.
184
+
185
+ Image blocks inside tool results are rewritten into text descriptions via
186
+ ``image_describer`` (see ``build_image_describer``); without one they
187
+ become the generic label.
188
+ """
189
+ describer = image_describer or build_image_describer([])
190
+ interleaved_events: list[dict[str, Any]] = []
191
+ denylist = (
192
+ "operator",
193
+ "perception",
194
+ "planner",
195
+ "validator",
196
+ "summarizer",
197
+ "checker",
198
+ )
199
+
200
+ relevant_traces = []
201
+ for t in traces:
202
+ if t.status not in ("success", "failed"):
203
+ continue
204
+
205
+ is_relevant_llm = False
206
+ if t.type == "llm_call":
207
+ curr_parent = t.parent_trace_id
208
+ visited = set()
209
+ while curr_parent:
210
+ if curr_parent in visited:
211
+ break
212
+ visited.add(curr_parent)
213
+ parent_trace = next(
214
+ (x for x in traces if x.trace_id == curr_parent),
215
+ None,
216
+ )
217
+ if parent_trace:
218
+ if parent_trace.name == "operator":
219
+ is_relevant_llm = True
220
+ t.name = parent_trace.name
221
+ break
222
+ curr_parent = parent_trace.parent_trace_id
223
+ else:
224
+ break
225
+
226
+ if is_relevant_llm:
227
+ relevant_traces.append(t)
228
+ elif t.type in ("tool", "agent") and t.name not in denylist:
229
+ relevant_traces.append(t)
230
+
231
+ # Sort by timestamp to ensure exact chronological order
232
+ relevant_traces.sort(key=lambda x: x.timestamp)
233
+
234
+ # Separate set of candidate tool/agent IDs to do top-level filtering
235
+ # (only keep top-level tool calls)
236
+ candidate_ids = {t.trace_id for t in relevant_traces if t.type in ("tool", "agent")}
237
+
238
+ for t in relevant_traces:
239
+ if t.type == "llm_call":
240
+ payload = t.payload or {}
241
+ response_list = payload.get("response") or []
242
+ thought_text = ""
243
+ has_structured_blocks = False
244
+ if response_list:
245
+ first_gen = response_list[0]
246
+ if isinstance(first_gen, dict):
247
+ content_val = first_gen.get("content")
248
+ if isinstance(content_val, list):
249
+ has_structured_blocks = True
250
+ for block in content_val:
251
+ if isinstance(block, dict):
252
+ if (
253
+ block.get("type") == "thinking"
254
+ and block.get("thinking", "").strip()
255
+ ):
256
+ interleaved_events.append(
257
+ {
258
+ "type": "native_thought",
259
+ "content": (block["thinking"].strip()),
260
+ }
261
+ )
262
+ elif block.get("type") == "text" and block.get("text", "").strip():
263
+ interleaved_events.append(
264
+ {
265
+ "type": "thought",
266
+ "content": (block["text"].strip()),
267
+ }
268
+ )
269
+ if not has_structured_blocks:
270
+ thought_text = first_gen.get("content") or first_gen.get("text") or ""
271
+ else:
272
+ thought_text = str(first_gen)
273
+
274
+ if not has_structured_blocks:
275
+ if isinstance(thought_text, list):
276
+ thought_text = safe_extract_text(thought_text)
277
+
278
+ if thought_text and thought_text.strip():
279
+ interleaved_events.append(
280
+ {
281
+ "type": "thought",
282
+ "content": thought_text.strip(),
283
+ }
284
+ )
285
+ elif t.type in ("tool", "agent"):
286
+ # Keep only top-level candidate traces (no ancestor in candidates)
287
+ is_sub_call = False
288
+ curr_parent = t.parent_trace_id
289
+ visited = set()
290
+ while curr_parent:
291
+ if curr_parent in visited:
292
+ break
293
+ visited.add(curr_parent)
294
+ if curr_parent in candidate_ids:
295
+ is_sub_call = True
296
+ break
297
+ parent_trace = next(
298
+ (x for x in traces if x.trace_id == curr_parent),
299
+ None,
300
+ )
301
+ if parent_trace:
302
+ curr_parent = parent_trace.parent_trace_id
303
+ else:
304
+ break
305
+ if is_sub_call:
306
+ continue
307
+
308
+ payload = t.payload or {}
309
+ tc_args = payload.get("args") or {}
310
+
311
+ if isinstance(tc_args, dict):
312
+ filtered_args = {
313
+ k: v for k, v in tc_args.items() if k not in ("state", "tool_call_id")
314
+ }
315
+ else:
316
+ filtered_args = tc_args
317
+
318
+ tc_result = payload.get("result") or payload.get("error") or "No result"
319
+ tc_result = describe_result_images(tc_result, describer)
320
+
321
+ interleaved_events.append(
322
+ {
323
+ "type": "tool_call",
324
+ "name": t.name,
325
+ "args": filtered_args,
326
+ "result": tc_result,
327
+ }
328
+ )
329
+
330
+ return interleaved_events
331
+
332
+
333
+ def relative_time_label(timestamp: float | None, session_start_time: float | None) -> str:
334
+ """Session-relative ``"12.3s"`` label of a step timestamp (``"0.0s"`` when
335
+ the session clock is unknown, matching ``DataEngine.get_relative_time``)."""
336
+ if session_start_time is None or timestamp is None:
337
+ return "0.0s"
338
+ return f"{timestamp - session_start_time:.1f}s"
339
+
340
+
341
+ def friendly_step(
342
+ step: StepRecord,
343
+ traces: list[TraceRecord],
344
+ image_describer: Callable[[str | None], dict[str, Any]] | None = None,
345
+ *,
346
+ session_start_time: float | None = None,
347
+ default_width: int = 1080,
348
+ default_height: int = 2400,
349
+ ) -> dict[str, Any]:
350
+ """Agent-friendly record of one stored step.
351
+
352
+ JSON columns are already parsed (``StepRecord``), the step's traces are
353
+ expanded into ``interleaved_events`` (see ``build_interleaved_events``),
354
+ ``tool_calls`` lists the tool events, ``relative_time`` is the
355
+ session-relative label and physical coordinates are normalized using the
356
+ dimensions stamped on the step (falling back to the defaults). Shared by
357
+ the live ``DataEngine`` and the offline ``OfflineHistoryReader`` so both
358
+ replay the same record.
359
+ """
360
+ step_dict = step.model_dump()
361
+ step_dict["relative_time"] = relative_time_label(step.timestamp, session_start_time)
362
+
363
+ try:
364
+ step_dict["interleaved_events"] = build_interleaved_events(traces, image_describer)
365
+ except Exception as e:
366
+ logger.error(f"Failed to retrieve traces for step {step.step_id}: {e}")
367
+ step_dict["interleaved_events"] = []
368
+ step_dict["tool_calls"] = [
369
+ e for e in step_dict["interleaved_events"] if e["type"] == "tool_call"
370
+ ]
371
+
372
+ # Normalize physical coordinates to normalized ones for agent consumption
373
+ try:
374
+ extra = step_dict.get("extra_metadata") or {}
375
+ width = extra.get("width") or default_width
376
+ height = extra.get("height") or default_height
377
+
378
+ if step_dict.get("action_taken"):
379
+ step_dict["action_taken"] = normalize_any_structure(
380
+ step_dict["action_taken"], width, height
381
+ )
382
+ if step_dict.get("last_execution_result"):
383
+ step_dict["last_execution_result"] = normalize_any_structure(
384
+ step_dict["last_execution_result"], width, height
385
+ )
386
+ except Exception as norm_err:
387
+ logger.error(f"Failed to normalize coordinates in friendly steps history: {norm_err}")
388
+
389
+ return step_dict
390
+
391
+
392
+ class DataEngine:
393
+ """Core engine for managing runtime data, storage, and streaming."""
394
+
395
+ def __init__(self, ctx: ArtemisContext):
396
+ self.ctx = ctx
397
+
398
+ # Determine paths from context
399
+ if ctx.execution_setup and ctx.execution_setup.traces_path:
400
+ self.global_base_dir = Path(ctx.execution_setup.traces_path)
401
+ self.db_path = self.global_base_dir / "data_engine.db"
402
+ else:
403
+ # Fallback to default unified settings
404
+ self.global_base_dir = settings.TRACES_PATH
405
+ self.db_path = settings.DATA_ENGINE_DB_PATH
406
+
407
+ self.storage = StorageManager(self.db_path, self.global_base_dir)
408
+
409
+ self.current_session_id: UUID | None = None
410
+ self.session_start_time: float | None = None
411
+ self.current_step_id: UUID | None = None
412
+ self.last_recorded_step_id: UUID | None = None
413
+ self.current_step_dir: Path | None = None
414
+ self.subscribers: list[Callable[[str, Any], None]] = []
415
+ self.lock = threading.Lock()
416
+ self._trace_counter = 0
417
+ self._pending_tasks = set()
418
+ self._pending_threads = []
419
+ self._accumulated_logs = {}
420
+ self._bg_task_to_trace_id = {}
421
+ self._trace_name_cache = {}
422
+ self._step_number_cache: dict[str, int] = {}
423
+ # Background tasks are written directly to SQLite storage
424
+
425
+ self.ipc_socket = None
426
+ self._ipc_socket_lock = threading.Lock()
427
+ self._ipc_shutdown = False
428
+ self._ipc_retry_after = 0.0
429
+ self._ipc_failure_count = 0
430
+ with self._ipc_socket_lock:
431
+ self._connect_ipc_locked()
432
+
433
+ @staticmethod
434
+ def _ipc_port_candidates() -> list[int]:
435
+ """Return inherited and freshly published UI ports, newest file included.
436
+
437
+ A worker inherits ``ARTEMIS_IPC_PORT`` when it starts. If the Windows UI
438
+ server restarts, that environment value is permanently stale while the
439
+ shared port file is updated by the new server.
440
+ """
441
+ candidates: list[int] = []
442
+ inherited_or_file_port = read_ipc_port()
443
+ if inherited_or_file_port:
444
+ candidates.append(inherited_or_file_port)
445
+
446
+ port_file = get_ipc_port_file()
447
+ if port_file.exists():
448
+ try:
449
+ file_value = port_file.read_text(encoding="utf-8").strip()
450
+ if file_value.isdigit() and int(file_value) not in candidates:
451
+ candidates.append(int(file_value))
452
+ except OSError as exc:
453
+ logger.debug(f"Could not read refreshed IPC port from {port_file}: {exc}")
454
+ return candidates
455
+
456
+ def _connect_ipc_locked(self, *, force: bool = False) -> bool:
457
+ """Connect to the UI event bridge while holding ``_ipc_socket_lock``."""
458
+ if self._ipc_shutdown:
459
+ return False
460
+ if self.ipc_socket is not None:
461
+ return True
462
+ if not force and time.monotonic() < self._ipc_retry_after:
463
+ return False
464
+
465
+ for ipc_port in self._ipc_port_candidates():
466
+ try:
467
+ # A localhost listener either accepts immediately or is absent.
468
+ # A short connect cap prevents telemetry from stalling the
469
+ # automation/data worker when the desktop event bridge is stale.
470
+ sock = socket.create_connection(("127.0.0.1", ipc_port), timeout=0.2)
471
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
472
+ sock.settimeout(None)
473
+ self.ipc_socket = sock
474
+ self._ipc_failure_count = 0
475
+ self._ipc_retry_after = 0.0
476
+ logger.info(f"Connected to IPC server on port {ipc_port} with TCP_NODELAY")
477
+ return True
478
+ except OSError as exc:
479
+ logger.warning(f"Failed to connect to IPC server on port {ipc_port}: {exc}")
480
+
481
+ self.ipc_socket = None
482
+ self._ipc_failure_count += 1
483
+ retry_delay = min(30.0, 0.5 * (2 ** min(self._ipc_failure_count - 1, 6)))
484
+ self._ipc_retry_after = time.monotonic() + retry_delay
485
+ return False
486
+
487
+ def _send_ipc_event(self, event_type: str, data: Any) -> None:
488
+ """Send one complete event, reconnecting once if a stale socket is found.
489
+
490
+ Publishing can originate from LLM callbacks and background worker threads.
491
+ Serializing socket access keeps newline-delimited JSON frames from being
492
+ interleaved on Windows and prevents the event that detects a dead socket
493
+ from being silently lost.
494
+ """
495
+ payload = json.dumps({"event_type": event_type, "data": data}, default=str) + "\n"
496
+ encoded_payload = payload.encode("utf-8")
497
+
498
+ with self._ipc_socket_lock:
499
+ for attempt in range(2):
500
+ if not self._connect_ipc_locked(force=attempt > 0):
501
+ return
502
+ try:
503
+ self.ipc_socket.sendall(encoded_payload)
504
+ return
505
+ except OSError as exc:
506
+ logger.warning(f"IPC send failed; reconnecting: {exc}")
507
+ try:
508
+ self.ipc_socket.close()
509
+ except OSError:
510
+ pass
511
+ self.ipc_socket = None
512
+ self._ipc_retry_after = 0.0
513
+
514
+ @property
515
+ def base_dir(self) -> Path:
516
+ if self.current_session_id:
517
+ return self.global_base_dir / str(self.current_session_id)
518
+ return self.global_base_dir
519
+
520
+ def subscribe(self, callback: Callable[[str, Any], None]):
521
+ """Subscribe to real-time events (e.g., for SSE)."""
522
+ self.subscribers.append(callback)
523
+
524
+ def unsubscribe(self, callback: Callable[[str, Any], None]):
525
+ """Unsubscribe from real-time events."""
526
+ if callback in self.subscribers:
527
+ self.subscribers.remove(callback)
528
+
529
+ def _publish(self, event_type: str, data: Any):
530
+ """Publish event to all subscribers."""
531
+ if isinstance(data, dict) and "session_id" not in data and self.current_session_id:
532
+ data["session_id"] = str(self.current_session_id)
533
+
534
+ for callback in self.subscribers:
535
+ try:
536
+ callback(event_type, data)
537
+ except Exception as e:
538
+ logger.error(f"Error in subscriber callback: {e}")
539
+
540
+ # Bridge to Cloud Gateway SSE stream in Cloud Mode
541
+ if os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
542
+ try:
543
+ import asyncio
544
+ from debug_pub.cloud_gateway import cloud_manager
545
+
546
+ sid = os.environ.get("ARTEMIS_CLOUD_SESSION_ID") or (
547
+ str(self.current_session_id) if self.current_session_id else None
548
+ )
549
+ if sid and sid in cloud_manager.sessions:
550
+ try:
551
+ loop = asyncio.get_running_loop()
552
+ if loop.is_running():
553
+ loop.create_task(
554
+ cloud_manager.sessions[sid].emit_event(
555
+ event_type,
556
+ data if isinstance(data, dict) else {"payload": data},
557
+ )
558
+ )
559
+ except RuntimeError:
560
+ pass
561
+ except Exception as exc:
562
+ logger.debug(f"Cloud gateway event bridge skipped: {exc}", exc_info=True)
563
+
564
+ self._send_ipc_event(event_type, data)
565
+
566
+ def start_session(
567
+ self,
568
+ goal: str,
569
+ device_info: dict[str, Any] | None = None,
570
+ session_id: UUID | str | None = None,
571
+ ) -> UUID:
572
+ """Start a new session."""
573
+ global _CURRENT_DATA_ENGINE
574
+ _CURRENT_DATA_ENGINE = self
575
+
576
+ if session_id is not None:
577
+ if isinstance(session_id, str):
578
+ try:
579
+ session_id = UUID(session_id)
580
+ except ValueError:
581
+ pass
582
+ else:
583
+ env_session_id = os.getenv("ARTEMIS_CLOUD_SESSION_ID") or os.getenv(
584
+ "ARTEMIS_SESSION_ID"
585
+ )
586
+ if env_session_id:
587
+ try:
588
+ session_id = UUID(env_session_id)
589
+ except ValueError:
590
+ session_id = env_session_id
591
+ else:
592
+ session_id = uuid4()
593
+ self.current_session_id = session_id
594
+ self.session_start_time = time.time()
595
+
596
+ # Clear old pause file if it exists
597
+ pause_file = PAUSE_FILE
598
+ if pause_file.exists():
599
+ try:
600
+ pause_file.unlink()
601
+ logger.info("Removed old pause file on session start.")
602
+ except Exception as e:
603
+ logger.error(f"Failed to delete old pause file: {e}")
604
+
605
+ session = SessionMetadata(
606
+ session_id=session_id,
607
+ initial_goal=goal,
608
+ start_time=self.session_start_time,
609
+ device_info=device_info or {},
610
+ pid=os.getpid(),
611
+ )
612
+ self.storage.create_session(session)
613
+ try:
614
+ from artemis.runtime import DeviceExecutionLock
615
+
616
+ DeviceExecutionLock.annotate_active_owner(
617
+ session_id=str(session_id),
618
+ ingress=os.getenv("ARTEMIS_TASK_INGRESS") or "sdk",
619
+ )
620
+ except Exception as exc:
621
+ logger.debug(f"Could not annotate active device owner: {exc}")
622
+ # Initialize step counter from existing steps if resuming an existing session
623
+ existing_steps = []
624
+ if self.storage and hasattr(self.storage, "get_steps"):
625
+ try:
626
+ existing_steps = self.storage.get_steps(session_id) or []
627
+ except Exception:
628
+ existing_steps = []
629
+ if existing_steps:
630
+ self._step_number_cache.clear()
631
+ for s in existing_steps:
632
+ if s.step_id and s.step_number is not None:
633
+ self._step_number_cache[str(s.step_id)] = s.step_number
634
+ self.current_step_number = max([s.step_number for s in existing_steps], default=0)
635
+ else:
636
+ self._step_number_cache.clear()
637
+ self.current_step_number = 0
638
+ logger.info(
639
+ f"Session started: {session_id} (current step counter: {self.current_step_number})"
640
+ )
641
+ self._publish("session_started", session.model_dump())
642
+ return session_id
643
+
644
+ def end_session(self, status: str = "completed"):
645
+ """End the current session, updating its status and end time."""
646
+ if not self.current_session_id:
647
+ return
648
+
649
+ # Session-level terminal statuses are canonically "completed" /
650
+ # "failed" / "cancelled"; "success" is a legacy alias some callers
651
+ # still pass and must never reach the sessions table.
652
+ if status == "success":
653
+ status = "completed"
654
+
655
+ # Clear pause file if it exists
656
+ pause_file = PAUSE_FILE
657
+ if pause_file.exists():
658
+ try:
659
+ pause_file.unlink()
660
+ logger.info("Removed pause file on session end.")
661
+ except Exception as e:
662
+ logger.error(f"Failed to delete pause file on session end: {e}")
663
+
664
+ session_id = self.current_session_id
665
+ end_time = time.time()
666
+ session = self.storage.get_session(session_id)
667
+ if session and session.end_time is not None and session.status not in ("running", "paused"):
668
+ logger.debug(f"Session end already published for {session_id}; skipping duplicate")
669
+ return
670
+ # Session-level LLM usage line (cache-hit ratios per source), best-effort.
671
+ try:
672
+ from artemis.services.token_meter import log_session_summary
673
+
674
+ log_session_summary(session_id)
675
+ except Exception as e:
676
+ logger.debug(f"Session usage summary skipped: {e}")
677
+ if session:
678
+ session.end_time = end_time
679
+ session.status = status
680
+ else:
681
+ session = SessionMetadata(
682
+ session_id=session_id,
683
+ initial_goal="",
684
+ start_time=self.session_start_time or end_time,
685
+ end_time=end_time,
686
+ status=status,
687
+ device_info=self.ctx.device.model_dump()
688
+ if getattr(self, "ctx", None) and self.ctx.device
689
+ else {},
690
+ )
691
+
692
+ try:
693
+ self.storage.update_session(session)
694
+ logger.info(f"Session ended: {session_id} with status: {status}")
695
+ self._publish("session_ended", session.model_dump())
696
+ except Exception as e:
697
+ logger.error(f"Failed to end session in DataEngine: {e}")
698
+
699
+ def record_video_start(
700
+ self,
701
+ video_id: UUID,
702
+ device_id: str,
703
+ local_video_path: str | Path,
704
+ start_time: float | None = None,
705
+ ):
706
+ """Record the start of a video recording."""
707
+ if not self.storage:
708
+ return
709
+ try:
710
+ record = VideoRecordingRecord(
711
+ video_id=video_id,
712
+ session_id=self.current_session_id,
713
+ device_id=device_id,
714
+ start_time=start_time or time.time(),
715
+ local_video_path=str(local_video_path),
716
+ )
717
+ self.storage.create_video_recording(record)
718
+ except Exception as e:
719
+ logger.error(f"Failed to record video start in DataEngine: {e}")
720
+
721
+ def record_video_stop(
722
+ self,
723
+ video_id: UUID,
724
+ device_id: str,
725
+ local_video_path: str | Path,
726
+ start_time: float,
727
+ end_time: float | None = None,
728
+ ):
729
+ """Record the completion of a video recording and sync to session metadata."""
730
+ if not self.storage:
731
+ return
732
+ try:
733
+ record = VideoRecordingRecord(
734
+ video_id=video_id,
735
+ session_id=self.current_session_id,
736
+ device_id=device_id,
737
+ start_time=start_time,
738
+ end_time=end_time or time.time(),
739
+ local_video_path=str(local_video_path),
740
+ status="ready",
741
+ )
742
+ self.storage.update_video_recording(record)
743
+ self._publish(
744
+ "recording_ready",
745
+ {
746
+ "session_id": str(self.current_session_id),
747
+ "video_id": str(video_id),
748
+ "local_video_path": str(local_video_path),
749
+ "end_time": record.end_time,
750
+ },
751
+ )
752
+ except Exception as e:
753
+ logger.error(f"Failed to record video stop in DataEngine: {e}")
754
+
755
+ def record_video_failure(
756
+ self,
757
+ video_id: UUID,
758
+ device_id: str,
759
+ local_video_path: str | Path | None,
760
+ start_time: float,
761
+ error: str,
762
+ ):
763
+ """Persist and publish a terminal recording failure."""
764
+ if not self.storage:
765
+ return
766
+ try:
767
+ record = VideoRecordingRecord(
768
+ video_id=video_id,
769
+ session_id=self.current_session_id,
770
+ device_id=device_id,
771
+ start_time=start_time,
772
+ end_time=time.time(),
773
+ local_video_path=str(local_video_path) if local_video_path else None,
774
+ status="failed",
775
+ error=error,
776
+ )
777
+ self.storage.update_video_recording(record)
778
+ self._publish(
779
+ "recording_failed",
780
+ {
781
+ "session_id": str(self.current_session_id),
782
+ "video_id": str(video_id),
783
+ "error": error,
784
+ },
785
+ )
786
+ except Exception as e:
787
+ logger.error(f"Failed to record video failure in DataEngine: {e}")
788
+
789
+ def update_session_device_info(self, **fields: Any) -> None:
790
+ """Merge ``fields`` into the running session's ``device_info`` record."""
791
+ if not self.storage or not self.current_session_id:
792
+ return
793
+ try:
794
+ session = self.storage.get_session(self.current_session_id)
795
+ if session is None:
796
+ return
797
+ session.device_info = {**(session.device_info or {}), **fields}
798
+ self.storage.update_session(session)
799
+ except (OSError, ValueError, sqlite3.Error) as e:
800
+ logger.warning(f"Failed to update session device_info in DataEngine: {e}")
801
+
802
+ def update_video_path(self, local_video_path: str | Path):
803
+ """Update the video path across tables when traces or videos are moved."""
804
+ if not self.storage or not self.current_session_id:
805
+ return
806
+ try:
807
+ self.storage.update_session_video_path(self.current_session_id, str(local_video_path))
808
+ except Exception as e:
809
+ logger.warning(f"Failed to update video path in DataEngine: {e}")
810
+
811
+ def get_or_create_image(
812
+ self,
813
+ image_bytes: bytes,
814
+ ui_tree: Any | None = None,
815
+ ocr_result: Any | None = None,
816
+ ) -> str:
817
+ """Get image name by hash, or create new record if not exists."""
818
+
819
+ hasher = hashlib.sha256()
820
+ hasher.update(image_bytes)
821
+ image_name = hasher.hexdigest()
822
+
823
+ image_record = self.storage.get_image(image_name)
824
+ if image_record:
825
+ # If the image exists but is missing OCR or UI tree, and we now have them, update the record.
826
+ if (ocr_result is not None and image_record.ocr_result is None) or (
827
+ ui_tree is not None and image_record.ui_tree is None
828
+ ):
829
+ self.storage.update_image_data(image_name, ocr_result, ui_tree)
830
+ return image_name
831
+
832
+ images_dir = self.global_base_dir / "images"
833
+ images_dir.mkdir(parents=True, exist_ok=True)
834
+ file_path = images_dir / f"{image_name}.jpg"
835
+
836
+ with open(file_path, "wb") as f:
837
+ f.write(image_bytes)
838
+
839
+ new_record = ImageRecord(
840
+ image_name=image_name,
841
+ ui_tree=ui_tree,
842
+ ocr_result=ocr_result,
843
+ extra_metadata={},
844
+ )
845
+ self.storage.create_image(new_record)
846
+
847
+ return image_name
848
+
849
+ def get_image_path(self, image_name: str) -> Path:
850
+ """Get the absolute file path of an image by its name/hash."""
851
+ return self.global_base_dir / "images" / f"{image_name}.jpg"
852
+
853
+ def record_step(
854
+ self,
855
+ pre_screenshot_bytes: bytes | None = None,
856
+ post_screenshot_bytes: bytes | None = None,
857
+ ui_tree: Any | None = None,
858
+ ocr_result: Any | None = None,
859
+ foreground_app: str | None = None,
860
+ action_taken: dict[str, Any] | None = None,
861
+ summary: str | None = None,
862
+ operator_raw_thinking: str | None = None,
863
+ operator_native_thinking: str | None = None,
864
+ last_execution_result: Any | None = None,
865
+ extra_metadata: dict[str, Any] | None = None,
866
+ ) -> UUID:
867
+ """Record a step, saving screenshots and metadata."""
868
+ if not self.current_session_id:
869
+ raise ValueError("No active session. Call start_session first.")
870
+
871
+ with self.lock:
872
+ existing_steps = []
873
+ if self.storage and hasattr(self.storage, "get_steps") and self.current_session_id:
874
+ try:
875
+ existing_steps = self.storage.get_steps(self.current_session_id) or []
876
+ except Exception:
877
+ existing_steps = []
878
+ max_existing = (
879
+ max([s.step_number for s in existing_steps], default=0) if existing_steps else 0
880
+ )
881
+ self.current_step_number = max(self.current_step_number, max_existing) + 1
882
+ step_number = self.current_step_number
883
+
884
+ step_id = self.current_step_id or uuid4()
885
+ self.last_recorded_step_id = step_id
886
+ self._step_number_cache[str(step_id)] = step_number
887
+ # Reset current_step_id so subsequent steps will allocate a new one
888
+ self.current_step_id = None
889
+
890
+ # Process images and step creation in background thread
891
+
892
+ pre_image_name = (
893
+ hashlib.sha256(pre_screenshot_bytes).hexdigest() if pre_screenshot_bytes else None
894
+ )
895
+ post_image_name = (
896
+ hashlib.sha256(post_screenshot_bytes).hexdigest() if post_screenshot_bytes else None
897
+ )
898
+ if pre_image_name and post_image_name and pre_image_name == post_image_name:
899
+ post_image_name = None
900
+
901
+ # Persist the foreground app for the recall search surface (the
902
+ # parameter was historically accepted but never stored). The explicit
903
+ # parameter wins; otherwise it is derived best-effort from the UI
904
+ # tree's package attributes — a pure string scan, no device call.
905
+ extra_metadata = dict(extra_metadata or {})
906
+ try:
907
+ app = foreground_app or _derive_foreground_app(ui_tree)
908
+ if app:
909
+ extra_metadata.setdefault("foreground_app", app)
910
+ except (AttributeError, TypeError, ValueError):
911
+ pass
912
+
913
+ # Stamp perceptual hashes for the local screen-similarity hint
914
+ # (pure PIL dHash; best-effort, computed synchronously to keep the
915
+ # record immutable once handed to the background writer).
916
+ try:
917
+ from artemis.utils.image_hash import dhash_hex
918
+
919
+ pre_dhash = dhash_hex(pre_screenshot_bytes)
920
+ if pre_dhash:
921
+ extra_metadata.setdefault("pre_image_dhash", pre_dhash)
922
+ post_dhash = dhash_hex(post_screenshot_bytes) if post_image_name else pre_dhash
923
+ if post_dhash:
924
+ extra_metadata.setdefault("post_image_dhash", post_dhash)
925
+ except ImportError:
926
+ pass
927
+
928
+ step = StepRecord(
929
+ step_id=step_id,
930
+ session_id=self.current_session_id,
931
+ step_number=step_number,
932
+ timestamp=time.time(),
933
+ pre_image_name=pre_image_name,
934
+ post_image_name=post_image_name,
935
+ summary=summary,
936
+ action_taken=action_taken,
937
+ operator_raw_thinking=operator_raw_thinking,
938
+ operator_native_thinking=operator_native_thinking,
939
+ last_execution_result=last_execution_result,
940
+ extra_metadata=extra_metadata,
941
+ )
942
+
943
+ def _run_background_storage():
944
+ if pre_screenshot_bytes:
945
+ self.get_or_create_image(pre_screenshot_bytes, ui_tree, ocr_result)
946
+ if post_screenshot_bytes:
947
+ self.get_or_create_image(post_screenshot_bytes)
948
+ self.storage.create_step(step)
949
+
950
+ # Run storage in the shared background scheduler. It chooses an
951
+ # asyncio worker when a loop is active and a tracked thread otherwise.
952
+ self._run_in_background(_run_background_storage)
953
+
954
+ logger.info(f"Recorded step {step_number} for session {self.current_session_id}")
955
+
956
+ # Publish event with relative time
957
+ step_dict = step.model_dump()
958
+ step_dict["relative_time"] = self.get_relative_time(step.timestamp)
959
+
960
+ # Include generic tools in the real-time event
961
+ try:
962
+ traces = self.storage.get_step_traces(step_id)
963
+ generic_tools = []
964
+ for t in traces:
965
+ if t.type in ("tool", "action") or (t.type == "llm_call" and t.status == "failed"):
966
+ t_dict = t.model_dump()
967
+ if t.parent_trace_id and "agent_name" not in t_dict:
968
+ parent_name = getattr(self, "_trace_name_cache", {}).get(
969
+ t.parent_trace_id
970
+ ) or getattr(self, "_trace_name_cache", {}).get(str(t.parent_trace_id))
971
+ if parent_name:
972
+ t_dict["agent_name"] = parent_name
973
+ generic_tools.append(t_dict)
974
+ step_dict["generic_tools"] = generic_tools
975
+ except Exception as e:
976
+ logger.error(f"Failed to fetch step traces for SSE: {e}")
977
+ step_dict["generic_tools"] = []
978
+
979
+ # Extract and attach token usage for real-time SSE stream
980
+ step_p, step_c, step_t = 0, 0, 0
981
+ if extra_metadata and isinstance(extra_metadata, dict) and "token_usage" in extra_metadata:
982
+ u = extra_metadata["token_usage"]
983
+ if isinstance(u, dict):
984
+ step_p = int(u.get("prompt_tokens") or u.get("input_tokens") or 0)
985
+ step_c = int(u.get("completion_tokens") or u.get("output_tokens") or 0)
986
+ step_t = int(u.get("total_tokens") or (step_p + step_c))
987
+
988
+ if step_t == 0:
989
+ token_info = self._get_step_token_usage_for_sse(step_id)
990
+ if token_info:
991
+ step_p = token_info["prompt_tokens"]
992
+ step_c = token_info["completion_tokens"]
993
+ step_t = token_info["total_tokens"]
994
+
995
+ if step_t > 0:
996
+ step_dict["token_usage"] = {
997
+ "prompt_tokens": step_p,
998
+ "completion_tokens": step_c,
999
+ "total_tokens": step_t,
1000
+ }
1001
+ step_dict["total_tokens"] = step_t
1002
+
1003
+ step_dict = normalize_step_actions(step_dict)
1004
+
1005
+ self._publish("step_recorded", step_dict)
1006
+
1007
+ return step_id
1008
+
1009
+ def record_trace(
1010
+ self,
1011
+ type: str,
1012
+ name: str,
1013
+ payload: dict[str, Any],
1014
+ step_id: UUID | None = None,
1015
+ parent_trace_id: UUID | None = None,
1016
+ status: str = "success",
1017
+ duration: float | None = None,
1018
+ trace_id: UUID | None = None,
1019
+ ) -> UUID:
1020
+ """Record a trace (agent, tool, or log), non-blocking."""
1021
+ if not self.current_session_id:
1022
+ raise ValueError("No active session. Call start_session first.")
1023
+
1024
+ trace_id = trace_id or uuid4()
1025
+
1026
+ with self.lock:
1027
+ self._trace_counter = getattr(self, "_trace_counter", 0) + 1
1028
+ trace_ts = time.time() + (self._trace_counter * 1e-7)
1029
+ if not hasattr(self, "_trace_name_cache"):
1030
+ self._trace_name_cache = {}
1031
+ self._trace_name_cache[trace_id] = name
1032
+ self._trace_name_cache[str(trace_id)] = name
1033
+
1034
+ trace = TraceRecord(
1035
+ trace_id=trace_id,
1036
+ session_id=self.current_session_id,
1037
+ step_id=step_id,
1038
+ parent_trace_id=parent_trace_id,
1039
+ type=type,
1040
+ name=name,
1041
+ timestamp=trace_ts,
1042
+ duration=duration,
1043
+ status=status,
1044
+ payload=payload,
1045
+ )
1046
+
1047
+ self._run_in_background(self.storage.create_trace, trace)
1048
+
1049
+ # Publish event
1050
+ trace_dict = trace.model_dump()
1051
+ if parent_trace_id:
1052
+ parent_name = getattr(self, "_trace_name_cache", {}).get(parent_trace_id) or getattr(
1053
+ self, "_trace_name_cache", {}
1054
+ ).get(str(parent_trace_id))
1055
+ if parent_name:
1056
+ trace_dict["agent_name"] = parent_name
1057
+ self._publish("trace_recorded", trace_dict)
1058
+
1059
+ return trace_id
1060
+
1061
+ def record_failed_output(
1062
+ self,
1063
+ trace_id: UUID,
1064
+ model_name: str,
1065
+ prompt: str,
1066
+ raw_output: str,
1067
+ error_message: str,
1068
+ ):
1069
+ """Record a malformed output (crime scene) for later training."""
1070
+ if not self.current_session_id:
1071
+ return
1072
+
1073
+ record = FailedOutputRecord(
1074
+ session_id=self.current_session_id,
1075
+ trace_id=trace_id,
1076
+ model_name=model_name,
1077
+ prompt=prompt,
1078
+ raw_output=raw_output,
1079
+ error_message=error_message,
1080
+ )
1081
+
1082
+ self._run_in_background(self.storage.create_failed_output, record)
1083
+
1084
+ def has_pending_operations(self) -> bool:
1085
+ """Check if there are any pending background tasks or threads."""
1086
+ return (
1087
+ len(self._pending_tasks) > 0
1088
+ or len([t for t in self._pending_threads if t.is_alive()]) > 0
1089
+ )
1090
+
1091
+ async def shutdown(self):
1092
+ """Wait for all pending background tasks and threads to complete."""
1093
+ if self._pending_tasks:
1094
+ logger.info(
1095
+ f"Waiting for {len(self._pending_tasks)} pending async tasks to complete..."
1096
+ )
1097
+ await asyncio.gather(*self._pending_tasks, return_exceptions=True)
1098
+
1099
+ if self._pending_threads:
1100
+ logger.info(f"Waiting for {len(self._pending_threads)} pending threads to complete...")
1101
+ threads_to_join = list(self._pending_threads)
1102
+ for thread in threads_to_join:
1103
+ if thread.is_alive():
1104
+ await asyncio.to_thread(thread.join)
1105
+
1106
+ with self._ipc_socket_lock:
1107
+ self._ipc_shutdown = True
1108
+ if self.ipc_socket is not None:
1109
+ try:
1110
+ self.ipc_socket.close()
1111
+ except OSError:
1112
+ pass
1113
+ self.ipc_socket = None
1114
+
1115
+ logger.info("DataEngine shutdown complete. All data persisted.")
1116
+
1117
+ def stream_output(
1118
+ self,
1119
+ execution_id: UUID,
1120
+ chunk: str,
1121
+ stream_type: str | None = None,
1122
+ is_thinking: bool | None = None,
1123
+ ):
1124
+ """Stream LLM output chunks."""
1125
+ if stream_type is None:
1126
+ if is_thinking is True:
1127
+ stream_type = "thinking"
1128
+ else:
1129
+ stream_type = "text"
1130
+
1131
+ parent_id = CURRENT_TRACE_ID.get()
1132
+ session_id_str = str(self.current_session_id) if self.current_session_id else None
1133
+
1134
+ self._publish(
1135
+ "llm_stream",
1136
+ {
1137
+ "execution_id": str(execution_id),
1138
+ "session_id": session_id_str,
1139
+ "parent_trace_id": str(parent_id) if parent_id else None,
1140
+ "step_id": (str(self.current_step_id) if self.current_step_id else None),
1141
+ "chunk": chunk,
1142
+ "stream_type": stream_type,
1143
+ "is_thinking": (
1144
+ is_thinking if is_thinking is not None else (stream_type == "thinking")
1145
+ ),
1146
+ },
1147
+ )
1148
+
1149
+ exec_str = str(execution_id)
1150
+ if not hasattr(self, "_accumulated_logs"):
1151
+ self._accumulated_logs = {}
1152
+ if exec_str not in self._accumulated_logs:
1153
+ self._accumulated_logs[exec_str] = ""
1154
+ self._accumulated_logs[exec_str] += chunk
1155
+
1156
+ if session_id_str:
1157
+ if session_id_str not in self._accumulated_logs:
1158
+ self._accumulated_logs[session_id_str] = ""
1159
+ self._accumulated_logs[session_id_str] += chunk
1160
+
1161
+ def _get_generic_tools_for_sse(self, step_id: UUID) -> list[dict[str, Any]]:
1162
+ try:
1163
+ traces = self.storage.get_step_traces(step_id)
1164
+ return [t.model_dump() for t in traces if t.type == "tool"]
1165
+ except Exception as e:
1166
+ logger.error(f"Failed to fetch step traces for SSE: {e}")
1167
+ return []
1168
+
1169
+ def _get_step_token_usage_for_sse(self, step_id: UUID) -> dict[str, Any] | None:
1170
+ try:
1171
+ traces = self.storage.get_step_traces(step_id)
1172
+ step_p, step_c, step_t = 0, 0, 0
1173
+ for t in traces:
1174
+ if t.type == "llm_call" and isinstance(t.payload, dict):
1175
+ u = t.payload.get("token_usage") or t.payload.get("usage_metadata")
1176
+ if isinstance(u, dict):
1177
+ p = int(
1178
+ u.get("prompt_tokens")
1179
+ or u.get("prompt_token_count")
1180
+ or u.get("input_tokens")
1181
+ or 0
1182
+ )
1183
+ c = int(
1184
+ u.get("completion_tokens")
1185
+ or u.get("candidates_token_count")
1186
+ or u.get("output_tokens")
1187
+ or 0
1188
+ )
1189
+ tot = int(u.get("total_tokens") or u.get("total_token_count") or (p + c))
1190
+ step_p += p
1191
+ step_c += c
1192
+ step_t += tot
1193
+ if step_t > 0:
1194
+ return {
1195
+ "prompt_tokens": step_p,
1196
+ "completion_tokens": step_c,
1197
+ "total_tokens": step_t,
1198
+ }
1199
+ except Exception as exc:
1200
+ logger.debug(
1201
+ f"Step token usage aggregation for SSE skipped for {step_id}: {exc}",
1202
+ exc_info=True,
1203
+ )
1204
+ return None
1205
+
1206
+ def _on_background_task_done(self, task: asyncio.Task):
1207
+ self._pending_tasks.discard(task)
1208
+ if not task.cancelled() and task.exception():
1209
+ exc = task.exception()
1210
+ logger.error(f"Background task failed with exception: {exc}", exc_info=exc)
1211
+
1212
+ def _run_in_background(self, fn, *args):
1213
+ """Safely offload blocking storage/disk operation to background without stalling event loop."""
1214
+ try:
1215
+ loop = asyncio.get_running_loop()
1216
+ except RuntimeError:
1217
+
1218
+ def run_and_cleanup():
1219
+ try:
1220
+ fn(*args)
1221
+ except Exception as exc:
1222
+ logger.error(f"Background storage operation failed: {exc}", exc_info=exc)
1223
+ finally:
1224
+ curr_thread = threading.current_thread()
1225
+ if curr_thread in self._pending_threads:
1226
+ self._pending_threads.remove(curr_thread)
1227
+
1228
+ thread = threading.Thread(target=run_and_cleanup, name="artemis-storage", daemon=True)
1229
+ self._pending_threads.append(thread)
1230
+ thread.start()
1231
+ else:
1232
+ task = loop.create_task(asyncio.to_thread(fn, *args))
1233
+ self._pending_tasks.add(task)
1234
+ task.add_done_callback(self._on_background_task_done)
1235
+
1236
+ def update_step_action(
1237
+ self,
1238
+ action_taken: dict[str, Any],
1239
+ post_screenshot_bytes: bytes | None = None,
1240
+ ):
1241
+ """Update the current step with action taken and post-action screenshot in background."""
1242
+ with self.lock:
1243
+ step_id = self.current_step_id or self.last_recorded_step_id
1244
+ if not step_id:
1245
+ raise ValueError("No active step to update.")
1246
+ if self.base_dir:
1247
+ step_dir = self.base_dir / "steps" / str(step_id)
1248
+ self.current_step_dir = step_dir
1249
+ step_dir.mkdir(parents=True, exist_ok=True)
1250
+ else:
1251
+ step_dir = None
1252
+
1253
+ def _update_and_write():
1254
+ self.storage.update_step_action(step_id, action_taken)
1255
+ if post_screenshot_bytes and step_dir:
1256
+ step_dir.mkdir(parents=True, exist_ok=True)
1257
+ with open(step_dir / "post.jpg", "wb") as f:
1258
+ f.write(post_screenshot_bytes)
1259
+
1260
+ self._run_in_background(_update_and_write)
1261
+
1262
+ step_num = self.get_step_number(step_id)
1263
+ update_payload = {
1264
+ "step_id": str(step_id),
1265
+ "action_taken": action_taken,
1266
+ "generic_tools": self._get_generic_tools_for_sse(step_id),
1267
+ }
1268
+ if step_num is not None:
1269
+ update_payload["step_number"] = step_num
1270
+
1271
+ tokens = self._get_step_token_usage_for_sse(step_id)
1272
+ if tokens:
1273
+ update_payload["token_usage"] = tokens
1274
+ update_payload["total_tokens"] = tokens["total_tokens"]
1275
+
1276
+ self._publish("step_updated", update_payload)
1277
+
1278
+ def get_step_number(self, step_id: UUID | int | str | None) -> int | None:
1279
+ """Resolve the 1-based sequential step number for a step ID."""
1280
+ if step_id is None:
1281
+ return None
1282
+ if isinstance(step_id, int):
1283
+ return step_id
1284
+ sid = str(step_id)
1285
+ cached = self._step_number_cache.get(sid)
1286
+ if cached is not None:
1287
+ return cached
1288
+ if self.storage and hasattr(self.storage, "get_step"):
1289
+ try:
1290
+ target_uuid = UUID(sid) if isinstance(step_id, str) else step_id
1291
+ step_record = self.storage.get_step(target_uuid)
1292
+ if step_record and getattr(step_record, "step_number", None) is not None:
1293
+ self._step_number_cache[sid] = int(step_record.step_number)
1294
+ return int(step_record.step_number)
1295
+ except Exception as exc:
1296
+ logger.debug(
1297
+ f"Step number lookup from storage skipped for {sid}: {exc}", exc_info=True
1298
+ )
1299
+ return None
1300
+
1301
+ def get_step_record(self, step_number: int):
1302
+ """Read-only lookup of a step record by its 1-based step number."""
1303
+ if not self.current_session_id:
1304
+ return None
1305
+ try:
1306
+ steps = self.storage.get_steps(self.current_session_id) or []
1307
+ except Exception as e:
1308
+ logger.error(f"Failed to fetch steps for step lookup: {e}")
1309
+ return None
1310
+ for s in steps:
1311
+ if s.step_number == step_number:
1312
+ return s
1313
+ return None
1314
+
1315
+ def get_step_image_path(self, step_number: int, which: str = "pre") -> Path | None:
1316
+ """Read-only path of a step's pre/post screenshot, if recorded."""
1317
+ step = self.get_step_record(step_number)
1318
+ if step is None:
1319
+ return None
1320
+ image_name = step.pre_image_name if which == "pre" else step.post_image_name
1321
+ if not image_name:
1322
+ return None
1323
+ path = self.get_image_path(image_name)
1324
+ return path if path.exists() else None
1325
+
1326
+ def update_step_summary(
1327
+ self,
1328
+ step_id: UUID | int | str,
1329
+ summary: str | None,
1330
+ *,
1331
+ source: str | None = None,
1332
+ version: int | None = None,
1333
+ model: str | None = None,
1334
+ status: str = "ready",
1335
+ ):
1336
+ """Update the step summary in background, with status/version metadata.
1337
+
1338
+ Writes ``summary_status`` / ``summary_source`` / ``summary_version`` /
1339
+ ``summary_model`` into the step's ``extra_metadata`` alongside the
1340
+ summary text; concurrent same-step writes are ordered by version in the
1341
+ storage layer. ``status`` may be ``pending`` / ``ready`` / ``failed`` /
1342
+ ``stale``; only ``ready`` writes carry summary text and publish SSE.
1343
+ """
1344
+ target_uuid = None
1345
+ if isinstance(step_id, int):
1346
+ if self.current_session_id:
1347
+ steps = self.storage.get_steps(self.current_session_id)
1348
+ for s in steps:
1349
+ if s.step_number == step_id:
1350
+ target_uuid = s.step_id
1351
+ break
1352
+ elif isinstance(step_id, str):
1353
+ try:
1354
+ target_uuid = UUID(step_id)
1355
+ except ValueError:
1356
+ target_uuid = step_id
1357
+ else:
1358
+ target_uuid = step_id
1359
+
1360
+ if not target_uuid:
1361
+ logger.debug(f"Could not resolve step_id for {step_id} to update summary.")
1362
+ return
1363
+
1364
+ self._run_in_background(
1365
+ functools.partial(
1366
+ self.storage.update_step_summary,
1367
+ target_uuid,
1368
+ summary,
1369
+ source=source,
1370
+ version=version,
1371
+ model=model,
1372
+ status=status,
1373
+ )
1374
+ )
1375
+ if status != "ready" or summary is None:
1376
+ return
1377
+ step_num = self.get_step_number(target_uuid if not isinstance(step_id, int) else step_id)
1378
+ payload = {
1379
+ "step_id": str(target_uuid),
1380
+ "summary": summary,
1381
+ "generic_tools": self._get_generic_tools_for_sse(target_uuid),
1382
+ }
1383
+ if step_num is not None:
1384
+ payload["step_number"] = step_num
1385
+ self._publish("step_updated", payload)
1386
+
1387
+ def record_history_chunk(
1388
+ self,
1389
+ *,
1390
+ start_step_number: int,
1391
+ end_step_number: int,
1392
+ version: int,
1393
+ status: str,
1394
+ start_step_id: str | None = None,
1395
+ end_step_id: str | None = None,
1396
+ source_step_ids: list[str] | None = None,
1397
+ subgoal_hash: str | None = None,
1398
+ band1: dict[str, Any] | None = None,
1399
+ band2: str | None = None,
1400
+ band3: str | None = None,
1401
+ rendered_text: str | None = None,
1402
+ ) -> UUID | None:
1403
+ """Persist one history-chunk version row in the background (append-only).
1404
+
1405
+ Chunk identity is the step range; callers own version numbering (a
1406
+ newer version for the same range supersedes older ones at read time).
1407
+ """
1408
+ if not self.current_session_id:
1409
+ return None
1410
+ chunk = HistoryChunkRecord(
1411
+ chunk_id=uuid4(),
1412
+ session_id=self.current_session_id,
1413
+ start_step_id=start_step_id,
1414
+ end_step_id=end_step_id,
1415
+ start_step_number=start_step_number,
1416
+ end_step_number=end_step_number,
1417
+ source_step_ids=source_step_ids or [],
1418
+ subgoal_hash=subgoal_hash,
1419
+ version=version,
1420
+ status=status,
1421
+ band1=band1 or {},
1422
+ band2=band2,
1423
+ band3=band3,
1424
+ rendered_text=rendered_text,
1425
+ )
1426
+ self._run_in_background(self.storage.create_history_chunk, chunk)
1427
+ return chunk.chunk_id
1428
+
1429
+ def get_history_chunks(self, *, all_versions: bool = False) -> list[HistoryChunkRecord]:
1430
+ """History chunks of the active session (newest version per step range)."""
1431
+ if not self.current_session_id:
1432
+ return []
1433
+ try:
1434
+ return self.storage.get_history_chunks(
1435
+ self.current_session_id, all_versions=all_versions
1436
+ )
1437
+ except Exception as e:
1438
+ logger.error(f"Failed to fetch history chunks: {e}")
1439
+ return []
1440
+
1441
+ def update_step_thinking(self, step_id: UUID, operator_raw_thinking: str):
1442
+ """Update step's thinking in SQLite in background."""
1443
+ self._run_in_background(self.storage.update_step_thinking, step_id, operator_raw_thinking)
1444
+ step_num = self.get_step_number(step_id)
1445
+ payload = {
1446
+ "step_id": str(step_id),
1447
+ "operator_raw_thinking": operator_raw_thinking,
1448
+ "generic_tools": self._get_generic_tools_for_sse(step_id),
1449
+ }
1450
+ if step_num is not None:
1451
+ payload["step_number"] = step_num
1452
+ self._publish("step_updated", payload)
1453
+
1454
+ def update_step_native_thinking(self, step_id: UUID, operator_native_thinking: str):
1455
+ """Update step's native thinking in SQLite in background."""
1456
+ self._run_in_background(
1457
+ self.storage.update_step_native_thinking,
1458
+ step_id,
1459
+ operator_native_thinking,
1460
+ )
1461
+ step_num = self.get_step_number(step_id)
1462
+ payload = {
1463
+ "step_id": str(step_id),
1464
+ "operator_native_thinking": operator_native_thinking,
1465
+ "generic_tools": self._get_generic_tools_for_sse(step_id),
1466
+ }
1467
+ if step_num is not None:
1468
+ payload["step_number"] = step_num
1469
+ self._publish("step_updated", payload)
1470
+
1471
+ def update_step_execution_result(
1472
+ self,
1473
+ step_id: UUID,
1474
+ last_execution_result: dict,
1475
+ post_image_name: str | None = None,
1476
+ ):
1477
+ """Update step's execution result and post_image_name in SQLite in background."""
1478
+ self._run_in_background(
1479
+ self.storage.update_step_execution_result,
1480
+ step_id,
1481
+ last_execution_result,
1482
+ post_image_name,
1483
+ )
1484
+ step_num = self.get_step_number(step_id)
1485
+ payload = {
1486
+ "step_id": str(step_id),
1487
+ "last_execution_result": last_execution_result,
1488
+ "post_image_name": post_image_name,
1489
+ "generic_tools": self._get_generic_tools_for_sse(step_id),
1490
+ }
1491
+ if step_num is not None:
1492
+ payload["step_number"] = step_num
1493
+ self._publish("step_updated", payload)
1494
+
1495
+ def get_relative_time(self, timestamp: float) -> str:
1496
+ """Compute relative time since session start."""
1497
+ if self.session_start_time is None:
1498
+ return "0.0s"
1499
+ diff = timestamp - self.session_start_time
1500
+ return f"{diff:.1f}s"
1501
+
1502
+ def allocate_step_id(self) -> UUID:
1503
+ """Pre-allocate a step ID for the upcoming step to synchronize traces."""
1504
+ self.current_step_id = uuid4()
1505
+ if self.base_dir:
1506
+ self.current_step_dir = self.base_dir / "steps" / str(self.current_step_id)
1507
+ self.current_step_dir.mkdir(parents=True, exist_ok=True)
1508
+ return self.current_step_id
1509
+
1510
+ def get_agent_friendly_steps(self) -> list[dict[str, Any]]:
1511
+ """Retrieve steps formatted for agent consumption (relative time)."""
1512
+ if not self.current_session_id:
1513
+ return []
1514
+ steps_with_traces = self.storage.get_steps_with_traces(self.current_session_id)
1515
+ describer = build_image_describer([step for step, _ in steps_with_traces])
1516
+ return [self._friendly_step(step, traces, describer) for step, traces in steps_with_traces]
1517
+
1518
+ def get_agent_friendly_steps_in_range(
1519
+ self, start_step: int, end_step: int | None = None
1520
+ ) -> list[dict[str, Any]]:
1521
+ """Agent-friendly records of steps ``start_step``..``end_step`` (inclusive,
1522
+ 1-based) without loading the traces of the whole session. ``end_step``
1523
+ defaults to ``start_step`` (a single step)."""
1524
+ if not self.current_session_id:
1525
+ return []
1526
+ end_step = start_step if end_step is None else end_step
1527
+ if start_step > end_step:
1528
+ start_step, end_step = end_step, start_step
1529
+ try:
1530
+ steps = self.storage.get_steps(self.current_session_id) or []
1531
+ except Exception as e:
1532
+ logger.error(f"Failed to fetch steps for range lookup: {e}")
1533
+ return []
1534
+ describer = build_image_describer(steps)
1535
+ result = []
1536
+ for step in steps:
1537
+ if start_step <= step.step_number <= end_step:
1538
+ traces = self.storage.get_traces_for_step(step.step_id)
1539
+ result.append(self._friendly_step(step, traces, describer))
1540
+ return result
1541
+
1542
+ def get_agent_friendly_step(self, step_number: int) -> dict[str, Any] | None:
1543
+ """Agent-friendly record of one step by its 1-based number, or None."""
1544
+ rows = self.get_agent_friendly_steps_in_range(step_number, step_number)
1545
+ return rows[0] if rows else None
1546
+
1547
+ def _friendly_step(
1548
+ self,
1549
+ step: StepRecord,
1550
+ traces: list[TraceRecord],
1551
+ image_describer: Callable[[str | None], dict[str, Any]] | None = None,
1552
+ ) -> dict[str, Any]:
1553
+ """Live twin of :func:`friendly_step`: the session clock and the device
1554
+ dimensions come from this engine's context."""
1555
+ device = self.ctx.device if self.ctx else None
1556
+ return friendly_step(
1557
+ step,
1558
+ traces,
1559
+ image_describer,
1560
+ session_start_time=self.session_start_time,
1561
+ default_width=getattr(device, "device_width", 1080) if device else 1080,
1562
+ default_height=getattr(device, "device_height", 2400) if device else 2400,
1563
+ )
1564
+
1565
+ def register_background_task(self, task_id: str, summary: str, trace_id: UUID | None = None):
1566
+ if not self.current_session_id:
1567
+ return
1568
+
1569
+ record = BackgroundTaskRecord(
1570
+ task_id=task_id,
1571
+ session_id=self.current_session_id,
1572
+ summary=summary,
1573
+ status="running",
1574
+ start_time=time.time(),
1575
+ trace_id=str(trace_id) if trace_id else None,
1576
+ )
1577
+
1578
+ if trace_id:
1579
+ if not hasattr(self, "_bg_task_to_trace_id"):
1580
+ self._bg_task_to_trace_id = {}
1581
+ self._bg_task_to_trace_id[task_id] = str(trace_id)
1582
+
1583
+ self._run_in_background(self.storage.create_background_task, record)
1584
+
1585
+ self._publish("background_tasks_updated", self.get_all_background_tasks())
1586
+
1587
+ def unregister_background_task(self, task_id: str, status: str = "completed"):
1588
+ end_time = time.time()
1589
+
1590
+ trace_id_str = None
1591
+ if hasattr(self, "_bg_task_to_trace_id"):
1592
+ trace_id_str = self._bg_task_to_trace_id.pop(task_id, None)
1593
+
1594
+ logs = ""
1595
+ if trace_id_str and hasattr(self, "_accumulated_logs"):
1596
+ logs = self._accumulated_logs.pop(trace_id_str, "")
1597
+
1598
+ self._run_in_background(
1599
+ self.storage.update_background_task_status_and_logs,
1600
+ task_id,
1601
+ status,
1602
+ end_time,
1603
+ logs,
1604
+ )
1605
+
1606
+ self._publish("background_tasks_updated", self.get_all_background_tasks())
1607
+
1608
+ def get_all_background_tasks(self) -> list[dict]:
1609
+ if not self.current_session_id:
1610
+ return []
1611
+ records = self.storage.get_background_tasks(self.current_session_id)
1612
+ return [r.model_dump() for r in records]
1613
+
1614
+ def get_active_background_tasks(self) -> list[dict]:
1615
+ return self.get_all_background_tasks()