mobster-cli 0.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (1009) hide show
  1. mobile_agent/__init__.py +4 -0
  2. mobile_agent/__main__.py +598 -0
  3. mobile_agent/accounts/__init__.py +1 -0
  4. mobile_agent/accounts/context.py +80 -0
  5. mobile_agent/accounts/mcp.py +55 -0
  6. mobile_agent/accounts/pacing.py +318 -0
  7. mobile_agent/accounts/register.py +78 -0
  8. mobile_agent/accounts/routes.py +212 -0
  9. mobile_agent/accounts/routing.py +69 -0
  10. mobile_agent/accounts/screen.py +273 -0
  11. mobile_agent/accounts/stats.py +54 -0
  12. mobile_agent/accounts/store.py +394 -0
  13. mobile_agent/agent_hooks.py +123 -0
  14. mobile_agent/ai_plans/__init__.py +7 -0
  15. mobile_agent/ai_plans/agents.py +96 -0
  16. mobile_agent/ai_plans/claude_code.py +208 -0
  17. mobile_agent/ai_plans/cli.py +400 -0
  18. mobile_agent/ai_plans/codex.py +258 -0
  19. mobile_agent/ai_plans/compat_chat.py +307 -0
  20. mobile_agent/ai_plans/device_login.py +164 -0
  21. mobile_agent/ai_plans/draft_client.py +68 -0
  22. mobile_agent/ai_plans/gemini.py +137 -0
  23. mobile_agent/ai_plans/instructions.py +65 -0
  24. mobile_agent/ai_plans/mcp_http.py +160 -0
  25. mobile_agent/ai_plans/phone_tools.py +892 -0
  26. mobile_agent/ai_plans/providers.py +235 -0
  27. mobile_agent/ai_plans/routes.py +160 -0
  28. mobile_agent/ai_plans/runner.py +400 -0
  29. mobile_agent/ai_plans/vault.py +114 -0
  30. mobile_agent/alerts.py +337 -0
  31. mobile_agent/android/__init__.py +18 -0
  32. mobile_agent/android/adb.py +268 -0
  33. mobile_agent/android/conditions.py +128 -0
  34. mobile_agent/android/doctor.py +54 -0
  35. mobile_agent/android/driver.py +137 -0
  36. mobile_agent/android/emulator.py +216 -0
  37. mobile_agent/android/logs.py +85 -0
  38. mobile_agent/android/manager.py +225 -0
  39. mobile_agent/android/records.py +31 -0
  40. mobile_agent/android/tree.py +96 -0
  41. mobile_agent/answer_shapes.py +230 -0
  42. mobile_agent/api_devices.py +333 -0
  43. mobile_agent/api_errors.py +17 -0
  44. mobile_agent/api_get.py +249 -0
  45. mobile_agent/api_http.py +467 -0
  46. mobile_agent/api_post.py +453 -0
  47. mobile_agent/api_routes.py +173 -0
  48. mobile_agent/app_api/__init__.py +4 -0
  49. mobile_agent/app_api/cli.py +151 -0
  50. mobile_agent/app_api/endpoints.py +193 -0
  51. mobile_agent/app_api/mcp.py +54 -0
  52. mobile_agent/app_api/openapi.py +146 -0
  53. mobile_agent/app_api/reading.py +172 -0
  54. mobile_agent/app_api/register.py +10 -0
  55. mobile_agent/app_api/screen.py +216 -0
  56. mobile_agent/app_api/service.py +382 -0
  57. mobile_agent/app_api/sessions.py +404 -0
  58. mobile_agent/app_api/tokens.py +167 -0
  59. mobile_agent/app_choice.py +131 -0
  60. mobile_agent/app_icons.py +183 -0
  61. mobile_agent/app_killer.py +69 -0
  62. mobile_agent/artifacts.py +606 -0
  63. mobile_agent/attach/__init__.py +13 -0
  64. mobile_agent/attach/cli.py +46 -0
  65. mobile_agent/attach/flutter.py +28 -0
  66. mobile_agent/attach/score.py +119 -0
  67. mobile_agent/attach/web.py +13 -0
  68. mobile_agent/attachments/__init__.py +1 -0
  69. mobile_agent/attachments/agent.py +418 -0
  70. mobile_agent/attachments/extract.py +217 -0
  71. mobile_agent/attachments/mcp.py +104 -0
  72. mobile_agent/attachments/register.py +73 -0
  73. mobile_agent/attachments/routes.py +326 -0
  74. mobile_agent/attachments/service.py +170 -0
  75. mobile_agent/attachments/sniff.py +152 -0
  76. mobile_agent/attachments/store.py +441 -0
  77. mobile_agent/audit_verify.py +436 -0
  78. mobile_agent/bench/README.md +410 -0
  79. mobile_agent/bench/__init__.py +0 -0
  80. mobile_agent/bench/__main__.py +394 -0
  81. mobile_agent/bench/ab.py +168 -0
  82. mobile_agent/bench/actuation.py +368 -0
  83. mobile_agent/bench/agents/__init__.py +9 -0
  84. mobile_agent/bench/agents/base.py +105 -0
  85. mobile_agent/bench/agents/plan_clients.py +245 -0
  86. mobile_agent/bench/agents/smart.py +533 -0
  87. mobile_agent/bench/checks.py +358 -0
  88. mobile_agent/bench/decide_gate.py +214 -0
  89. mobile_agent/bench/diagnose.py +181 -0
  90. mobile_agent/bench/evals/__init__.py +14 -0
  91. mobile_agent/bench/evals/build.py +565 -0
  92. mobile_agent/bench/evals/checkpoint.py +214 -0
  93. mobile_agent/bench/evals/irt.py +219 -0
  94. mobile_agent/bench/evals/l0.py +647 -0
  95. mobile_agent/bench/evals/l1.py +866 -0
  96. mobile_agent/bench/evals/models.py +124 -0
  97. mobile_agent/bench/evals/pairwise.py +189 -0
  98. mobile_agent/bench/evals/proxy.py +90 -0
  99. mobile_agent/bench/evals/sets.py +149 -0
  100. mobile_agent/bench/evals/traces.py +446 -0
  101. mobile_agent/bench/fixtures.py +176 -0
  102. mobile_agent/bench/guard_replay.py +129 -0
  103. mobile_agent/bench/h2-manifest.json +20 -0
  104. mobile_agent/bench/h3-manifest.json +20 -0
  105. mobile_agent/bench/heldout-manifest.json +32 -0
  106. mobile_agent/bench/heldout.py +375 -0
  107. mobile_agent/bench/iosworld.py +901 -0
  108. mobile_agent/bench/iosworld_stats.py +510 -0
  109. mobile_agent/bench/llm_units.py +116 -0
  110. mobile_agent/bench/manifest.json +93 -0
  111. mobile_agent/bench/metrics.py +66 -0
  112. mobile_agent/bench/phone.py +44 -0
  113. mobile_agent/bench/replay.py +172 -0
  114. mobile_agent/bench/report.py +207 -0
  115. mobile_agent/bench/runner.py +456 -0
  116. mobile_agent/bench/safety.py +310 -0
  117. mobile_agent/bench/stream_timing.py +213 -0
  118. mobile_agent/bench/suite.py +444 -0
  119. mobile_agent/bench/sweep.py +176 -0
  120. mobile_agent/bench/tape.py +798 -0
  121. mobile_agent/bench/tape_replay.py +88 -0
  122. mobile_agent/bench/trace_evals.py +128 -0
  123. mobile_agent/bench/trace_view.py +81 -0
  124. mobile_agent/bench/truth/iphone15pro.json +138 -0
  125. mobile_agent/bench/truth.py +340 -0
  126. mobile_agent/bench/tuning.py +543 -0
  127. mobile_agent/bench/tuning_analysis.py +656 -0
  128. mobile_agent/bench/vague-heldout-manifest.json +12 -0
  129. mobile_agent/bench/vague_rubric.py +135 -0
  130. mobile_agent/bench/vague_run.py +243 -0
  131. mobile_agent/brand.py +454 -0
  132. mobile_agent/brand_fonts.py +756 -0
  133. mobile_agent/build_identity.py +21 -0
  134. mobile_agent/cable_bt.py +623 -0
  135. mobile_agent/cable_bt_runtime.py +128 -0
  136. mobile_agent/catalog.py +69 -0
  137. mobile_agent/chart_svg.py +415 -0
  138. mobile_agent/chat_here.py +189 -0
  139. mobile_agent/cli_constants.py +5 -0
  140. mobile_agent/cli_parser.py +611 -0
  141. mobile_agent/cli_run.py +115 -0
  142. mobile_agent/compiled_read/__init__.py +21 -0
  143. mobile_agent/compiled_read/delta.py +89 -0
  144. mobile_agent/compiled_read/extractors.py +202 -0
  145. mobile_agent/compiled_read/routes.py +56 -0
  146. mobile_agent/completion.py +267 -0
  147. mobile_agent/compose.py +48 -0
  148. mobile_agent/config.py +114 -0
  149. mobile_agent/connectors/__init__.py +1 -0
  150. mobile_agent/connectors/api_session.py +76 -0
  151. mobile_agent/connectors/callback_page.py +146 -0
  152. mobile_agent/connectors/client.py +582 -0
  153. mobile_agent/connectors/github_app.py +55 -0
  154. mobile_agent/connectors/graph_api.py +396 -0
  155. mobile_agent/connectors/imports.py +170 -0
  156. mobile_agent/connectors/issue.py +66 -0
  157. mobile_agent/connectors/keystore.py +85 -0
  158. mobile_agent/connectors/marks.py +222 -0
  159. mobile_agent/connectors/oauth.py +476 -0
  160. mobile_agent/connectors/picker.py +228 -0
  161. mobile_agent/connectors/presets.py +591 -0
  162. mobile_agent/connectors/register.py +48 -0
  163. mobile_agent/connectors/routes.py +153 -0
  164. mobile_agent/connectors/service.py +966 -0
  165. mobile_agent/connectors/shared.py +136 -0
  166. mobile_agent/connectors/skill.py +446 -0
  167. mobile_agent/connectors/slack_api.py +269 -0
  168. mobile_agent/connectors/steps.py +92 -0
  169. mobile_agent/connectors/store.py +144 -0
  170. mobile_agent/connectors/vault.py +72 -0
  171. mobile_agent/console.py +178 -0
  172. mobile_agent/content_queue/__init__.py +1 -0
  173. mobile_agent/content_queue/client.py +263 -0
  174. mobile_agent/content_queue/importing.py +153 -0
  175. mobile_agent/content_queue/media_check.py +224 -0
  176. mobile_agent/content_queue/prompt.py +64 -0
  177. mobile_agent/content_queue/register.py +91 -0
  178. mobile_agent/content_queue/routes.py +370 -0
  179. mobile_agent/content_queue/runner.py +475 -0
  180. mobile_agent/content_queue/store.py +521 -0
  181. mobile_agent/contract.py +996 -0
  182. mobile_agent/contract_asks.py +248 -0
  183. mobile_agent/contract_prompt.py +45 -0
  184. mobile_agent/contract_screen.py +981 -0
  185. mobile_agent/contract_view.py +84 -0
  186. mobile_agent/corpus/__init__.py +26 -0
  187. mobile_agent/corpus/cli.py +63 -0
  188. mobile_agent/corpus/consent.py +55 -0
  189. mobile_agent/corpus/listener.py +76 -0
  190. mobile_agent/corpus/register.py +9 -0
  191. mobile_agent/corpus/skeleton.py +72 -0
  192. mobile_agent/corpus/store.py +143 -0
  193. mobile_agent/costs.py +161 -0
  194. mobile_agent/decider_scope.py +40 -0
  195. mobile_agent/decision_provider.py +189 -0
  196. mobile_agent/deeplinks.py +352 -0
  197. mobile_agent/device_info.py +116 -0
  198. mobile_agent/device_manager.py +978 -0
  199. mobile_agent/device_status.py +195 -0
  200. mobile_agent/device_targets.py +217 -0
  201. mobile_agent/devices.py +676 -0
  202. mobile_agent/devkit/__init__.py +64 -0
  203. mobile_agent/devkit/account_cli.py +90 -0
  204. mobile_agent/devkit/asc.py +562 -0
  205. mobile_agent/devkit/asc_cli.py +214 -0
  206. mobile_agent/devkit/ci.py +737 -0
  207. mobile_agent/devkit/ci_cli.py +196 -0
  208. mobile_agent/devkit/ci_hosts.py +194 -0
  209. mobile_agent/devkit/es256.py +197 -0
  210. mobile_agent/devkit/expo.py +440 -0
  211. mobile_agent/devkit/expo_cli.py +405 -0
  212. mobile_agent/devkit/expo_dev.py +203 -0
  213. mobile_agent/devkit/expo_routes.py +90 -0
  214. mobile_agent/devkit/fixtures_cli.py +133 -0
  215. mobile_agent/devkit/fixtures_routes.py +82 -0
  216. mobile_agent/devkit/links.py +375 -0
  217. mobile_agent/devkit/links_cli.py +123 -0
  218. mobile_agent/devkit/links_routes.py +83 -0
  219. mobile_agent/devkit/nightly.py +152 -0
  220. mobile_agent/devkit/shots.py +203 -0
  221. mobile_agent/devkit/testflight.py +155 -0
  222. mobile_agent/devkit/testflight_cli.py +127 -0
  223. mobile_agent/devkit/testflight_routes.py +111 -0
  224. mobile_agent/devkit/xcode.py +236 -0
  225. mobile_agent/devkit/xcode_cli.py +101 -0
  226. mobile_agent/devtools.py +419 -0
  227. mobile_agent/doctor.py +509 -0
  228. mobile_agent/doctor_fix.py +467 -0
  229. mobile_agent/downloads.py +55 -0
  230. mobile_agent/drivers.py +793 -0
  231. mobile_agent/dry_run.py +102 -0
  232. mobile_agent/effect_ledger.py +83 -0
  233. mobile_agent/engine_health.py +378 -0
  234. mobile_agent/engine_hooks.py +96 -0
  235. mobile_agent/engines.py +408 -0
  236. mobile_agent/env_file.py +63 -0
  237. mobile_agent/env_secrets.py +149 -0
  238. mobile_agent/errors.py +15 -0
  239. mobile_agent/evals/__init__.py +3 -0
  240. mobile_agent/evals/app_survey.py +149 -0
  241. mobile_agent/evals/development/__init__.py +1 -0
  242. mobile_agent/evals/development/score.py +276 -0
  243. mobile_agent/evals/latency_report.py +312 -0
  244. mobile_agent/evals/offline_report.py +374 -0
  245. mobile_agent/evals/oracles.py +298 -0
  246. mobile_agent/evals/redteam/__init__.py +15 -0
  247. mobile_agent/evals/redteam/__main__.py +72 -0
  248. mobile_agent/evals/redteam/cases.py +730 -0
  249. mobile_agent/evals/redteam/harness.py +604 -0
  250. mobile_agent/evals/redteam/live.py +139 -0
  251. mobile_agent/evals/redteam/plain.py +85 -0
  252. mobile_agent/evals/vision/__init__.py +0 -0
  253. mobile_agent/evals/vision/images/dog_001.jpg +0 -0
  254. mobile_agent/evals/vision/images/dog_002.jpg +0 -0
  255. mobile_agent/evals/vision/images/dog_003.jpg +0 -0
  256. mobile_agent/evals/vision/images/dog_004.jpg +0 -0
  257. mobile_agent/evals/vision/images/dog_005.jpg +0 -0
  258. mobile_agent/evals/vision/images/dog_006.jpg +0 -0
  259. mobile_agent/evals/vision/images/dog_007.jpg +0 -0
  260. mobile_agent/evals/vision/images/dog_010.jpg +0 -0
  261. mobile_agent/evals/vision/images/dog_011.jpg +0 -0
  262. mobile_agent/evals/vision/images/dog_013.jpg +0 -0
  263. mobile_agent/evals/vision/images/dog_014.jpg +0 -0
  264. mobile_agent/evals/vision/images/dog_015.jpg +0 -0
  265. mobile_agent/evals/vision/images/dog_016.jpg +0 -0
  266. mobile_agent/evals/vision/images/dog_017.jpg +0 -0
  267. mobile_agent/evals/vision/images/dog_019.jpg +0 -0
  268. mobile_agent/evals/vision/images/dog_020.jpg +0 -0
  269. mobile_agent/evals/vision/images/dog_021.jpg +0 -0
  270. mobile_agent/evals/vision/images/dog_022.jpg +0 -0
  271. mobile_agent/evals/vision/images/dog_023.jpg +0 -0
  272. mobile_agent/evals/vision/images/dog_024.jpg +0 -0
  273. mobile_agent/evals/vision/images/dog_026.jpg +0 -0
  274. mobile_agent/evals/vision/images/dog_027.jpg +0 -0
  275. mobile_agent/evals/vision/images/dog_034.jpg +0 -0
  276. mobile_agent/evals/vision/images/dog_035.jpg +0 -0
  277. mobile_agent/evals/vision/images/dog_038.jpg +0 -0
  278. mobile_agent/evals/vision/images/dog_042.jpg +0 -0
  279. mobile_agent/evals/vision/images/dog_047.jpg +0 -0
  280. mobile_agent/evals/vision/images/dog_052.jpg +0 -0
  281. mobile_agent/evals/vision/images/dog_058.jpg +0 -0
  282. mobile_agent/evals/vision/images/dog_062.jpg +0 -0
  283. mobile_agent/evals/vision/images/eyes_000.jpg +0 -0
  284. mobile_agent/evals/vision/images/eyes_010.jpg +0 -0
  285. mobile_agent/evals/vision/images/eyes_011.jpg +0 -0
  286. mobile_agent/evals/vision/images/eyes_015.jpg +0 -0
  287. mobile_agent/evals/vision/images/eyes_016.jpg +0 -0
  288. mobile_agent/evals/vision/images/eyes_017.jpg +0 -0
  289. mobile_agent/evals/vision/images/eyes_019.jpg +0 -0
  290. mobile_agent/evals/vision/images/eyes_021.jpg +0 -0
  291. mobile_agent/evals/vision/images/eyes_025.jpg +0 -0
  292. mobile_agent/evals/vision/images/eyes_026.jpg +0 -0
  293. mobile_agent/evals/vision/images/eyes_027.jpg +0 -0
  294. mobile_agent/evals/vision/images/eyes_030.jpg +0 -0
  295. mobile_agent/evals/vision/images/eyes_032.jpg +0 -0
  296. mobile_agent/evals/vision/images/eyes_033.jpg +0 -0
  297. mobile_agent/evals/vision/images/eyes_034.jpg +0 -0
  298. mobile_agent/evals/vision/images/eyes_035.jpg +0 -0
  299. mobile_agent/evals/vision/images/eyes_036.jpg +0 -0
  300. mobile_agent/evals/vision/images/eyes_037.jpg +0 -0
  301. mobile_agent/evals/vision/images/eyes_039.jpg +0 -0
  302. mobile_agent/evals/vision/images/eyes_040.jpg +0 -0
  303. mobile_agent/evals/vision/images/eyes_041.jpg +0 -0
  304. mobile_agent/evals/vision/images/eyes_042.jpg +0 -0
  305. mobile_agent/evals/vision/images/eyes_043.jpg +0 -0
  306. mobile_agent/evals/vision/images/eyes_044.jpg +0 -0
  307. mobile_agent/evals/vision/images/eyes_046.jpg +0 -0
  308. mobile_agent/evals/vision/images/eyes_047.jpg +0 -0
  309. mobile_agent/evals/vision/images/eyes_048.jpg +0 -0
  310. mobile_agent/evals/vision/images/eyes_049.jpg +0 -0
  311. mobile_agent/evals/vision/images/eyes_051.jpg +0 -0
  312. mobile_agent/evals/vision/images/eyes_053.jpg +0 -0
  313. mobile_agent/evals/vision/images/eyes_054.jpg +0 -0
  314. mobile_agent/evals/vision/images/eyes_055.jpg +0 -0
  315. mobile_agent/evals/vision/images/eyes_058.jpg +0 -0
  316. mobile_agent/evals/vision/images/eyes_059.jpg +0 -0
  317. mobile_agent/evals/vision/images/receipt_002.jpg +0 -0
  318. mobile_agent/evals/vision/images/receipt_003.jpg +0 -0
  319. mobile_agent/evals/vision/images/receipt_004.jpg +0 -0
  320. mobile_agent/evals/vision/images/receipt_007.jpg +0 -0
  321. mobile_agent/evals/vision/images/receipt_009.jpg +0 -0
  322. mobile_agent/evals/vision/images/receipt_010.jpg +0 -0
  323. mobile_agent/evals/vision/images/receipt_011.jpg +0 -0
  324. mobile_agent/evals/vision/images/receipt_012.jpg +0 -0
  325. mobile_agent/evals/vision/images/receipt_013.jpg +0 -0
  326. mobile_agent/evals/vision/images/receipt_014.jpg +0 -0
  327. mobile_agent/evals/vision/images/receipt_016.jpg +0 -0
  328. mobile_agent/evals/vision/images/receipt_017.jpg +0 -0
  329. mobile_agent/evals/vision/images/receipt_023.jpg +0 -0
  330. mobile_agent/evals/vision/images/receipt_027.jpg +0 -0
  331. mobile_agent/evals/vision/images/receipt_029.jpg +0 -0
  332. mobile_agent/evals/vision/images/receipt_032.jpg +0 -0
  333. mobile_agent/evals/vision/images/receipt_033.jpg +0 -0
  334. mobile_agent/evals/vision/images/receipt_036.jpg +0 -0
  335. mobile_agent/evals/vision/images/receipt_041.jpg +0 -0
  336. mobile_agent/evals/vision/images/receipt_043.jpg +0 -0
  337. mobile_agent/evals/vision/images/receipt_047.jpg +0 -0
  338. mobile_agent/evals/vision/images/receipt_049.jpg +0 -0
  339. mobile_agent/evals/vision/images/receipt_051.jpg +0 -0
  340. mobile_agent/evals/vision/images/receipt_053.jpg +0 -0
  341. mobile_agent/evals/vision/images/receipt_056.jpg +0 -0
  342. mobile_agent/evals/vision/images/receipt_057.jpg +0 -0
  343. mobile_agent/evals/vision/images/receipt_058.jpg +0 -0
  344. mobile_agent/evals/vision/images/receipt_059.jpg +0 -0
  345. mobile_agent/evals/vision/images/receipt_060.jpg +0 -0
  346. mobile_agent/evals/vision/images/receipt_061.jpg +0 -0
  347. mobile_agent/evals/vision/images/receipt_062.jpg +0 -0
  348. mobile_agent/evals/vision/images/receipt_063.jpg +0 -0
  349. mobile_agent/evals/vision/images/receipt_064.jpg +0 -0
  350. mobile_agent/evals/vision/images/receipt_065.jpg +0 -0
  351. mobile_agent/evals/vision/images/receipt_066.jpg +0 -0
  352. mobile_agent/evals/vision/images/receipt_067.jpg +0 -0
  353. mobile_agent/evals/vision/images/receipt_068.jpg +0 -0
  354. mobile_agent/evals/vision/images/receipt_069.jpg +0 -0
  355. mobile_agent/evals/vision/images/receipt_070.jpg +0 -0
  356. mobile_agent/evals/vision/images/receipt_071.jpg +0 -0
  357. mobile_agent/evals/vision/images/sunglasses_000.jpg +0 -0
  358. mobile_agent/evals/vision/images/sunglasses_001.jpg +0 -0
  359. mobile_agent/evals/vision/images/sunglasses_002.jpg +0 -0
  360. mobile_agent/evals/vision/images/sunglasses_004.jpg +0 -0
  361. mobile_agent/evals/vision/images/sunglasses_005.jpg +0 -0
  362. mobile_agent/evals/vision/images/sunglasses_008.jpg +0 -0
  363. mobile_agent/evals/vision/images/sunglasses_009.jpg +0 -0
  364. mobile_agent/evals/vision/images/sunglasses_010.jpg +0 -0
  365. mobile_agent/evals/vision/images/sunglasses_012.jpg +0 -0
  366. mobile_agent/evals/vision/images/sunglasses_013.jpg +0 -0
  367. mobile_agent/evals/vision/images/sunglasses_015.jpg +0 -0
  368. mobile_agent/evals/vision/images/sunglasses_016.jpg +0 -0
  369. mobile_agent/evals/vision/images/sunglasses_017.jpg +0 -0
  370. mobile_agent/evals/vision/images/sunglasses_018.jpg +0 -0
  371. mobile_agent/evals/vision/images/sunglasses_021.jpg +0 -0
  372. mobile_agent/evals/vision/images/sunglasses_022.jpg +0 -0
  373. mobile_agent/evals/vision/images/sunglasses_023.jpg +0 -0
  374. mobile_agent/evals/vision/images/sunglasses_024.jpg +0 -0
  375. mobile_agent/evals/vision/images/sunglasses_030.jpg +0 -0
  376. mobile_agent/evals/vision/images/sunglasses_031.jpg +0 -0
  377. mobile_agent/evals/vision/images/sunglasses_032.jpg +0 -0
  378. mobile_agent/evals/vision/images/sunglasses_033.jpg +0 -0
  379. mobile_agent/evals/vision/images/sunglasses_034.jpg +0 -0
  380. mobile_agent/evals/vision/images/sunglasses_036.jpg +0 -0
  381. mobile_agent/evals/vision/images/sunglasses_038.jpg +0 -0
  382. mobile_agent/evals/vision/images/sunglasses_039.jpg +0 -0
  383. mobile_agent/evals/vision/images/sunglasses_040.jpg +0 -0
  384. mobile_agent/evals/vision/images/sunglasses_041.jpg +0 -0
  385. mobile_agent/evals/vision/manifest.json +2355 -0
  386. mobile_agent/eventbus.py +110 -0
  387. mobile_agent/explain.py +34 -0
  388. mobile_agent/failure_line.py +197 -0
  389. mobile_agent/fanout.py +663 -0
  390. mobile_agent/feedback/__init__.py +2 -0
  391. mobile_agent/feedback/bundle.py +204 -0
  392. mobile_agent/feedback/outbox.py +196 -0
  393. mobile_agent/feedback/register.py +9 -0
  394. mobile_agent/feedback/routes.py +177 -0
  395. mobile_agent/fleet.py +841 -0
  396. mobile_agent/fleet_health.py +729 -0
  397. mobile_agent/fleet_phone.py +186 -0
  398. mobile_agent/frontier/__init__.py +79 -0
  399. mobile_agent/frontier/act.py +380 -0
  400. mobile_agent/frontier/actions.py +70 -0
  401. mobile_agent/frontier/agent.py +256 -0
  402. mobile_agent/frontier/approvals.py +366 -0
  403. mobile_agent/frontier/hooks.py +199 -0
  404. mobile_agent/frontier/interrupts.py +125 -0
  405. mobile_agent/frontier/observe.py +331 -0
  406. mobile_agent/frontier/phases.py +381 -0
  407. mobile_agent/frontier/pipeline.py +61 -0
  408. mobile_agent/frontier/prompt.py +384 -0
  409. mobile_agent/frontier/protocol.py +91 -0
  410. mobile_agent/frontier/safety.py +467 -0
  411. mobile_agent/frontier/saved.py +375 -0
  412. mobile_agent/frontier/screen.py +419 -0
  413. mobile_agent/frontier/skills.py +147 -0
  414. mobile_agent/frontier/stages/__init__.py +1 -0
  415. mobile_agent/frontier/stages/after.py +192 -0
  416. mobile_agent/frontier/stages/approve.py +138 -0
  417. mobile_agent/frontier/stages/controls.py +115 -0
  418. mobile_agent/frontier/stages/ends.py +102 -0
  419. mobile_agent/frontier/stages/gate.py +92 -0
  420. mobile_agent/frontier/stages/guards.py +132 -0
  421. mobile_agent/frontier/stages/points.py +155 -0
  422. mobile_agent/frontier/stages/send.py +177 -0
  423. mobile_agent/frontier/turn.py +151 -0
  424. mobile_agent/full_disk_access.py +83 -0
  425. mobile_agent/gemini.py +69 -0
  426. mobile_agent/genuine.py +120 -0
  427. mobile_agent/graph/__init__.py +4 -0
  428. mobile_agent/graph/catalog.py +789 -0
  429. mobile_agent/graph/chain.py +306 -0
  430. mobile_agent/graph/dispatch.py +98 -0
  431. mobile_agent/graph/draft.py +373 -0
  432. mobile_agent/graph/edit.py +76 -0
  433. mobile_agent/graph/estimate.py +189 -0
  434. mobile_agent/graph/expr.py +562 -0
  435. mobile_agent/graph/functions.py +271 -0
  436. mobile_agent/graph/infer.py +144 -0
  437. mobile_agent/graph/nodes.py +936 -0
  438. mobile_agent/graph/phone_steps.py +178 -0
  439. mobile_agent/graph/rerun.py +327 -0
  440. mobile_agent/graph/run.py +899 -0
  441. mobile_agent/graph/schema.py +842 -0
  442. mobile_agent/graph/state.py +161 -0
  443. mobile_agent/graph/values.py +198 -0
  444. mobile_agent/harness/__init__.py +2 -0
  445. mobile_agent/harness/checkpoints.py +468 -0
  446. mobile_agent/harness/control.py +101 -0
  447. mobile_agent/harness/prewarm.py +213 -0
  448. mobile_agent/harness/register.py +43 -0
  449. mobile_agent/harness/routes.py +114 -0
  450. mobile_agent/harness/settings.py +66 -0
  451. mobile_agent/harness/tools/__init__.py +67 -0
  452. mobile_agent/harness/tools/ask_user.py +102 -0
  453. mobile_agent/harness/tools/watch.py +120 -0
  454. mobile_agent/harness_api.py +815 -0
  455. mobile_agent/hedge.py +203 -0
  456. mobile_agent/help_topics.py +305 -0
  457. mobile_agent/hints.py +248 -0
  458. mobile_agent/history.py +69 -0
  459. mobile_agent/home.py +214 -0
  460. mobile_agent/http_pool.py +175 -0
  461. mobile_agent/image_policy.py +139 -0
  462. mobile_agent/inbox.py +148 -0
  463. mobile_agent/installed_apps.py +344 -0
  464. mobile_agent/integrations/__init__.py +1 -0
  465. mobile_agent/integrations/cli.py +472 -0
  466. mobile_agent/integrations/client_files.py +65 -0
  467. mobile_agent/integrations/clients.py +998 -0
  468. mobile_agent/integrations/doctor.py +185 -0
  469. mobile_agent/integrations/jsonc.py +383 -0
  470. mobile_agent/integrations/routes.py +135 -0
  471. mobile_agent/integrations/rules.py +112 -0
  472. mobile_agent/integrations/server_options.py +46 -0
  473. mobile_agent/integrations/skill.py +152 -0
  474. mobile_agent/integrations/skill_files.py +10 -0
  475. mobile_agent/integrations/textedit.py +309 -0
  476. mobile_agent/intents/__init__.py +17 -0
  477. mobile_agent/intents/atlas.py +216 -0
  478. mobile_agent/intents/callback.py +44 -0
  479. mobile_agent/intents/cli.py +55 -0
  480. mobile_agent/intents/harvest.py +91 -0
  481. mobile_agent/intents/plan.py +93 -0
  482. mobile_agent/ios/__init__.py +1 -0
  483. mobile_agent/ios/guide.py +16 -0
  484. mobile_agent/ios/kb.py +212 -0
  485. mobile_agent/ios/kb_data.py +1588 -0
  486. mobile_agent/ios/mcp.py +44 -0
  487. mobile_agent/ios/register.py +8 -0
  488. mobile_agent/ios/system.py +369 -0
  489. mobile_agent/journal.py +531 -0
  490. mobile_agent/keychain_acl.py +374 -0
  491. mobile_agent/keys.py +395 -0
  492. mobile_agent/latency_overlap.py +36 -0
  493. mobile_agent/latency_trace.py +170 -0
  494. mobile_agent/learned/__init__.py +15 -0
  495. mobile_agent/learned/app_path.py +160 -0
  496. mobile_agent/learned/code_read.py +38 -0
  497. mobile_agent/learned/parts.py +546 -0
  498. mobile_agent/learned/programs.py +691 -0
  499. mobile_agent/learned/register.py +171 -0
  500. mobile_agent/learned/routes.py +61 -0
  501. mobile_agent/learned/taught.py +329 -0
  502. mobile_agent/learned/teach.py +538 -0
  503. mobile_agent/learned/workflows.py +647 -0
  504. mobile_agent/learned/zero.py +41 -0
  505. mobile_agent/links.py +37 -0
  506. mobile_agent/listen.py +607 -0
  507. mobile_agent/lock_spike.py +278 -0
  508. mobile_agent/lockscreen.py +526 -0
  509. mobile_agent/login.py +354 -0
  510. mobile_agent/longform/__init__.py +15 -0
  511. mobile_agent/longform/ads.py +191 -0
  512. mobile_agent/longform/blocks.py +117 -0
  513. mobile_agent/longform/budget.py +265 -0
  514. mobile_agent/longform/context.py +285 -0
  515. mobile_agent/longform/costs.py +93 -0
  516. mobile_agent/longform/digest.py +145 -0
  517. mobile_agent/longform/extract.py +508 -0
  518. mobile_agent/longform/feed.py +505 -0
  519. mobile_agent/longform/followup.py +168 -0
  520. mobile_agent/longform/frames.py +82 -0
  521. mobile_agent/longform/identity.py +87 -0
  522. mobile_agent/longform/inbox.py +221 -0
  523. mobile_agent/longform/items.py +425 -0
  524. mobile_agent/longform/models.py +115 -0
  525. mobile_agent/longform/notebook.py +329 -0
  526. mobile_agent/longform/plan.py +614 -0
  527. mobile_agent/longform/recall.py +195 -0
  528. mobile_agent/longform/receipts.py +81 -0
  529. mobile_agent/longform/register.py +124 -0
  530. mobile_agent/longform/report.py +343 -0
  531. mobile_agent/longform/routes.py +102 -0
  532. mobile_agent/longform/session.py +644 -0
  533. mobile_agent/longform/store.py +146 -0
  534. mobile_agent/longform/synthesis.py +347 -0
  535. mobile_agent/mac_tools.py +223 -0
  536. mobile_agent/manual_control.py +143 -0
  537. mobile_agent/maps.py +149 -0
  538. mobile_agent/marks.py +92 -0
  539. mobile_agent/mcp_server/__init__.py +3 -0
  540. mobile_agent/mcp_server/__main__.py +29 -0
  541. mobile_agent/mcp_server/action_journal.py +24 -0
  542. mobile_agent/mcp_server/action_tools.py +638 -0
  543. mobile_agent/mcp_server/approvals.py +219 -0
  544. mobile_agent/mcp_server/catalog.py +92 -0
  545. mobile_agent/mcp_server/cli.py +194 -0
  546. mobile_agent/mcp_server/codes.py +147 -0
  547. mobile_agent/mcp_server/device_tools.py +306 -0
  548. mobile_agent/mcp_server/direct.py +272 -0
  549. mobile_agent/mcp_server/expo_tools.py +105 -0
  550. mobile_agent/mcp_server/phone_tools.py +510 -0
  551. mobile_agent/mcp_server/protocol.py +364 -0
  552. mobile_agent/mcp_server/registry.py +55 -0
  553. mobile_agent/mcp_server/run_tools.py +531 -0
  554. mobile_agent/mcp_server/schemas.py +425 -0
  555. mobile_agent/mcp_server/screen_tools.py +168 -0
  556. mobile_agent/mcp_server/session.py +469 -0
  557. mobile_agent/mcp_server/support.py +145 -0
  558. mobile_agent/mcp_server/tools.py +493 -0
  559. mobile_agent/memory/__init__.py +9 -0
  560. mobile_agent/memory/cli.py +207 -0
  561. mobile_agent/memory/extract.py +191 -0
  562. mobile_agent/memory/listener.py +100 -0
  563. mobile_agent/memory/model.py +144 -0
  564. mobile_agent/memory/project.py +96 -0
  565. mobile_agent/memory/provider.py +127 -0
  566. mobile_agent/memory/register.py +21 -0
  567. mobile_agent/memory/retrieval.py +172 -0
  568. mobile_agent/memory/routes.py +216 -0
  569. mobile_agent/memory/runs.py +124 -0
  570. mobile_agent/memory/store.py +806 -0
  571. mobile_agent/memory/writer.py +278 -0
  572. mobile_agent/mirroring.py +585 -0
  573. mobile_agent/mirroring_runtime.py +221 -0
  574. mobile_agent/model_clients.py +604 -0
  575. mobile_agent/narrate.py +813 -0
  576. mobile_agent/native_helpers.py +84 -0
  577. mobile_agent/netxray/__init__.py +1 -0
  578. mobile_agent/netxray/analyze.py +322 -0
  579. mobile_agent/netxray/attribution.py +62 -0
  580. mobile_agent/netxray/capture.py +88 -0
  581. mobile_agent/netxray/cli.py +142 -0
  582. mobile_agent/netxray/job.py +219 -0
  583. mobile_agent/netxray/mobster-pcap.c +560 -0
  584. mobile_agent/netxray/packets.py +410 -0
  585. mobile_agent/netxray/pcapng.py +223 -0
  586. mobile_agent/netxray/register.py +93 -0
  587. mobile_agent/netxray/report.py +214 -0
  588. mobile_agent/netxray/store.py +48 -0
  589. mobile_agent/netxray/summary.py +157 -0
  590. mobile_agent/netxray/target.py +67 -0
  591. mobile_agent/netxray/tool.py +66 -0
  592. mobile_agent/netxray/trackers.py +194 -0
  593. mobile_agent/notify_triggers/__init__.py +1 -0
  594. mobile_agent/notify_triggers/rules.py +39 -0
  595. mobile_agent/notify_triggers/sources.py +315 -0
  596. mobile_agent/notify_triggers/watch.py +135 -0
  597. mobile_agent/offers.py +86 -0
  598. mobile_agent/orchestrate.py +215 -0
  599. mobile_agent/outlets/__init__.py +1 -0
  600. mobile_agent/outlets/connect.py +249 -0
  601. mobile_agent/outlets/folder.py +180 -0
  602. mobile_agent/outlets/hooks.py +453 -0
  603. mobile_agent/outlets/inbound.py +472 -0
  604. mobile_agent/outlets/inbound_reply.py +57 -0
  605. mobile_agent/outlets/inbound_routes.py +81 -0
  606. mobile_agent/outlets/kinds.py +308 -0
  607. mobile_agent/outlets/mail.py +110 -0
  608. mobile_agent/outlets/net.py +86 -0
  609. mobile_agent/outlets/notion.py +244 -0
  610. mobile_agent/outlets/register.py +56 -0
  611. mobile_agent/outlets/routes.py +135 -0
  612. mobile_agent/outlets/service.py +580 -0
  613. mobile_agent/outlets/sheets.py +169 -0
  614. mobile_agent/outlets/slack.py +177 -0
  615. mobile_agent/outlets/store.py +194 -0
  616. mobile_agent/outlets/vault.py +80 -0
  617. mobile_agent/outlets/webhook.py +316 -0
  618. mobile_agent/output_contract.py +141 -0
  619. mobile_agent/owner_presence.py +211 -0
  620. mobile_agent/ownership.py +219 -0
  621. mobile_agent/packs/__init__.py +1 -0
  622. mobile_agent/packs/calendar.py +243 -0
  623. mobile_agent/packs/context.py +68 -0
  624. mobile_agent/packs/core.py +757 -0
  625. mobile_agent/packs/front_door.py +316 -0
  626. mobile_agent/packs/heal.py +214 -0
  627. mobile_agent/packs/health.py +122 -0
  628. mobile_agent/packs/insights.py +76 -0
  629. mobile_agent/packs/instagram.py +640 -0
  630. mobile_agent/packs/linkedin.py +106 -0
  631. mobile_agent/packs/maps.py +196 -0
  632. mobile_agent/packs/mcp.py +149 -0
  633. mobile_agent/packs/messages.py +270 -0
  634. mobile_agent/packs/notes.py +226 -0
  635. mobile_agent/packs/photos.py +166 -0
  636. mobile_agent/packs/pins.py +47 -0
  637. mobile_agent/packs/reddit.py +110 -0
  638. mobile_agent/packs/register.py +276 -0
  639. mobile_agent/packs/rows.py +108 -0
  640. mobile_agent/packs/safari.py +143 -0
  641. mobile_agent/packs/settings.py +253 -0
  642. mobile_agent/packs/social.py +300 -0
  643. mobile_agent/packs/threads.py +97 -0
  644. mobile_agent/packs/tiktok.py +455 -0
  645. mobile_agent/packs/tools.py +290 -0
  646. mobile_agent/packs/x.py +127 -0
  647. mobile_agent/packs/youtube.py +199 -0
  648. mobile_agent/parity.py +270 -0
  649. mobile_agent/paths.py +99 -0
  650. mobile_agent/perception.py +470 -0
  651. mobile_agent/permissions/__init__.py +32 -0
  652. mobile_agent/permissions/consequence.py +535 -0
  653. mobile_agent/permissions/exceptions.py +99 -0
  654. mobile_agent/permissions/gate.py +316 -0
  655. mobile_agent/permissions/outside.py +316 -0
  656. mobile_agent/permissions/policy.py +288 -0
  657. mobile_agent/permissions/project_policy.py +411 -0
  658. mobile_agent/permissions/register.py +148 -0
  659. mobile_agent/permissions/rules.py +291 -0
  660. mobile_agent/phone_io/__init__.py +146 -0
  661. mobile_agent/phone_io/cli.py +427 -0
  662. mobile_agent/phone_io/clipboard.py +78 -0
  663. mobile_agent/phone_io/controls.py +78 -0
  664. mobile_agent/phone_io/files.py +289 -0
  665. mobile_agent/phone_io/handoff.py +123 -0
  666. mobile_agent/phone_io/icloud.py +118 -0
  667. mobile_agent/phone_io/install.py +188 -0
  668. mobile_agent/phone_io/network_capture.py +188 -0
  669. mobile_agent/phone_io/network_cli.py +138 -0
  670. mobile_agent/phone_io/network_profiles.py +138 -0
  671. mobile_agent/phone_io/photos.py +512 -0
  672. mobile_agent/phone_io/recording.py +115 -0
  673. mobile_agent/phone_io/secrets.py +146 -0
  674. mobile_agent/phone_io/transfer.py +895 -0
  675. mobile_agent/phone_io/tunnel.py +129 -0
  676. mobile_agent/phone_ready.py +91 -0
  677. mobile_agent/pinning.py +158 -0
  678. mobile_agent/plan_gate.py +807 -0
  679. mobile_agent/plugins/__init__.py +3 -0
  680. mobile_agent/plugins/install.py +163 -0
  681. mobile_agent/plugins/manifest.py +242 -0
  682. mobile_agent/plugins/register.py +15 -0
  683. mobile_agent/plugins/routes.py +82 -0
  684. mobile_agent/plugins/service.py +322 -0
  685. mobile_agent/plugins/skills.py +76 -0
  686. mobile_agent/plugins/store.py +79 -0
  687. mobile_agent/pool.py +285 -0
  688. mobile_agent/power_watch.py +275 -0
  689. mobile_agent/premium_model.py +22 -0
  690. mobile_agent/project_env.py +291 -0
  691. mobile_agent/project_env_cli.py +74 -0
  692. mobile_agent/project_env_routes.py +55 -0
  693. mobile_agent/projects/__init__.py +27 -0
  694. mobile_agent/projects/ci.py +332 -0
  695. mobile_agent/projects/clone.py +339 -0
  696. mobile_agent/projects/drafts.py +158 -0
  697. mobile_agent/projects/folder.py +436 -0
  698. mobile_agent/projects/github.py +131 -0
  699. mobile_agent/projects/register.py +31 -0
  700. mobile_agent/projects/revisions.py +253 -0
  701. mobile_agent/projects/routes.py +234 -0
  702. mobile_agent/projects/runs.py +297 -0
  703. mobile_agent/projects/service.py +695 -0
  704. mobile_agent/projects/setup.py +84 -0
  705. mobile_agent/projects/store.py +379 -0
  706. mobile_agent/projects/sync.py +380 -0
  707. mobile_agent/projects/workflow_files.py +270 -0
  708. mobile_agent/replay_core/__init__.py +26 -0
  709. mobile_agent/replay_core/checks.py +594 -0
  710. mobile_agent/replay_core/engine.py +596 -0
  711. mobile_agent/replay_core/heal.py +364 -0
  712. mobile_agent/replay_core/path.py +240 -0
  713. mobile_agent/replay_core/recorder.py +206 -0
  714. mobile_agent/replay_core/store.py +171 -0
  715. mobile_agent/result_blocks.py +941 -0
  716. mobile_agent/run.py +642 -0
  717. mobile_agent/run_delivery.py +123 -0
  718. mobile_agent/run_executor.py +200 -0
  719. mobile_agent/run_export.py +284 -0
  720. mobile_agent/run_gif.py +891 -0
  721. mobile_agent/runner_hints.py +86 -0
  722. mobile_agent/runner_renewal.py +205 -0
  723. mobile_agent/runtime_create.py +549 -0
  724. mobile_agent/runtime_driver.py +458 -0
  725. mobile_agent/runtime_limits.py +319 -0
  726. mobile_agent/runtime_queue.py +394 -0
  727. mobile_agent/runtime_runs.py +383 -0
  728. mobile_agent/runtime_status.py +181 -0
  729. mobile_agent/runtime_teach.py +295 -0
  730. mobile_agent/runtime_work.py +599 -0
  731. mobile_agent/screen_text.py +101 -0
  732. mobile_agent/secret_filter.py +113 -0
  733. mobile_agent/serve.py +160 -0
  734. mobile_agent/server.py +363 -0
  735. mobile_agent/service_support.py +42 -0
  736. mobile_agent/settle/__init__.py +1 -0
  737. mobile_agent/settle/predict.py +102 -0
  738. mobile_agent/settle/replay.py +92 -0
  739. mobile_agent/settle/store.py +93 -0
  740. mobile_agent/settle/stream.py +43 -0
  741. mobile_agent/setup_cli.py +225 -0
  742. mobile_agent/setup_errors.py +87 -0
  743. mobile_agent/setup_service.py +527 -0
  744. mobile_agent/share.py +104 -0
  745. mobile_agent/signing.py +184 -0
  746. mobile_agent/sim/__init__.py +6 -0
  747. mobile_agent/sim/__main__.py +26 -0
  748. mobile_agent/sim/api.py +952 -0
  749. mobile_agent/sim/apps.py +172 -0
  750. mobile_agent/sim/biometrics.py +84 -0
  751. mobile_agent/sim/cli.py +271 -0
  752. mobile_agent/sim/doctor.py +293 -0
  753. mobile_agent/sim/governor.py +90 -0
  754. mobile_agent/sim/holders.py +88 -0
  755. mobile_agent/sim/models.py +62 -0
  756. mobile_agent/sim/registry.py +161 -0
  757. mobile_agent/sim/simctl.py +269 -0
  758. mobile_agent/sim/wda.py +618 -0
  759. mobile_agent/sim/xcode.py +74 -0
  760. mobile_agent/skills/__init__.py +12 -0
  761. mobile_agent/skills/codes.py +597 -0
  762. mobile_agent/skills/notifications.py +281 -0
  763. mobile_agent/skills/open_url.py +127 -0
  764. mobile_agent/smart_events.py +588 -0
  765. mobile_agent/smart_result.py +490 -0
  766. mobile_agent/smart_run.py +413 -0
  767. mobile_agent/state.py +662 -0
  768. mobile_agent/states.py +217 -0
  769. mobile_agent/steering.py +70 -0
  770. mobile_agent/style.py +178 -0
  771. mobile_agent/supervised.py +234 -0
  772. mobile_agent/swift/helper_bt_hid.swift +534 -0
  773. mobile_agent/swift/helper_doc_text.swift +184 -0
  774. mobile_agent/swift/helper_draft_reply.swift +170 -0
  775. mobile_agent/swift/helper_map.swift +110 -0
  776. mobile_agent/swift/helper_mirroring.swift +1099 -0
  777. mobile_agent/swift/helper_mjpeg_mp4.swift +103 -0
  778. mobile_agent/swift/helper_pdf.swift +100 -0
  779. mobile_agent/swift/helper_phone_audio.swift +993 -0
  780. mobile_agent/swift/helper_phone_screen.swift +416 -0
  781. mobile_agent/swift/helper_screen_text.swift +24 -0
  782. mobile_agent/swift/helper_transfer.swift +246 -0
  783. mobile_agent/swift/ocr.swift +22 -0
  784. mobile_agent/task_policy.py +337 -0
  785. mobile_agent/team_signing/__init__.py +34 -0
  786. mobile_agent/team_signing/apple_ca.py +37 -0
  787. mobile_agent/team_signing/bundle.py +136 -0
  788. mobile_agent/team_signing/cli.py +208 -0
  789. mobile_agent/team_signing/identity.py +248 -0
  790. mobile_agent/team_signing/ledger.py +105 -0
  791. mobile_agent/team_signing/prebuilt.py +61 -0
  792. mobile_agent/team_signing/resign.py +256 -0
  793. mobile_agent/team_signing/service.py +382 -0
  794. mobile_agent/team_signing/tooling.py +243 -0
  795. mobile_agent/team_signing/writes.py +268 -0
  796. mobile_agent/terminal_image.py +81 -0
  797. mobile_agent/testkit/__init__.py +1 -0
  798. mobile_agent/testkit/a11y_baseline.py +132 -0
  799. mobile_agent/testkit/api.py +403 -0
  800. mobile_agent/testkit/capsule.py +400 -0
  801. mobile_agent/testkit/capsule_cli.py +57 -0
  802. mobile_agent/testkit/cli.py +948 -0
  803. mobile_agent/testkit/cli_arguments.py +193 -0
  804. mobile_agent/testkit/comparison.py +288 -0
  805. mobile_agent/testkit/comparison_cli.py +41 -0
  806. mobile_agent/testkit/discover.py +257 -0
  807. mobile_agent/testkit/editor.py +243 -0
  808. mobile_agent/testkit/execute.py +515 -0
  809. mobile_agent/testkit/github_checks.py +248 -0
  810. mobile_agent/testkit/gitref.py +66 -0
  811. mobile_agent/testkit/heal_review.py +176 -0
  812. mobile_agent/testkit/health.py +480 -0
  813. mobile_agent/testkit/history.py +255 -0
  814. mobile_agent/testkit/hosted_results.py +321 -0
  815. mobile_agent/testkit/input_files.py +173 -0
  816. mobile_agent/testkit/jobs.py +285 -0
  817. mobile_agent/testkit/junit_verdict.py +33 -0
  818. mobile_agent/testkit/maestro.py +802 -0
  819. mobile_agent/testkit/mcp.py +562 -0
  820. mobile_agent/testkit/mcp_comparison.py +88 -0
  821. mobile_agent/testkit/minimize.py +471 -0
  822. mobile_agent/testkit/minimizer_cli.py +48 -0
  823. mobile_agent/testkit/notify.py +212 -0
  824. mobile_agent/testkit/phone_video.py +191 -0
  825. mobile_agent/testkit/pr.py +491 -0
  826. mobile_agent/testkit/pr_report.py +562 -0
  827. mobile_agent/testkit/prbuild.py +148 -0
  828. mobile_agent/testkit/proof_report.py +55 -0
  829. mobile_agent/testkit/provenance.py +119 -0
  830. mobile_agent/testkit/prrun.py +244 -0
  831. mobile_agent/testkit/recent.py +166 -0
  832. mobile_agent/testkit/recipes.py +146 -0
  833. mobile_agent/testkit/record.py +204 -0
  834. mobile_agent/testkit/register.py +288 -0
  835. mobile_agent/testkit/reports.py +998 -0
  836. mobile_agent/testkit/repro.py +432 -0
  837. mobile_agent/testkit/scenario.py +440 -0
  838. mobile_agent/testkit/scenario_cli.py +37 -0
  839. mobile_agent/testkit/snapshots_cli.py +60 -0
  840. mobile_agent/testkit/suite.py +670 -0
  841. mobile_agent/testkit/targets.py +497 -0
  842. mobile_agent/testkit/xctest.py +378 -0
  843. mobile_agent/threads/__init__.py +1 -0
  844. mobile_agent/threads/chat_once.py +264 -0
  845. mobile_agent/threads/cli.py +740 -0
  846. mobile_agent/threads/client.py +326 -0
  847. mobile_agent/threads/context.py +246 -0
  848. mobile_agent/threads/mcp.py +739 -0
  849. mobile_agent/threads/recent.py +34 -0
  850. mobile_agent/threads/register.py +54 -0
  851. mobile_agent/threads/routes.py +188 -0
  852. mobile_agent/threads/service.py +975 -0
  853. mobile_agent/threads/store.py +361 -0
  854. mobile_agent/threads/summary.py +117 -0
  855. mobile_agent/threads/term.py +101 -0
  856. mobile_agent/threads/workflow_cli.py +104 -0
  857. mobile_agent/tls.py +36 -0
  858. mobile_agent/traces.py +271 -0
  859. mobile_agent/tracks.py +235 -0
  860. mobile_agent/transport.py +271 -0
  861. mobile_agent/triggers/__init__.py +1 -0
  862. mobile_agent/triggers/actions.py +204 -0
  863. mobile_agent/triggers/model.py +146 -0
  864. mobile_agent/triggers/register.py +29 -0
  865. mobile_agent/triggers/routes.py +101 -0
  866. mobile_agent/triggers/service.py +387 -0
  867. mobile_agent/triggers/store.py +142 -0
  868. mobile_agent/tui/__init__.py +89 -0
  869. mobile_agent/tui/app.py +582 -0
  870. mobile_agent/tui/chrome.py +321 -0
  871. mobile_agent/tui/commands.py +375 -0
  872. mobile_agent/tui/demo_phone.py +369 -0
  873. mobile_agent/tui/images.py +29 -0
  874. mobile_agent/tui/keys.py +64 -0
  875. mobile_agent/tui/prompt.py +195 -0
  876. mobile_agent/tui/render.py +395 -0
  877. mobile_agent/tui/screens.py +180 -0
  878. mobile_agent/tui/session.py +308 -0
  879. mobile_agent/tui/tasks.py +287 -0
  880. mobile_agent/tui/theme.py +118 -0
  881. mobile_agent/tui/widgets.py +272 -0
  882. mobile_agent/update_check.py +213 -0
  883. mobile_agent/usage.py +184 -0
  884. mobile_agent/verify/__init__.py +0 -0
  885. mobile_agent/verify/__main__.py +41 -0
  886. mobile_agent/verify/a11y.py +189 -0
  887. mobile_agent/verify/actions.py +290 -0
  888. mobile_agent/verify/assertions.py +570 -0
  889. mobile_agent/verify/audit.py +312 -0
  890. mobile_agent/verify/business.py +221 -0
  891. mobile_agent/verify/business_run.py +131 -0
  892. mobile_agent/verify/business_state.py +168 -0
  893. mobile_agent/verify/check_schema.py +398 -0
  894. mobile_agent/verify/checks.py +614 -0
  895. mobile_agent/verify/cli.py +423 -0
  896. mobile_agent/verify/conditions.py +483 -0
  897. mobile_agent/verify/crashes.py +321 -0
  898. mobile_agent/verify/device_audit.py +246 -0
  899. mobile_agent/verify/dialogs.py +287 -0
  900. mobile_agent/verify/evidence.py +705 -0
  901. mobile_agent/verify/fixtures.py +305 -0
  902. mobile_agent/verify/flow.py +213 -0
  903. mobile_agent/verify/l10n.py +141 -0
  904. mobile_agent/verify/netcerts.py +67 -0
  905. mobile_agent/verify/netproxy.py +356 -0
  906. mobile_agent/verify/netrun.py +81 -0
  907. mobile_agent/verify/netshim.m +28 -0
  908. mobile_agent/verify/netshim.py +48 -0
  909. mobile_agent/verify/netstubs.py +132 -0
  910. mobile_agent/verify/overlay.py +174 -0
  911. mobile_agent/verify/perf.py +239 -0
  912. mobile_agent/verify/proof.py +192 -0
  913. mobile_agent/verify/replay_admission.py +48 -0
  914. mobile_agent/verify/report.py +952 -0
  915. mobile_agent/verify/run_cleanup.py +67 -0
  916. mobile_agent/verify/run_links.py +48 -0
  917. mobile_agent/verify/run_perf.py +44 -0
  918. mobile_agent/verify/run_secrets.py +39 -0
  919. mobile_agent/verify/runner.py +976 -0
  920. mobile_agent/verify/runs.py +57 -0
  921. mobile_agent/verify/screen_coverage.py +181 -0
  922. mobile_agent/verify/smart.py +106 -0
  923. mobile_agent/verify/snapshot_run.py +114 -0
  924. mobile_agent/verify/snapshots.py +306 -0
  925. mobile_agent/verify/sound.py +335 -0
  926. mobile_agent/verify/source_observation.py +130 -0
  927. mobile_agent/verify/system_surfaces/__init__.py +7 -0
  928. mobile_agent/verify/system_surfaces/play.py +106 -0
  929. mobile_agent/verify/system_surfaces/spec.py +139 -0
  930. mobile_agent/verify/system_surfaces/stage.py +138 -0
  931. mobile_agent/verify/system_surfaces/surfaces.py +121 -0
  932. mobile_agent/verify/tree.py +223 -0
  933. mobile_agent/verify/verdict.py +377 -0
  934. mobile_agent/verify/wire.py +392 -0
  935. mobile_agent/verify/wirestore.py +176 -0
  936. mobile_agent/waiting/__init__.py +1 -0
  937. mobile_agent/waiting/contacts.py +73 -0
  938. mobile_agent/waiting/drafts.py +136 -0
  939. mobile_agent/waiting/job.py +145 -0
  940. mobile_agent/waiting/messages.py +174 -0
  941. mobile_agent/waiting/rank.py +115 -0
  942. mobile_agent/waiting/register.py +26 -0
  943. mobile_agent/watch.py +502 -0
  944. mobile_agent/wda/__init__.py +2 -0
  945. mobile_agent/wda/frame_clock.py +646 -0
  946. mobile_agent/wda/frame_source.py +325 -0
  947. mobile_agent/wda/frame_store.py +115 -0
  948. mobile_agent/wda/gate.py +163 -0
  949. mobile_agent/wda/gestures.py +291 -0
  950. mobile_agent/wda/guards.py +94 -0
  951. mobile_agent/wda/identity.py +55 -0
  952. mobile_agent/wda/observe.py +884 -0
  953. mobile_agent/wda/patches.py +856 -0
  954. mobile_agent/wda/point_gestures.py +150 -0
  955. mobile_agent/wda/source.py +32 -0
  956. mobile_agent/wda/system_dialogs.py +571 -0
  957. mobile_agent/wda/text.py +694 -0
  958. mobile_agent/wda/timeouts.py +77 -0
  959. mobile_agent/wda/video.py +505 -0
  960. mobile_agent/wda/watchdog.py +243 -0
  961. mobile_agent/wireless/__init__.py +15 -0
  962. mobile_agent/wireless/api.py +354 -0
  963. mobile_agent/wireless/cli.py +287 -0
  964. mobile_agent/wireless/devicectl.py +229 -0
  965. mobile_agent/wireless/exposure.py +33 -0
  966. mobile_agent/wireless/mobster-wifi-pair.c +122 -0
  967. mobile_agent/wireless/monitor.py +332 -0
  968. mobile_agent/wireless/network.py +334 -0
  969. mobile_agent/wireless/onboarding.py +293 -0
  970. mobile_agent/wireless/pair.py +124 -0
  971. mobile_agent/wireless/probe.py +322 -0
  972. mobile_agent/wireless/register.py +8 -0
  973. mobile_agent/wireless/relay.py +179 -0
  974. mobile_agent/wireless/result.py +87 -0
  975. mobile_agent/wireless/store.py +85 -0
  976. mobile_agent/wireless/switch.py +77 -0
  977. mobile_agent/wireless/transport.py +118 -0
  978. mobile_agent/wireless/words.py +81 -0
  979. mobile_agent/wireless/xctestrun.py +83 -0
  980. mobile_agent/workflow_tools.py +59 -0
  981. mobile_agent/workflows/__init__.py +2 -0
  982. mobile_agent/workflows/brief.py +201 -0
  983. mobile_agent/workflows/cron.py +110 -0
  984. mobile_agent/workflows/health.py +257 -0
  985. mobile_agent/workflows/history.py +126 -0
  986. mobile_agent/workflows/records.py +442 -0
  987. mobile_agent/workflows/runs.py +1000 -0
  988. mobile_agent/workflows/scheduler.py +134 -0
  989. mobile_agent/workflows/service.py +973 -0
  990. mobile_agent/workflows/starters.py +285 -0
  991. mobile_agent/workflows/triggers.py +248 -0
  992. mobile_agent/xray/__init__.py +1 -0
  993. mobile_agent/xray/app_cli.py +116 -0
  994. mobile_agent/xray/cli.py +21 -0
  995. mobile_agent/xray/crawl.py +754 -0
  996. mobile_agent/xray/diff.py +171 -0
  997. mobile_agent/xray/diff_cli.py +43 -0
  998. mobile_agent/xray/job.py +184 -0
  999. mobile_agent/xray/paywall.py +188 -0
  1000. mobile_agent/xray/policy.py +305 -0
  1001. mobile_agent/xray/register.py +43 -0
  1002. mobile_agent/xray/report.py +283 -0
  1003. mobile_agent/xray/screens.py +117 -0
  1004. mobster_cli-0.4.3.dist-info/METADATA +93 -0
  1005. mobster_cli-0.4.3.dist-info/RECORD +1009 -0
  1006. mobster_cli-0.4.3.dist-info/WHEEL +5 -0
  1007. mobster_cli-0.4.3.dist-info/entry_points.txt +3 -0
  1008. mobster_cli-0.4.3.dist-info/licenses/LICENSE +21 -0
  1009. mobster_cli-0.4.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,941 @@
1
+ """Result blocks: a task's answer as cards from a fixed vocabulary (docs/results.mdx).
2
+
3
+ A finished task may carry ``blocks``, a versioned envelope ``{"v": 1, "blocks": [...]}``, beside its plain ``answer``.
4
+ Each block is one of ``TYPES``: data only, never code, HTML or a path. ``validate`` is the one gate: the Runtime runs
5
+ every envelope through it before a block is stored or shown. A block that doesn't fit becomes a ``markdown`` block with
6
+ its readable text, so an answer never disappears; every string goes through ``secret_filter.redact``.
7
+
8
+ - ``validate(value)``: (envelope or None, problems), the envelope normalized and bounded.
9
+ - Builders for code that already holds the data (long tasks, a read list, a workflow): ``review_queue``, ``table``,
10
+ ``media_list``, ``evidence``, ``checklist``, ``markdown``, ``chart`` and ``envelope``.
11
+ - ``to_markdown(envelope)`` and ``table_csv(block)``: the blocks as Markdown and a table as CSV, for export and for
12
+ clients that show text.
13
+ - A review queue's Approve (``approval_item``, ``follow_up_goal``, ``preapproval``, ``preapproval_matches``): the item
14
+ is read from the stored block, never from the client; Approve starts a task that sends exactly that text to exactly
15
+ that recipient, and its send still goes through the run's permission mode and the journal. An item with no draft
16
+ can't be approved, except one that code marked ``yourWords`` (``YOUR_WORDS``): there the person's own typed words
17
+ are the draft.
18
+ """
19
+
20
+ import csv
21
+ import io
22
+ import json
23
+ import math
24
+ import re
25
+
26
+ from .secret_filter import is_secret, redact
27
+
28
+ VERSION = 1
29
+
30
+ MAX_BLOCKS = 8
31
+ MAX_ROWS = 200
32
+ MAX_ITEMS = 60
33
+ MAX_COLUMNS = 16
34
+ MAX_TEXT = 2000
35
+ MAX_MARKDOWN = 12000
36
+ MAX_TITLE = 160
37
+ MAX_BYTES = 256_000
38
+ MAX_SERIES = 5
39
+ MAX_POINTS = 60
40
+ MAX_SCATTER_SERIES = 3 # all three pairs of the first three slots stay apart for colour-blind readers
41
+ MAX_SCATTER_POINTS = 200
42
+ CHART_KINDS = ("bar", "line", "area", "pie", "scatter", "sparkline")
43
+ CHART_ALIASES = {"column": "bar", "columns": "bar", "bars": "bar", "donut": "pie", "doughnut": "pie", "lines": "line"}
44
+ VALUE_FORMATS = ("number", "money", "percent")
45
+ TABLE_VIEWS = ("table", "chart", "map", "compare")
46
+ MAX_PLACES = 30
47
+ MAX_OPTIONS = 4
48
+ TIMELINE_STATES = ("done", "now", "next", "pass", "fail", "uncertain")
49
+
50
+ COLUMN_TYPES = ("text", "number", "money", "percent", "date", "duration", "bool", "link", "status", "brand")
51
+ NUMERIC_COLUMNS = ("number", "money", "percent", "duration")
52
+ STATUSES = ("pass", "fail", "uncertain")
53
+ QUEUE_ACTIONS = ("send_message", "reply", "post_comment")
54
+ DEFAULT_BUCKETS = ({"id": "reply", "label": "Needs a reply"}, {"id": "wait", "label": "Can wait"},
55
+ {"id": "spam", "label": "Spam"})
56
+ BLANK = re.compile(r"\[[^\]\n]{1,80}\]")
57
+ _ID = re.compile(r"[A-Za-z0-9_.:-]{1,64}")
58
+ _CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
59
+
60
+
61
+ class BlockError(ValueError):
62
+ """A block that doesn't fit the vocabulary; its message says what, in plain words."""
63
+
64
+
65
+ class _Marker:
66
+ """A value only code in this process can hold: JSON can't say it, and a copy of it is itself."""
67
+ def __init__(self, name):
68
+ self.name = name
69
+
70
+ def __repr__(self):
71
+ return f"<{self.name}>"
72
+
73
+ def __copy__(self):
74
+ return self
75
+
76
+ def __deepcopy__(self, memo):
77
+ return self
78
+
79
+
80
+ # Code that makes an item with no draft ON PURPOSE, for the person to answer in their own words (Who's waiting on me),
81
+ # sets its ``yourWords`` to this. ``validate`` stores ``yourWords: true`` for it and for nothing else, so a block that
82
+ # comes from a model or a request can't carry the mark; ``approval_item`` is then the one place that reads it.
83
+ YOUR_WORDS = _Marker("your words")
84
+
85
+
86
+ def _text(value, limit=MAX_TEXT, *, required=False, field="text", multiline=True):
87
+ """A bounded, redacted string, or None. Numbers become their text; anything else is refused."""
88
+ if value is None or value == "":
89
+ if required:
90
+ raise BlockError(f"{field} is required")
91
+ return None
92
+ if isinstance(value, bool):
93
+ value = "yes" if value else "no"
94
+ elif isinstance(value, (int, float)) and math.isfinite(value):
95
+ value = format(value, "g") if isinstance(value, float) else str(value)
96
+ if not isinstance(value, str):
97
+ raise BlockError(f"{field} must be text")
98
+ value = _CONTROL.sub("", value).replace("\r\n", "\n")
99
+ if not multiline:
100
+ value = " ".join(value.split())
101
+ value = value.strip()
102
+ if not value:
103
+ if required:
104
+ raise BlockError(f"{field} is required")
105
+ return None
106
+ return redact(value[:limit])
107
+
108
+
109
+ def _id(value, fallback):
110
+ value = str(value).strip() if isinstance(value, (str, int)) and not isinstance(value, bool) else ""
111
+ return value if _ID.fullmatch(value) else fallback
112
+
113
+
114
+ def _frame(value):
115
+ """A run frame's id (GET /api/runs/{id}/frames/{frameId}), never a path or a URL."""
116
+ return value if isinstance(value, str) and _ID.fullmatch(value) else None
117
+
118
+
119
+ def _link(value):
120
+ """An https link, else None: no other scheme reaches a block."""
121
+ if not isinstance(value, str):
122
+ return None
123
+ value = value.strip()
124
+ return value[:2000] if re.fullmatch(r"https://[^\s<>\"'`]{1,1990}", value) and not is_secret(value) else None
125
+
126
+
127
+ def _number(value):
128
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
129
+ return None
130
+ return value
131
+
132
+
133
+ def _crop(value):
134
+ if not isinstance(value, dict):
135
+ return None
136
+ try:
137
+ box = {key: float(value[key]) for key in ("x", "y", "w", "h")}
138
+ except (KeyError, TypeError, ValueError):
139
+ return None
140
+ if not all(0 <= box[k] <= 1 for k in box) or box["w"] <= 0 or box["h"] <= 0:
141
+ return None
142
+ return {key: round(min(1.0, box[key]), 4) for key in box}
143
+
144
+
145
+ def status_word(value):
146
+ value = str(value or "").strip().lower()
147
+ return {"passed": "pass", "ok": "pass", "yes": "pass", "failed": "fail", "no": "fail",
148
+ "unsure": "uncertain", "unknown": "uncertain"}.get(value, value) if value else None
149
+
150
+
151
+ def _review_queue(block):
152
+ action = block.get("action") or "send_message"
153
+ if action not in QUEUE_ACTIONS:
154
+ raise BlockError("A review queue's action is send_message, reply or post_comment")
155
+ buckets = []
156
+ for bucket in block.get("buckets") or DEFAULT_BUCKETS:
157
+ if isinstance(bucket, dict):
158
+ bucket_id = _id(bucket.get("id"), None)
159
+ label = _text(bucket.get("label"), 40, multiline=False)
160
+ if bucket_id and label and bucket_id not in {b["id"] for b in buckets}:
161
+ buckets.append({"id": bucket_id, "label": label})
162
+ if len(buckets) >= 6:
163
+ break
164
+ raw = block.get("items")
165
+ if not isinstance(raw, list) or not raw:
166
+ raise BlockError("A review queue needs items")
167
+ items, seen = [], set()
168
+ for index, item in enumerate(raw[:MAX_ITEMS]):
169
+ if not isinstance(item, dict):
170
+ raise BlockError("A review queue's item is an object")
171
+ item_id = _id(item.get("id"), f"i{index + 1}")
172
+ if item_id in seen:
173
+ item_id = f"i{index + 1}"
174
+ seen.add(item_id)
175
+ bucket = item.get("bucket")
176
+ bucket = bucket if buckets and bucket in {b["id"] for b in buckets} else (buckets[0]["id"] if buckets else None)
177
+ out = {"id": item_id, "bucket": bucket,
178
+ "to": _text(item.get("to"), 120, required=True, field="An item's recipient (to)", multiline=False),
179
+ "handle": _text(item.get("handle"), 80, multiline=False),
180
+ "app": _text(item.get("app"), 80, multiline=False),
181
+ "bundleId": item.get("bundleId") if isinstance(item.get("bundleId"), str)
182
+ and re.fullmatch(r"[A-Za-z0-9.-]{3,155}", item["bundleId"]) else None,
183
+ "account": _text(item.get("account"), 120, multiline=False),
184
+ "context": _text(item.get("context") or item.get("summary"), 400),
185
+ "lastMessage": _text(item.get("lastMessage"), 600),
186
+ "draft": _text(item.get("draft"), 4000),
187
+ "decision": _text(item.get("decision"), 200),
188
+ "frameId": _frame(item.get("frameId"))}
189
+ if item.get("yourWords") is YOUR_WORDS and out["draft"] is None:
190
+ out["yourWords"] = True
191
+ items.append({key: value for key, value in out.items() if value is not None or key in ("to", "draft")})
192
+ return {"action": action, "buckets": buckets, "items": items}
193
+
194
+
195
+ def _column(column, index):
196
+ if isinstance(column, str):
197
+ column = {"label": column}
198
+ if not isinstance(column, dict):
199
+ raise BlockError("A table's column is a name or an object")
200
+ label = _text(column.get("label") or column.get("key"), 60, required=True, field="A column's label",
201
+ multiline=False)
202
+ key = (_id(column.get("key"), None) or re.sub(r"[^a-z0-9]+", "_", label.casefold()).strip("_")[:40]
203
+ or f"c{index + 1}")
204
+ kind = column.get("type") or "text"
205
+ if kind not in COLUMN_TYPES:
206
+ kind = "text"
207
+ out = {"key": key, "label": label, "type": kind}
208
+ if kind == "money" and isinstance(column.get("currency"), str) and re.fullmatch(r"[A-Z]{3}", column["currency"]):
209
+ out["currency"] = column["currency"]
210
+ return out
211
+
212
+
213
+ def _cell(value, kind):
214
+ if value is None or value == "":
215
+ return None
216
+ if kind in NUMERIC_COLUMNS:
217
+ number = _number(value)
218
+ if number is not None:
219
+ return number
220
+ if isinstance(value, str):
221
+ cleaned = re.sub(r"[\s,$€£¥%]", "", value)
222
+ try:
223
+ number = float(cleaned)
224
+ return int(number) if number.is_integer() and "." not in cleaned else number
225
+ except ValueError:
226
+ pass
227
+ return _text(value, MAX_TEXT)
228
+ if kind == "bool":
229
+ if isinstance(value, bool):
230
+ return value
231
+ word = str(value).strip().casefold()
232
+ return (True if word in ("yes", "true", "y", "1") else False
233
+ if word in ("no", "false", "n", "0") else _text(value))
234
+ if kind == "link":
235
+ return _link(value) or _text(value, 300, multiline=False)
236
+ if kind == "brand":
237
+ return _text(value, 80, multiline=False)
238
+ if kind == "status":
239
+ status = status_word(value)
240
+ return status if status in STATUSES else _text(value, 120, multiline=False)
241
+ return _text(value, MAX_TEXT)
242
+
243
+
244
+ def _table(block):
245
+ columns = block.get("columns")
246
+ if not isinstance(columns, list) or not 1 <= len(columns) <= MAX_COLUMNS:
247
+ raise BlockError(f"A table has 1 to {MAX_COLUMNS} columns")
248
+ columns = [_column(column, index) for index, column in enumerate(columns)]
249
+ keys = set()
250
+ for index, column in enumerate(columns):
251
+ if column["key"] in keys:
252
+ column["key"] = f"c{index + 1}"
253
+ keys.add(column["key"])
254
+ raw = block.get("rows")
255
+ if not isinstance(raw, list):
256
+ raise BlockError("A table's rows are a list")
257
+ rows = []
258
+ for row in raw[:MAX_ROWS]:
259
+ frame = None
260
+ if isinstance(row, dict) and "cells" in row:
261
+ frame, cells = _frame(row.get("frameId")), row.get("cells")
262
+ else:
263
+ cells = row
264
+ if isinstance(cells, list):
265
+ cells = {column["key"]: cells[i] for i, column in enumerate(columns) if i < len(cells)}
266
+ if not isinstance(cells, dict):
267
+ raise BlockError("A table's row is a list of cells or an object of them")
268
+ if isinstance(row, dict) and "cells" not in row:
269
+ frame = _frame(row.get("frameId"))
270
+ out = {"cells": {column["key"]: _cell(cells.get(column["key"], cells.get(column["label"])), column["type"])
271
+ for column in columns}}
272
+ if frame:
273
+ out["frameId"] = frame
274
+ rows.append(out)
275
+ result = {"columns": columns, "rows": rows}
276
+ if len(raw) > MAX_ROWS:
277
+ result["truncated"] = len(raw)
278
+ sort = block.get("sort")
279
+ if isinstance(sort, dict) and sort.get("key") in keys:
280
+ result["sort"] = {"key": sort["key"], "dir": "asc" if sort.get("dir") == "asc" else "desc"}
281
+ # How the card opens (the others stay a click away): its rows as a chart, on a map or as cards side by side.
282
+ chart = _table_chart(block.get("chart"), columns)
283
+ if chart:
284
+ result["chart"] = chart
285
+ if block.get("view") in TABLE_VIEWS and block["view"] != "table":
286
+ result["view"] = block["view"]
287
+ return result
288
+
289
+
290
+ def _table_chart(value, columns):
291
+ """A table's chart: {"kind", "x": a column's key, "y": [numeric columns' keys], "stacked"?}, or None."""
292
+ if not isinstance(value, dict):
293
+ return None
294
+ kind = CHART_ALIASES.get(str(value.get("kind") or "bar").lower(), str(value.get("kind") or "bar").lower())
295
+ by_key = {column["key"]: column for column in columns}
296
+ x = value.get("x") if value.get("x") in by_key else None
297
+ wanted = value.get("y") if isinstance(value.get("y"), list) else []
298
+ y = [key for key in wanted if key in by_key and by_key[key]["type"] in NUMERIC_COLUMNS and key != x]
299
+ if kind not in CHART_KINDS or not y:
300
+ return None
301
+ out = {"kind": kind, "y": y[:1 if kind == "pie" else MAX_SCATTER_SERIES if kind == "scatter" else MAX_SERIES]}
302
+ if x:
303
+ out["x"] = x
304
+ if value.get("stacked") is True and kind in ("bar", "area") and len(out["y"]) > 1:
305
+ out["stacked"] = True
306
+ return out
307
+
308
+
309
+ def _media_list(block):
310
+ raw = block.get("items")
311
+ if not isinstance(raw, list) or not raw:
312
+ raise BlockError("A media list needs items")
313
+ items = []
314
+ for index, item in enumerate(raw[:MAX_ITEMS]):
315
+ if not isinstance(item, dict):
316
+ raise BlockError("A media list's item is an object")
317
+ sounds = [s for s in (_text(sound, 80, multiline=False) for sound in (item.get("sounds") or [])[:6]
318
+ if isinstance(item.get("sounds"), list)) if s]
319
+ out = {"id": _id(item.get("id"), f"m{index + 1}"),
320
+ "title": _text(item.get("title"), 200, multiline=False),
321
+ "creator": _text(item.get("creator"), 80, multiline=False),
322
+ "frameId": _frame(item.get("frameId")), "at": _number(item.get("at")),
323
+ "transcript": _text(item.get("transcript"), 600), "sounds": sounds or None,
324
+ "link": _link(item.get("link"))}
325
+ if not (out["title"] or out["creator"] or out["frameId"] or out["transcript"]):
326
+ raise BlockError("A media item needs a title, a creator, a screenshot or a transcript")
327
+ items.append({key: value for key, value in out.items() if value is not None})
328
+ return {"items": items}
329
+
330
+
331
+ def _evidence(block):
332
+ out = {"claim": _text(block.get("claim"), 600, required=True, field="Evidence's claim"),
333
+ "frameId": _frame(block.get("frameId")), "crop": _crop(block.get("crop")),
334
+ "quote": _text(block.get("quote"), 300), "verdict": status_word(block.get("verdict"))}
335
+ if out["verdict"] not in STATUSES:
336
+ out["verdict"] = None
337
+ if not out["frameId"] and not out["quote"]:
338
+ raise BlockError("Evidence needs a screenshot or a quote")
339
+ return {key: value for key, value in out.items() if value is not None}
340
+
341
+
342
+ def _checklist(block):
343
+ raw = block.get("items")
344
+ if not isinstance(raw, list) or not raw:
345
+ raise BlockError("A checklist needs items")
346
+ items = []
347
+ for index, item in enumerate(raw[:MAX_ITEMS]):
348
+ if not isinstance(item, dict):
349
+ raise BlockError("A checklist's item is an object")
350
+ status = status_word(item.get("status"))
351
+ out = {"id": _id(item.get("id"), f"s{index + 1}"),
352
+ "label": _text(item.get("label"), 200, required=True, field="A checklist item's label"),
353
+ "status": status if status in STATUSES else "uncertain",
354
+ "expected": _text(item.get("expected"), 400), "observed": _text(item.get("observed"), 400),
355
+ "frameId": _frame(item.get("frameId"))}
356
+ items.append({key: value for key, value in out.items() if value is not None})
357
+ return {"items": items}
358
+
359
+
360
+ def _markdown(block):
361
+ return {"text": _text(block.get("text"), MAX_MARKDOWN, required=True, field="Markdown's text")}
362
+
363
+
364
+ def _chart(block):
365
+ """bar, line, area, pie, scatter or sparkline."""
366
+ kind = str(block.get("kind") or "bar").strip().lower()
367
+ kind = CHART_ALIASES.get(kind, kind)
368
+ if kind not in CHART_KINDS:
369
+ raise BlockError("A chart is bar, line, area, pie, scatter or sparkline")
370
+ out = {"kind": kind}
371
+ raw = block.get("series") if isinstance(block.get("series"), list) else []
372
+ if kind == "scatter":
373
+ series = []
374
+ for one in raw[:MAX_SCATTER_SERIES]:
375
+ if not isinstance(one, dict) or not isinstance(one.get("points"), list):
376
+ raise BlockError("A scatter chart's series each hold points")
377
+ points = []
378
+ for point in one["points"][:MAX_SCATTER_POINTS]:
379
+ if isinstance(point, (list, tuple)) and len(point) >= 2:
380
+ point = {"x": point[0], "y": point[1]}
381
+ if not isinstance(point, dict):
382
+ continue
383
+ x, y = _number(point.get("x")), _number(point.get("y"))
384
+ if x is None or y is None:
385
+ continue
386
+ label = _text(point.get("label"), 60, multiline=False)
387
+ points.append({"x": x, "y": y, **({"label": label} if label else {})})
388
+ if points:
389
+ series.append({"label": _text(one.get("label"), 40, multiline=False) or "Value", "points": points})
390
+ if not series:
391
+ raise BlockError("A scatter chart needs points")
392
+ out["series"] = series
393
+ else:
394
+ x = block.get("x")
395
+ if not isinstance(x, list) or not 1 <= len(x) <= MAX_POINTS:
396
+ raise BlockError(f"A chart has 1 to {MAX_POINTS} labels")
397
+ labels = [_text(label, 40, multiline=False) or "" for label in x]
398
+ series = []
399
+ for one in raw[:1 if kind == "pie" else MAX_SERIES]:
400
+ if (not isinstance(one, dict) or not isinstance(one.get("values"), list)
401
+ or len(one["values"]) != len(labels)):
402
+ raise BlockError("Each series has one value per label")
403
+ values = [_number(value) for value in one["values"]]
404
+ series.append({"label": _text(one.get("label"), 40, multiline=False) or "Value", "values": values})
405
+ if not series:
406
+ raise BlockError("A chart needs a series")
407
+ out.update(x=labels, series=series)
408
+ unit = _text(block.get("unit"), 12, multiline=False)
409
+ if unit:
410
+ out["unit"] = unit
411
+ if block.get("format") in VALUE_FORMATS:
412
+ out["format"] = block["format"]
413
+ if isinstance(block.get("currency"), str) and re.fullmatch(r"[A-Z]{3}", block["currency"]):
414
+ out["currency"] = block["currency"]
415
+ if block.get("stacked") is True and kind in ("bar", "area") and len(out["series"]) > 1:
416
+ out["stacked"] = True
417
+ for key in ("xLabel", "yLabel"):
418
+ label = _text(block.get(key), 40, multiline=False)
419
+ if label:
420
+ out[key] = label
421
+ return out
422
+
423
+
424
+ def _coordinate(value, limit):
425
+ number = _number(value)
426
+ if number is None and isinstance(value, str):
427
+ try:
428
+ number = float(value.strip())
429
+ except ValueError:
430
+ return None
431
+ return round(number, 6) if number is not None and -limit <= number <= limit else None
432
+
433
+
434
+ def _map(block):
435
+ """Places on a map: each a name and an address or a latitude and longitude (``lat``, ``lon``)."""
436
+ raw = block.get("places")
437
+ if not isinstance(raw, list) or not raw:
438
+ raise BlockError("A map needs places")
439
+ places = []
440
+ for index, place in enumerate(raw[:MAX_PLACES]):
441
+ if not isinstance(place, dict):
442
+ raise BlockError("A map's place is an object")
443
+ lat = _coordinate(place.get("lat", place.get("latitude")), 90)
444
+ lon = _coordinate(place.get("lon", place.get("lng", place.get("longitude"))), 180)
445
+ out = {"id": _id(place.get("id"), f"p{index + 1}"),
446
+ "name": _text(place.get("name") or place.get("title"), 120, required=True, field="A place's name",
447
+ multiline=False),
448
+ "address": _text(place.get("address"), 200, multiline=False),
449
+ "lat": lat if lon is not None else None, "lon": lon if lat is not None else None,
450
+ "note": _text(place.get("note") or place.get("detail"), 300),
451
+ "rating": _number(place.get("rating")), "link": _link(place.get("link")),
452
+ "frameId": _frame(place.get("frameId"))}
453
+ if out["lat"] is None and not out["address"]:
454
+ raise BlockError("A place needs an address or a latitude and longitude")
455
+ places.append({key: value for key, value in out.items() if value is not None})
456
+ out = {"places": places}
457
+ near = _text(block.get("near"), 120, multiline=False)
458
+ if near:
459
+ out["near"] = near
460
+ return out
461
+
462
+
463
+ def _timeline(block):
464
+ """Events in order: ``at`` (a time, a date or ISO 8601, as text), a title, a detail, a state."""
465
+ raw = block.get("items")
466
+ if not isinstance(raw, list) or not raw:
467
+ raise BlockError("A timeline needs items")
468
+ items = []
469
+ for index, item in enumerate(raw[:MAX_ITEMS]):
470
+ if not isinstance(item, dict):
471
+ raise BlockError("A timeline's item is an object")
472
+ state = str(item.get("state") or item.get("status") or "").strip().lower()
473
+ state = status_word(state) if state in ("passed", "failed", "ok", "unsure", "unknown") else state
474
+ out = {"id": _id(item.get("id"), f"e{index + 1}"),
475
+ "at": _text(item.get("at") or item.get("when") or item.get("time"), 40, multiline=False),
476
+ "title": _text(item.get("title") or item.get("label"), 200, required=True,
477
+ field="A timeline item's title", multiline=False),
478
+ "detail": _text(item.get("detail") or item.get("description"), 600),
479
+ "state": state if state in TIMELINE_STATES else None, "frameId": _frame(item.get("frameId"))}
480
+ items.append({key: value for key, value in out.items() if value is not None})
481
+ return {"items": items}
482
+
483
+
484
+ def _comparison(block):
485
+ raw = block.get("options")
486
+ if not isinstance(raw, list) or not 2 <= len(raw) <= MAX_OPTIONS:
487
+ raise BlockError(f"A comparison has 2 to {MAX_OPTIONS} options")
488
+ options, seen = [], set()
489
+ for index, option in enumerate(raw):
490
+ if not isinstance(option, dict):
491
+ raise BlockError("A comparison's option is an object")
492
+ option_id = _id(option.get("id"), f"o{index + 1}")
493
+ if option_id in seen:
494
+ option_id = f"o{index + 1}"
495
+ seen.add(option_id)
496
+ facts = option.get("facts")
497
+ if isinstance(facts, dict):
498
+ facts = [{"label": key, "value": value} for key, value in facts.items()]
499
+ clean = []
500
+ for fact in (facts if isinstance(facts, list) else [])[:12]:
501
+ if isinstance(fact, dict):
502
+ label = _text(fact.get("label"), 60, multiline=False)
503
+ value = _text(fact.get("value"), 200, multiline=False)
504
+ if label and value is not None:
505
+ clean.append({"label": label, "value": value})
506
+
507
+ def words(key):
508
+ values = option.get(key) if isinstance(option.get(key), list) else []
509
+ return [w for w in (_text(v, 160, multiline=False) for v in values[:6]) if w] or None
510
+
511
+ out = {"id": option_id,
512
+ "name": _text(option.get("name") or option.get("title"), 80, required=True,
513
+ field="An option's name", multiline=False),
514
+ "subtitle": _text(option.get("subtitle"), 120, multiline=False),
515
+ "badge": _text(option.get("badge"), 24, multiline=False),
516
+ "facts": clean or None, "pros": words("pros"), "cons": words("cons"),
517
+ "link": _link(option.get("link")), "frameId": _frame(option.get("frameId"))}
518
+ options.append({key: value for key, value in out.items() if value is not None})
519
+ out = {"options": options}
520
+ if block.get("recommended") in seen:
521
+ out["recommended"] = block["recommended"]
522
+ verdict = _text(block.get("verdict"), 400)
523
+ if verdict:
524
+ out["verdict"] = verdict
525
+ return out
526
+
527
+
528
+ def _gallery(block):
529
+ """Screens from the run, in a grid: each a frameId and a caption."""
530
+ raw = block.get("items")
531
+ if not isinstance(raw, list) or not raw:
532
+ raise BlockError("A gallery needs items")
533
+ items = []
534
+ for index, item in enumerate(raw[:MAX_ITEMS]):
535
+ if not isinstance(item, dict):
536
+ raise BlockError("A gallery's item is an object")
537
+ frame = _frame(item.get("frameId"))
538
+ if not frame:
539
+ continue
540
+ out = {"id": _id(item.get("id"), f"g{index + 1}"), "frameId": frame,
541
+ "caption": _text(item.get("caption") or item.get("title"), 160, multiline=False),
542
+ "link": _link(item.get("link"))}
543
+ items.append({key: value for key, value in out.items() if value is not None})
544
+ if not items:
545
+ raise BlockError("A gallery's items each name a screen (frameId)")
546
+ return {"items": items}
547
+
548
+
549
+ SHAPES = {"review_queue": _review_queue, "table": _table, "media_list": _media_list, "evidence": _evidence,
550
+ "checklist": _checklist, "markdown": _markdown, "chart": _chart, "map": _map, "timeline": _timeline,
551
+ "comparison": _comparison, "gallery": _gallery}
552
+ TYPES = tuple(SHAPES)
553
+
554
+
555
+ def _readable(block):
556
+ """What a block that didn't fit still says, as Markdown: its title and every string in it, or None."""
557
+ lines = []
558
+
559
+ def walk(node, depth=0):
560
+ if depth > 6 or len(lines) > 80:
561
+ return
562
+ if isinstance(node, str):
563
+ text = _text(node, 400)
564
+ if text and not text.startswith("https://") and text not in lines:
565
+ lines.append(text)
566
+ elif isinstance(node, (int, float)) and not isinstance(node, bool):
567
+ return
568
+ elif isinstance(node, dict):
569
+ for key, value in node.items():
570
+ if key not in ("type", "id", "frameId", "bundleId", "kind", "action", "bucket", "key"):
571
+ walk(value, depth + 1)
572
+ elif isinstance(node, list):
573
+ for value in node:
574
+ walk(value, depth + 1)
575
+
576
+ walk(block)
577
+ text = "\n".join(f"- {line}" if index else line for index, line in enumerate(lines))
578
+ return text[:MAX_MARKDOWN] or None
579
+
580
+
581
+ def validate_block(block, index=0):
582
+ """One block, normalized: {type, id, title?, ...its fields}. Raises BlockError when it doesn't fit."""
583
+ if not isinstance(block, dict):
584
+ raise BlockError("A block is an object")
585
+ kind = block.get("type")
586
+ if kind not in SHAPES:
587
+ raise BlockError(f"Unknown block type {str(kind)[:40]!r}")
588
+ out = {"type": kind, "id": _id(block.get("id"), f"b{index + 1}")}
589
+ title = _text(block.get("title"), MAX_TITLE, multiline=False)
590
+ if title:
591
+ out["title"] = title
592
+ out.update(SHAPES[kind](block))
593
+ return out
594
+
595
+
596
+ def validate(value):
597
+ """(envelope, problems)."""
598
+ problems = []
599
+ if isinstance(value, dict):
600
+ if value.get("v", VERSION) != VERSION:
601
+ return None, [f"Unsupported result blocks version {str(value.get('v'))[:8]}"]
602
+ value = value.get("blocks")
603
+ if not isinstance(value, list):
604
+ return None, ["Result blocks are a list"] if value is not None else []
605
+ if len(value) > MAX_BLOCKS:
606
+ problems.append(f"Only the first {MAX_BLOCKS} blocks are kept")
607
+ blocks, ids = [], set()
608
+ for index, block in enumerate(value[:MAX_BLOCKS]):
609
+ try:
610
+ shaped = validate_block(block, index)
611
+ except BlockError as problem:
612
+ problems.append(str(problem))
613
+ text = _readable(block)
614
+ if not text:
615
+ continue
616
+ shaped = {"type": "markdown", "id": f"b{index + 1}", "text": text, "fallback": True}
617
+ except (TypeError, ValueError, AttributeError, KeyError):
618
+ problems.append("A block couldn't be read")
619
+ continue
620
+ if shaped["id"] in ids:
621
+ shaped["id"] = f"b{index + 1}"
622
+ while shaped["id"] in ids:
623
+ shaped["id"] += "_"
624
+ ids.add(shaped["id"])
625
+ blocks.append(shaped)
626
+ if not blocks:
627
+ return None, problems
628
+ envelope = {"v": VERSION, "blocks": blocks}
629
+ while len(json.dumps(envelope, ensure_ascii=False).encode()) > MAX_BYTES and envelope["blocks"]:
630
+ envelope["blocks"].pop()
631
+ problems.append("The blocks were too large; the last was left out")
632
+ return (envelope if envelope["blocks"] else None), problems
633
+
634
+
635
+ def review_queue(items, *, title=None, buckets=None, action="send_message", id="queue"):
636
+ return {"type": "review_queue", "id": id, **({"title": title} if title else {}), "action": action,
637
+ **({"buckets": list(buckets)} if buckets is not None else {}), "items": list(items)}
638
+
639
+
640
+ def table(columns, rows, *, title=None, sort=None, id="table"):
641
+ return {"type": "table", "id": id, **({"title": title} if title else {}), "columns": list(columns),
642
+ "rows": list(rows), **({"sort": sort} if sort else {})}
643
+
644
+
645
+ def media_list(items, *, title=None, id="media"):
646
+ return {"type": "media_list", "id": id, **({"title": title} if title else {}), "items": list(items)}
647
+
648
+
649
+ def evidence(claim, *, frame_id=None, crop=None, quote=None, verdict=None, title=None, id="evidence"):
650
+ return {"type": "evidence", "id": id, **({"title": title} if title else {}), "claim": claim, "frameId": frame_id,
651
+ "crop": crop, "quote": quote, "verdict": verdict}
652
+
653
+
654
+ def checklist(items, *, title=None, id="checks"):
655
+ return {"type": "checklist", "id": id, **({"title": title} if title else {}), "items": list(items)}
656
+
657
+
658
+ def markdown(text, *, title=None, id="text"):
659
+ return {"type": "markdown", "id": id, **({"title": title} if title else {}), "text": text}
660
+
661
+
662
+ def chart(x, series, *, kind="bar", title=None, unit=None, id="chart", **extra):
663
+ """A chart block."""
664
+ return {"type": "chart", "id": id, **({"title": title} if title else {}), "kind": kind,
665
+ **({"x": list(x)} if x is not None else {}), "series": list(series), **({"unit": unit} if unit else {}),
666
+ **extra}
667
+
668
+
669
+ def map_block(places, *, title=None, near=None, id="map"):
670
+ return {"type": "map", "id": id, **({"title": title} if title else {}), "places": list(places),
671
+ **({"near": near} if near else {})}
672
+
673
+
674
+ def timeline(items, *, title=None, id="timeline"):
675
+ return {"type": "timeline", "id": id, **({"title": title} if title else {}), "items": list(items)}
676
+
677
+
678
+ def comparison(options, *, title=None, recommended=None, verdict=None, id="compare"):
679
+ return {"type": "comparison", "id": id, **({"title": title} if title else {}), "options": list(options),
680
+ **({"recommended": recommended} if recommended else {}), **({"verdict": verdict} if verdict else {})}
681
+
682
+
683
+ def gallery(items, *, title=None, id="gallery"):
684
+ return {"type": "gallery", "id": id, **({"title": title} if title else {}), "items": list(items)}
685
+
686
+
687
+ def envelope(blocks):
688
+ """The validated envelope of ``blocks`` (None when none is usable)."""
689
+ return validate(list(blocks))[0]
690
+
691
+
692
+ def merge(*envelopes):
693
+ """Several envelopes as one, in order, ids kept unique (None when all are empty)."""
694
+ blocks = [block for value in envelopes if isinstance(value, dict) for block in value.get("blocks") or ()]
695
+ return validate(blocks)[0] if blocks else None
696
+
697
+
698
+ def _cell_text(value):
699
+ if value is None:
700
+ return ""
701
+ if isinstance(value, bool):
702
+ return "Yes" if value else "No"
703
+ return str(value)
704
+
705
+
706
+ def _guard(text):
707
+ """A spreadsheet never runs a cell: one that starts like a formula gets an apostrophe (result-formats.md)."""
708
+ return "'" + text if text[:1] in ("=", "+", "-", "@", "\t", "\r") else text
709
+
710
+
711
+ def table_csv(block):
712
+ """A table block as CSV text: a header of the column labels, then one line per row."""
713
+ out = io.StringIO()
714
+ writer = csv.writer(out, lineterminator="\n")
715
+ writer.writerow([_guard(column["label"]) for column in block["columns"]])
716
+ for row in block["rows"]:
717
+ writer.writerow([_guard(_cell_text(row["cells"].get(column["key"]))) for column in block["columns"]])
718
+ return out.getvalue()
719
+
720
+
721
+ def _md_cell(value):
722
+ return _cell_text(value).replace("|", "\\|").replace("\n", " ")
723
+
724
+
725
+ def comparison_rows(options):
726
+ """Fact labels in first-seen order, with each option's first value or an empty cell."""
727
+ labels = dict.fromkeys(fact["label"] for option in options for fact in option.get("facts") or ())
728
+ for label in labels:
729
+ yield label, [next((fact["value"] for fact in option.get("facts") or () if fact["label"] == label), "")
730
+ for option in options]
731
+
732
+
733
+ def block_markdown(block):
734
+ """One block as Markdown."""
735
+ kind, lines = block["type"], []
736
+ if block.get("title"):
737
+ lines += [f"## {block['title']}", ""]
738
+ if kind == "markdown":
739
+ lines.append(block["text"])
740
+ elif kind == "table":
741
+ columns = block["columns"]
742
+ lines.append("| " + " | ".join(_md_cell(c["label"]) for c in columns) + " |")
743
+ lines.append("| " + " | ".join("---:" if c["type"] in NUMERIC_COLUMNS else "---"
744
+ for c in columns) + " |")
745
+ for row in block["rows"]:
746
+ lines.append("| " + " | ".join(_md_cell(row["cells"].get(c["key"])) for c in columns) + " |")
747
+ elif kind == "review_queue":
748
+ labels = {bucket["id"]: bucket["label"] for bucket in block.get("buckets") or ()}
749
+ for item in block["items"]:
750
+ where = f" ({item['app']})" if item.get("app") else ""
751
+ bucket = f" [{labels[item['bucket']]}]" if item.get("bucket") in labels else ""
752
+ lines.append(f"**{item['to']}**{where}{bucket}")
753
+ if item.get("context"):
754
+ lines.append(item["context"])
755
+ if item.get("lastMessage"):
756
+ lines.append(f"> {item['lastMessage']}")
757
+ if item.get("decision"):
758
+ lines.append(f"Needs your decision: {item['decision']}")
759
+ if item.get("draft"):
760
+ lines.append(f"Draft: {item['draft']}")
761
+ lines.append("")
762
+ lines.append("Nothing here was sent.")
763
+ elif kind == "media_list":
764
+ for item in block["items"]:
765
+ head = ", ".join(part for part in (item.get("title"), item.get("creator")) if part) or "Item"
766
+ lines.append(f"- **{head}**" + (f" ({item['link']})" if item.get("link") else ""))
767
+ if item.get("sounds"):
768
+ lines.append(f" Sounds: {', '.join(item['sounds'])}")
769
+ if item.get("transcript"):
770
+ lines.append(f" > {item['transcript']}")
771
+ elif kind == "evidence":
772
+ verdict = f" ({block['verdict']})" if block.get("verdict") else ""
773
+ lines.append(f"{block['claim']}{verdict}")
774
+ if block.get("quote"):
775
+ lines.append(f"> {block['quote']}")
776
+ elif kind == "checklist":
777
+ marks = {"pass": "Pass", "fail": "Fail", "uncertain": "Needs review"}
778
+ for item in block["items"]:
779
+ lines.append(f"- {marks[item['status']]}: {item['label']}")
780
+ if item.get("expected"):
781
+ lines.append(f" Expected: {item['expected']}")
782
+ if item.get("observed"):
783
+ lines.append(f" Observed: {item['observed']}")
784
+ elif kind == "chart" and block.get("kind") == "scatter":
785
+ lines += ["| Series | Point | x | y |", "| --- | --- | ---: | ---: |"]
786
+ for one in block["series"]:
787
+ for point in one["points"]:
788
+ lines.append(f"| {_md_cell(one['label'])} | {_md_cell(point.get('label'))} | "
789
+ f"{_cell_text(point['x'])} | {_cell_text(point['y'])} |")
790
+ elif kind == "chart":
791
+ names = [one["label"] for one in block["series"]]
792
+ lines.append("| | " + " | ".join(names) + " |")
793
+ lines.append("| --- |" + " ---: |" * len(names))
794
+ for index, label in enumerate(block["x"]):
795
+ lines.append(f"| {_md_cell(label)} | "
796
+ + " | ".join(_cell_text(one["values"][index]) for one in block["series"]) + " |")
797
+ elif kind == "map":
798
+ for index, place in enumerate(block["places"], 1):
799
+ where = place.get("address") or f"{place['lat']}, {place['lon']}"
800
+ lines.append(f"{index}. **{place['name']}**: {where}"
801
+ + (f" ({place['note']})" if place.get("note") else ""))
802
+ elif kind == "timeline":
803
+ for item in block["items"]:
804
+ lines.append(f"- {item['at'] + ': ' if item.get('at') else ''}**{item['title']}**"
805
+ + (f". {item['detail']}" if item.get("detail") else ""))
806
+ elif kind == "comparison":
807
+ options = block["options"]
808
+ lines.append("| | " + " | ".join(_md_cell(o["name"]) for o in options) + " |")
809
+ lines.append("| --- |" + " --- |" * len(options))
810
+ for label, values in comparison_rows(options):
811
+ lines.append(f"| {_md_cell(label)} | " + " | ".join(_md_cell(v) for v in values) + " |")
812
+ for key, word in (("pros", "Pros"), ("cons", "Cons")):
813
+ if any(o.get(key) for o in options):
814
+ lines.append(f"| {word} | " + " | ".join(_md_cell("; ".join(o.get(key) or ())) for o in options) + " |")
815
+ best = next((o["name"] for o in options if o["id"] == block.get("recommended")), None)
816
+ if best or block.get("verdict"):
817
+ lines += ["", (f"**Pick: {best}.** " if best else "") + (block.get("verdict") or "")]
818
+ elif kind == "gallery":
819
+ for index, item in enumerate(block["items"], 1):
820
+ lines.append(f"{index}. {item.get('caption') or 'Screen'}")
821
+ return "\n".join(lines).rstrip() + "\n"
822
+
823
+
824
+ def to_markdown(value):
825
+ """Every block of an envelope as Markdown, one after the other ("" for none)."""
826
+ blocks = value.get("blocks") if isinstance(value, dict) else None
827
+ return "\n".join(block_markdown(block) for block in blocks or ())
828
+
829
+
830
+ def find(value, block_id, item_id=None):
831
+ """(block, item) in a stored envelope, or (None, None)."""
832
+ for block in (value or {}).get("blocks") or () if isinstance(value, dict) else ():
833
+ if block.get("id") == block_id:
834
+ if item_id is None:
835
+ return block, None
836
+ item = next((one for one in block.get("items") or () if one.get("id") == item_id), None)
837
+ return (block, item) if item is not None else (None, None)
838
+ return None, None
839
+
840
+
841
+ def approval_item(value, block_id, item_id, text):
842
+ """The item an Approve names and its exact words, from the STORED envelope; BlockError when it can't go."""
843
+ block, item = find(value, block_id, item_id)
844
+ if block is None or block.get("type") != "review_queue":
845
+ raise BlockError("That draft isn't in this task's results anymore.")
846
+ if text is not None and not isinstance(text, str):
847
+ raise BlockError("A draft is text.")
848
+ if not item.get("draft") and item.get("yourWords") is not True:
849
+ # An item with no draft has no Approve (an opener to someone who doesn't fit or asked for no DMs, a
850
+ # conversation that can wait): typed words don't make one. Only an item code marked ``yourWords`` takes
851
+ # them (validate stores that mark for nothing a client or a model sent), and then they go through every check
852
+ # below as a draft would: an empty reply, a part in brackets, a secret, no recipient.
853
+ raise BlockError("This one has no draft, so there's nothing to approve.")
854
+ words = _CONTROL.sub("", text if text is not None else item.get("draft") or "").strip()
855
+ if not words:
856
+ raise BlockError("Write the message first. There's nothing to send.")
857
+ if len(words) > 4000:
858
+ raise BlockError("A message is at most 4,000 characters.")
859
+ if BLANK.search(words):
860
+ raise BlockError("Fill in the part in brackets first, then approve it.")
861
+ if is_secret(words):
862
+ raise BlockError("This draft looks like it holds a password or a code, so Mobster won't send it.")
863
+ if not item.get("to"):
864
+ raise BlockError("Mobster doesn't know who this goes to, so it can't send it.")
865
+ return block, item, words
866
+
867
+
868
+ VERBS = {"send_message": "send this message to", "reply": "reply to", "post_comment": "post this comment for"}
869
+
870
+
871
+ def follow_up_goal(block, item, words):
872
+ """The follow-up task's words: exactly this text, to exactly this recipient, in this app, and nothing else."""
873
+ who = item["to"] + (f" ({item['handle']})" if item.get("handle") and item["handle"] != item["to"] else "")
874
+ where = f"In {item['app']}, " if item.get("app") else ""
875
+ verb = VERBS.get(block.get("action"), VERBS["send_message"])
876
+ first = (where + verb) if where else verb[:1].upper() + verb[1:]
877
+ account = f" Send it from {item['account']}." if item.get("account") else ""
878
+ return (f"{first} {who}, word for word: \u201c{words}\u201d.{account} Use these exact words, open the "
879
+ f"conversation with exactly {item['to']}, and send nothing else.")
880
+
881
+
882
+ def preapproval(block, item, words):
883
+ """What the follow-up task's run keeps, so its one send of these words to this person needs no second OK."""
884
+ return {"to": item["to"], "handle": item.get("handle"), "app": item.get("app"), "text": words,
885
+ "source": {"block": block["id"], "item": item["id"]}, "used": False}
886
+
887
+
888
+ def _who(text):
889
+ return " ".join(str(text or "").casefold().lstrip("@").split())
890
+
891
+
892
+ def _plain(text):
893
+ return " ".join(str(text or "").casefold().replace("\u2019", "'").replace("\u2018", "'").replace("\u201c", '"')
894
+ .replace("\u201d", '"').replace("\u00a0", " ").split())
895
+
896
+
897
+ def preapproval_matches(spec, request):
898
+ """Whether ``request`` is exactly the send ``spec`` covers (words, shown recipient, app, unused); else it asks."""
899
+ if not isinstance(spec, dict) or spec.get("used") or not isinstance(request, dict):
900
+ return False
901
+ if spec.get("kind") is not None:
902
+ return False # the content queue's: Run.request_approval matches it itself
903
+ if (request.get("kind") or "action") != "commit" or request.get("act") not in (
904
+ "send_message", "reply", "comment", "post", "send"):
905
+ return False
906
+ if request.get("operation") not in ("TAP", "SUBMIT", "TYPE_SUBMIT"):
907
+ return False
908
+ if request.get("recipient_seen") is not True or not request.get("recipient"):
909
+ return False
910
+ if _plain(request.get("text")) != _plain(spec.get("text")) or not _plain(spec.get("text")):
911
+ return False
912
+ shown = _who(request.get("recipient"))
913
+ if shown not in {who for who in (_who(spec.get("to")), _who(spec.get("handle"))) if who}:
914
+ return False
915
+ if spec.get("app") and _who(request.get("app_name")) != _who(spec["app"]):
916
+ return False
917
+ return True
918
+
919
+
920
+ def from_report(report):
921
+ if not isinstance(report, dict):
922
+ return None
923
+ blocks = []
924
+ own = report.get("blocks")
925
+ if isinstance(own, dict):
926
+ blocks += list(own.get("blocks") or ())
927
+ elif isinstance(own, list):
928
+ blocks += own
929
+ if not any(isinstance(block, dict) and block.get("type") == "review_queue" for block in blocks):
930
+ queue = report.get("queue") if isinstance(report.get("queue"), dict) else None
931
+ rows = queue.get("items") if queue else report.get("drafts")
932
+ if isinstance(rows, list) and rows:
933
+ items = [{"id": row.get("id"), "bucket": row.get("bucket") or "reply", "to": row.get("to"),
934
+ "app": row.get("app"), "bundleId": row.get("bundleId"),
935
+ "context": row.get("summary") or row.get("context"), "lastMessage": row.get("lastMessage"),
936
+ "draft": row.get("draft"), "decision": row.get("decision"), "frameId": row.get("frameId")}
937
+ for row in rows if isinstance(row, dict) and row.get("to")]
938
+ if items:
939
+ title = report.get("title") if queue and isinstance(report.get("title"), str) else "Drafts to review"
940
+ blocks.insert(0, review_queue(items, title=title))
941
+ return envelope(blocks) if blocks else None