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.
- mobile_agent/__init__.py +4 -0
- mobile_agent/__main__.py +598 -0
- mobile_agent/accounts/__init__.py +1 -0
- mobile_agent/accounts/context.py +80 -0
- mobile_agent/accounts/mcp.py +55 -0
- mobile_agent/accounts/pacing.py +318 -0
- mobile_agent/accounts/register.py +78 -0
- mobile_agent/accounts/routes.py +212 -0
- mobile_agent/accounts/routing.py +69 -0
- mobile_agent/accounts/screen.py +273 -0
- mobile_agent/accounts/stats.py +54 -0
- mobile_agent/accounts/store.py +394 -0
- mobile_agent/agent_hooks.py +123 -0
- mobile_agent/ai_plans/__init__.py +7 -0
- mobile_agent/ai_plans/agents.py +96 -0
- mobile_agent/ai_plans/claude_code.py +208 -0
- mobile_agent/ai_plans/cli.py +400 -0
- mobile_agent/ai_plans/codex.py +258 -0
- mobile_agent/ai_plans/compat_chat.py +307 -0
- mobile_agent/ai_plans/device_login.py +164 -0
- mobile_agent/ai_plans/draft_client.py +68 -0
- mobile_agent/ai_plans/gemini.py +137 -0
- mobile_agent/ai_plans/instructions.py +65 -0
- mobile_agent/ai_plans/mcp_http.py +160 -0
- mobile_agent/ai_plans/phone_tools.py +892 -0
- mobile_agent/ai_plans/providers.py +235 -0
- mobile_agent/ai_plans/routes.py +160 -0
- mobile_agent/ai_plans/runner.py +400 -0
- mobile_agent/ai_plans/vault.py +114 -0
- mobile_agent/alerts.py +337 -0
- mobile_agent/android/__init__.py +18 -0
- mobile_agent/android/adb.py +268 -0
- mobile_agent/android/conditions.py +128 -0
- mobile_agent/android/doctor.py +54 -0
- mobile_agent/android/driver.py +137 -0
- mobile_agent/android/emulator.py +216 -0
- mobile_agent/android/logs.py +85 -0
- mobile_agent/android/manager.py +225 -0
- mobile_agent/android/records.py +31 -0
- mobile_agent/android/tree.py +96 -0
- mobile_agent/answer_shapes.py +230 -0
- mobile_agent/api_devices.py +333 -0
- mobile_agent/api_errors.py +17 -0
- mobile_agent/api_get.py +249 -0
- mobile_agent/api_http.py +467 -0
- mobile_agent/api_post.py +453 -0
- mobile_agent/api_routes.py +173 -0
- mobile_agent/app_api/__init__.py +4 -0
- mobile_agent/app_api/cli.py +151 -0
- mobile_agent/app_api/endpoints.py +193 -0
- mobile_agent/app_api/mcp.py +54 -0
- mobile_agent/app_api/openapi.py +146 -0
- mobile_agent/app_api/reading.py +172 -0
- mobile_agent/app_api/register.py +10 -0
- mobile_agent/app_api/screen.py +216 -0
- mobile_agent/app_api/service.py +382 -0
- mobile_agent/app_api/sessions.py +404 -0
- mobile_agent/app_api/tokens.py +167 -0
- mobile_agent/app_choice.py +131 -0
- mobile_agent/app_icons.py +183 -0
- mobile_agent/app_killer.py +69 -0
- mobile_agent/artifacts.py +606 -0
- mobile_agent/attach/__init__.py +13 -0
- mobile_agent/attach/cli.py +46 -0
- mobile_agent/attach/flutter.py +28 -0
- mobile_agent/attach/score.py +119 -0
- mobile_agent/attach/web.py +13 -0
- mobile_agent/attachments/__init__.py +1 -0
- mobile_agent/attachments/agent.py +418 -0
- mobile_agent/attachments/extract.py +217 -0
- mobile_agent/attachments/mcp.py +104 -0
- mobile_agent/attachments/register.py +73 -0
- mobile_agent/attachments/routes.py +326 -0
- mobile_agent/attachments/service.py +170 -0
- mobile_agent/attachments/sniff.py +152 -0
- mobile_agent/attachments/store.py +441 -0
- mobile_agent/audit_verify.py +436 -0
- mobile_agent/bench/README.md +410 -0
- mobile_agent/bench/__init__.py +0 -0
- mobile_agent/bench/__main__.py +394 -0
- mobile_agent/bench/ab.py +168 -0
- mobile_agent/bench/actuation.py +368 -0
- mobile_agent/bench/agents/__init__.py +9 -0
- mobile_agent/bench/agents/base.py +105 -0
- mobile_agent/bench/agents/plan_clients.py +245 -0
- mobile_agent/bench/agents/smart.py +533 -0
- mobile_agent/bench/checks.py +358 -0
- mobile_agent/bench/decide_gate.py +214 -0
- mobile_agent/bench/diagnose.py +181 -0
- mobile_agent/bench/evals/__init__.py +14 -0
- mobile_agent/bench/evals/build.py +565 -0
- mobile_agent/bench/evals/checkpoint.py +214 -0
- mobile_agent/bench/evals/irt.py +219 -0
- mobile_agent/bench/evals/l0.py +647 -0
- mobile_agent/bench/evals/l1.py +866 -0
- mobile_agent/bench/evals/models.py +124 -0
- mobile_agent/bench/evals/pairwise.py +189 -0
- mobile_agent/bench/evals/proxy.py +90 -0
- mobile_agent/bench/evals/sets.py +149 -0
- mobile_agent/bench/evals/traces.py +446 -0
- mobile_agent/bench/fixtures.py +176 -0
- mobile_agent/bench/guard_replay.py +129 -0
- mobile_agent/bench/h2-manifest.json +20 -0
- mobile_agent/bench/h3-manifest.json +20 -0
- mobile_agent/bench/heldout-manifest.json +32 -0
- mobile_agent/bench/heldout.py +375 -0
- mobile_agent/bench/iosworld.py +901 -0
- mobile_agent/bench/iosworld_stats.py +510 -0
- mobile_agent/bench/llm_units.py +116 -0
- mobile_agent/bench/manifest.json +93 -0
- mobile_agent/bench/metrics.py +66 -0
- mobile_agent/bench/phone.py +44 -0
- mobile_agent/bench/replay.py +172 -0
- mobile_agent/bench/report.py +207 -0
- mobile_agent/bench/runner.py +456 -0
- mobile_agent/bench/safety.py +310 -0
- mobile_agent/bench/stream_timing.py +213 -0
- mobile_agent/bench/suite.py +444 -0
- mobile_agent/bench/sweep.py +176 -0
- mobile_agent/bench/tape.py +798 -0
- mobile_agent/bench/tape_replay.py +88 -0
- mobile_agent/bench/trace_evals.py +128 -0
- mobile_agent/bench/trace_view.py +81 -0
- mobile_agent/bench/truth/iphone15pro.json +138 -0
- mobile_agent/bench/truth.py +340 -0
- mobile_agent/bench/tuning.py +543 -0
- mobile_agent/bench/tuning_analysis.py +656 -0
- mobile_agent/bench/vague-heldout-manifest.json +12 -0
- mobile_agent/bench/vague_rubric.py +135 -0
- mobile_agent/bench/vague_run.py +243 -0
- mobile_agent/brand.py +454 -0
- mobile_agent/brand_fonts.py +756 -0
- mobile_agent/build_identity.py +21 -0
- mobile_agent/cable_bt.py +623 -0
- mobile_agent/cable_bt_runtime.py +128 -0
- mobile_agent/catalog.py +69 -0
- mobile_agent/chart_svg.py +415 -0
- mobile_agent/chat_here.py +189 -0
- mobile_agent/cli_constants.py +5 -0
- mobile_agent/cli_parser.py +611 -0
- mobile_agent/cli_run.py +115 -0
- mobile_agent/compiled_read/__init__.py +21 -0
- mobile_agent/compiled_read/delta.py +89 -0
- mobile_agent/compiled_read/extractors.py +202 -0
- mobile_agent/compiled_read/routes.py +56 -0
- mobile_agent/completion.py +267 -0
- mobile_agent/compose.py +48 -0
- mobile_agent/config.py +114 -0
- mobile_agent/connectors/__init__.py +1 -0
- mobile_agent/connectors/api_session.py +76 -0
- mobile_agent/connectors/callback_page.py +146 -0
- mobile_agent/connectors/client.py +582 -0
- mobile_agent/connectors/github_app.py +55 -0
- mobile_agent/connectors/graph_api.py +396 -0
- mobile_agent/connectors/imports.py +170 -0
- mobile_agent/connectors/issue.py +66 -0
- mobile_agent/connectors/keystore.py +85 -0
- mobile_agent/connectors/marks.py +222 -0
- mobile_agent/connectors/oauth.py +476 -0
- mobile_agent/connectors/picker.py +228 -0
- mobile_agent/connectors/presets.py +591 -0
- mobile_agent/connectors/register.py +48 -0
- mobile_agent/connectors/routes.py +153 -0
- mobile_agent/connectors/service.py +966 -0
- mobile_agent/connectors/shared.py +136 -0
- mobile_agent/connectors/skill.py +446 -0
- mobile_agent/connectors/slack_api.py +269 -0
- mobile_agent/connectors/steps.py +92 -0
- mobile_agent/connectors/store.py +144 -0
- mobile_agent/connectors/vault.py +72 -0
- mobile_agent/console.py +178 -0
- mobile_agent/content_queue/__init__.py +1 -0
- mobile_agent/content_queue/client.py +263 -0
- mobile_agent/content_queue/importing.py +153 -0
- mobile_agent/content_queue/media_check.py +224 -0
- mobile_agent/content_queue/prompt.py +64 -0
- mobile_agent/content_queue/register.py +91 -0
- mobile_agent/content_queue/routes.py +370 -0
- mobile_agent/content_queue/runner.py +475 -0
- mobile_agent/content_queue/store.py +521 -0
- mobile_agent/contract.py +996 -0
- mobile_agent/contract_asks.py +248 -0
- mobile_agent/contract_prompt.py +45 -0
- mobile_agent/contract_screen.py +981 -0
- mobile_agent/contract_view.py +84 -0
- mobile_agent/corpus/__init__.py +26 -0
- mobile_agent/corpus/cli.py +63 -0
- mobile_agent/corpus/consent.py +55 -0
- mobile_agent/corpus/listener.py +76 -0
- mobile_agent/corpus/register.py +9 -0
- mobile_agent/corpus/skeleton.py +72 -0
- mobile_agent/corpus/store.py +143 -0
- mobile_agent/costs.py +161 -0
- mobile_agent/decider_scope.py +40 -0
- mobile_agent/decision_provider.py +189 -0
- mobile_agent/deeplinks.py +352 -0
- mobile_agent/device_info.py +116 -0
- mobile_agent/device_manager.py +978 -0
- mobile_agent/device_status.py +195 -0
- mobile_agent/device_targets.py +217 -0
- mobile_agent/devices.py +676 -0
- mobile_agent/devkit/__init__.py +64 -0
- mobile_agent/devkit/account_cli.py +90 -0
- mobile_agent/devkit/asc.py +562 -0
- mobile_agent/devkit/asc_cli.py +214 -0
- mobile_agent/devkit/ci.py +737 -0
- mobile_agent/devkit/ci_cli.py +196 -0
- mobile_agent/devkit/ci_hosts.py +194 -0
- mobile_agent/devkit/es256.py +197 -0
- mobile_agent/devkit/expo.py +440 -0
- mobile_agent/devkit/expo_cli.py +405 -0
- mobile_agent/devkit/expo_dev.py +203 -0
- mobile_agent/devkit/expo_routes.py +90 -0
- mobile_agent/devkit/fixtures_cli.py +133 -0
- mobile_agent/devkit/fixtures_routes.py +82 -0
- mobile_agent/devkit/links.py +375 -0
- mobile_agent/devkit/links_cli.py +123 -0
- mobile_agent/devkit/links_routes.py +83 -0
- mobile_agent/devkit/nightly.py +152 -0
- mobile_agent/devkit/shots.py +203 -0
- mobile_agent/devkit/testflight.py +155 -0
- mobile_agent/devkit/testflight_cli.py +127 -0
- mobile_agent/devkit/testflight_routes.py +111 -0
- mobile_agent/devkit/xcode.py +236 -0
- mobile_agent/devkit/xcode_cli.py +101 -0
- mobile_agent/devtools.py +419 -0
- mobile_agent/doctor.py +509 -0
- mobile_agent/doctor_fix.py +467 -0
- mobile_agent/downloads.py +55 -0
- mobile_agent/drivers.py +793 -0
- mobile_agent/dry_run.py +102 -0
- mobile_agent/effect_ledger.py +83 -0
- mobile_agent/engine_health.py +378 -0
- mobile_agent/engine_hooks.py +96 -0
- mobile_agent/engines.py +408 -0
- mobile_agent/env_file.py +63 -0
- mobile_agent/env_secrets.py +149 -0
- mobile_agent/errors.py +15 -0
- mobile_agent/evals/__init__.py +3 -0
- mobile_agent/evals/app_survey.py +149 -0
- mobile_agent/evals/development/__init__.py +1 -0
- mobile_agent/evals/development/score.py +276 -0
- mobile_agent/evals/latency_report.py +312 -0
- mobile_agent/evals/offline_report.py +374 -0
- mobile_agent/evals/oracles.py +298 -0
- mobile_agent/evals/redteam/__init__.py +15 -0
- mobile_agent/evals/redteam/__main__.py +72 -0
- mobile_agent/evals/redteam/cases.py +730 -0
- mobile_agent/evals/redteam/harness.py +604 -0
- mobile_agent/evals/redteam/live.py +139 -0
- mobile_agent/evals/redteam/plain.py +85 -0
- mobile_agent/evals/vision/__init__.py +0 -0
- mobile_agent/evals/vision/images/dog_001.jpg +0 -0
- mobile_agent/evals/vision/images/dog_002.jpg +0 -0
- mobile_agent/evals/vision/images/dog_003.jpg +0 -0
- mobile_agent/evals/vision/images/dog_004.jpg +0 -0
- mobile_agent/evals/vision/images/dog_005.jpg +0 -0
- mobile_agent/evals/vision/images/dog_006.jpg +0 -0
- mobile_agent/evals/vision/images/dog_007.jpg +0 -0
- mobile_agent/evals/vision/images/dog_010.jpg +0 -0
- mobile_agent/evals/vision/images/dog_011.jpg +0 -0
- mobile_agent/evals/vision/images/dog_013.jpg +0 -0
- mobile_agent/evals/vision/images/dog_014.jpg +0 -0
- mobile_agent/evals/vision/images/dog_015.jpg +0 -0
- mobile_agent/evals/vision/images/dog_016.jpg +0 -0
- mobile_agent/evals/vision/images/dog_017.jpg +0 -0
- mobile_agent/evals/vision/images/dog_019.jpg +0 -0
- mobile_agent/evals/vision/images/dog_020.jpg +0 -0
- mobile_agent/evals/vision/images/dog_021.jpg +0 -0
- mobile_agent/evals/vision/images/dog_022.jpg +0 -0
- mobile_agent/evals/vision/images/dog_023.jpg +0 -0
- mobile_agent/evals/vision/images/dog_024.jpg +0 -0
- mobile_agent/evals/vision/images/dog_026.jpg +0 -0
- mobile_agent/evals/vision/images/dog_027.jpg +0 -0
- mobile_agent/evals/vision/images/dog_034.jpg +0 -0
- mobile_agent/evals/vision/images/dog_035.jpg +0 -0
- mobile_agent/evals/vision/images/dog_038.jpg +0 -0
- mobile_agent/evals/vision/images/dog_042.jpg +0 -0
- mobile_agent/evals/vision/images/dog_047.jpg +0 -0
- mobile_agent/evals/vision/images/dog_052.jpg +0 -0
- mobile_agent/evals/vision/images/dog_058.jpg +0 -0
- mobile_agent/evals/vision/images/dog_062.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_000.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_010.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_011.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_015.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_016.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_017.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_019.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_021.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_025.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_026.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_027.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_030.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_032.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_033.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_034.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_035.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_036.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_037.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_039.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_040.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_041.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_042.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_043.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_044.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_046.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_047.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_048.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_049.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_051.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_053.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_054.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_055.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_058.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_059.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_002.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_003.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_004.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_007.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_009.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_010.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_011.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_012.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_013.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_014.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_016.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_017.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_023.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_027.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_029.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_032.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_033.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_036.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_041.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_043.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_047.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_049.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_051.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_053.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_056.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_057.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_058.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_059.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_060.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_061.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_062.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_063.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_064.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_065.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_066.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_067.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_068.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_069.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_070.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_071.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_000.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_001.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_002.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_004.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_005.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_008.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_009.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_010.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_012.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_013.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_015.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_016.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_017.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_018.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_021.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_022.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_023.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_024.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_030.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_031.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_032.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_033.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_034.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_036.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_038.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_039.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_040.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_041.jpg +0 -0
- mobile_agent/evals/vision/manifest.json +2355 -0
- mobile_agent/eventbus.py +110 -0
- mobile_agent/explain.py +34 -0
- mobile_agent/failure_line.py +197 -0
- mobile_agent/fanout.py +663 -0
- mobile_agent/feedback/__init__.py +2 -0
- mobile_agent/feedback/bundle.py +204 -0
- mobile_agent/feedback/outbox.py +196 -0
- mobile_agent/feedback/register.py +9 -0
- mobile_agent/feedback/routes.py +177 -0
- mobile_agent/fleet.py +841 -0
- mobile_agent/fleet_health.py +729 -0
- mobile_agent/fleet_phone.py +186 -0
- mobile_agent/frontier/__init__.py +79 -0
- mobile_agent/frontier/act.py +380 -0
- mobile_agent/frontier/actions.py +70 -0
- mobile_agent/frontier/agent.py +256 -0
- mobile_agent/frontier/approvals.py +366 -0
- mobile_agent/frontier/hooks.py +199 -0
- mobile_agent/frontier/interrupts.py +125 -0
- mobile_agent/frontier/observe.py +331 -0
- mobile_agent/frontier/phases.py +381 -0
- mobile_agent/frontier/pipeline.py +61 -0
- mobile_agent/frontier/prompt.py +384 -0
- mobile_agent/frontier/protocol.py +91 -0
- mobile_agent/frontier/safety.py +467 -0
- mobile_agent/frontier/saved.py +375 -0
- mobile_agent/frontier/screen.py +419 -0
- mobile_agent/frontier/skills.py +147 -0
- mobile_agent/frontier/stages/__init__.py +1 -0
- mobile_agent/frontier/stages/after.py +192 -0
- mobile_agent/frontier/stages/approve.py +138 -0
- mobile_agent/frontier/stages/controls.py +115 -0
- mobile_agent/frontier/stages/ends.py +102 -0
- mobile_agent/frontier/stages/gate.py +92 -0
- mobile_agent/frontier/stages/guards.py +132 -0
- mobile_agent/frontier/stages/points.py +155 -0
- mobile_agent/frontier/stages/send.py +177 -0
- mobile_agent/frontier/turn.py +151 -0
- mobile_agent/full_disk_access.py +83 -0
- mobile_agent/gemini.py +69 -0
- mobile_agent/genuine.py +120 -0
- mobile_agent/graph/__init__.py +4 -0
- mobile_agent/graph/catalog.py +789 -0
- mobile_agent/graph/chain.py +306 -0
- mobile_agent/graph/dispatch.py +98 -0
- mobile_agent/graph/draft.py +373 -0
- mobile_agent/graph/edit.py +76 -0
- mobile_agent/graph/estimate.py +189 -0
- mobile_agent/graph/expr.py +562 -0
- mobile_agent/graph/functions.py +271 -0
- mobile_agent/graph/infer.py +144 -0
- mobile_agent/graph/nodes.py +936 -0
- mobile_agent/graph/phone_steps.py +178 -0
- mobile_agent/graph/rerun.py +327 -0
- mobile_agent/graph/run.py +899 -0
- mobile_agent/graph/schema.py +842 -0
- mobile_agent/graph/state.py +161 -0
- mobile_agent/graph/values.py +198 -0
- mobile_agent/harness/__init__.py +2 -0
- mobile_agent/harness/checkpoints.py +468 -0
- mobile_agent/harness/control.py +101 -0
- mobile_agent/harness/prewarm.py +213 -0
- mobile_agent/harness/register.py +43 -0
- mobile_agent/harness/routes.py +114 -0
- mobile_agent/harness/settings.py +66 -0
- mobile_agent/harness/tools/__init__.py +67 -0
- mobile_agent/harness/tools/ask_user.py +102 -0
- mobile_agent/harness/tools/watch.py +120 -0
- mobile_agent/harness_api.py +815 -0
- mobile_agent/hedge.py +203 -0
- mobile_agent/help_topics.py +305 -0
- mobile_agent/hints.py +248 -0
- mobile_agent/history.py +69 -0
- mobile_agent/home.py +214 -0
- mobile_agent/http_pool.py +175 -0
- mobile_agent/image_policy.py +139 -0
- mobile_agent/inbox.py +148 -0
- mobile_agent/installed_apps.py +344 -0
- mobile_agent/integrations/__init__.py +1 -0
- mobile_agent/integrations/cli.py +472 -0
- mobile_agent/integrations/client_files.py +65 -0
- mobile_agent/integrations/clients.py +998 -0
- mobile_agent/integrations/doctor.py +185 -0
- mobile_agent/integrations/jsonc.py +383 -0
- mobile_agent/integrations/routes.py +135 -0
- mobile_agent/integrations/rules.py +112 -0
- mobile_agent/integrations/server_options.py +46 -0
- mobile_agent/integrations/skill.py +152 -0
- mobile_agent/integrations/skill_files.py +10 -0
- mobile_agent/integrations/textedit.py +309 -0
- mobile_agent/intents/__init__.py +17 -0
- mobile_agent/intents/atlas.py +216 -0
- mobile_agent/intents/callback.py +44 -0
- mobile_agent/intents/cli.py +55 -0
- mobile_agent/intents/harvest.py +91 -0
- mobile_agent/intents/plan.py +93 -0
- mobile_agent/ios/__init__.py +1 -0
- mobile_agent/ios/guide.py +16 -0
- mobile_agent/ios/kb.py +212 -0
- mobile_agent/ios/kb_data.py +1588 -0
- mobile_agent/ios/mcp.py +44 -0
- mobile_agent/ios/register.py +8 -0
- mobile_agent/ios/system.py +369 -0
- mobile_agent/journal.py +531 -0
- mobile_agent/keychain_acl.py +374 -0
- mobile_agent/keys.py +395 -0
- mobile_agent/latency_overlap.py +36 -0
- mobile_agent/latency_trace.py +170 -0
- mobile_agent/learned/__init__.py +15 -0
- mobile_agent/learned/app_path.py +160 -0
- mobile_agent/learned/code_read.py +38 -0
- mobile_agent/learned/parts.py +546 -0
- mobile_agent/learned/programs.py +691 -0
- mobile_agent/learned/register.py +171 -0
- mobile_agent/learned/routes.py +61 -0
- mobile_agent/learned/taught.py +329 -0
- mobile_agent/learned/teach.py +538 -0
- mobile_agent/learned/workflows.py +647 -0
- mobile_agent/learned/zero.py +41 -0
- mobile_agent/links.py +37 -0
- mobile_agent/listen.py +607 -0
- mobile_agent/lock_spike.py +278 -0
- mobile_agent/lockscreen.py +526 -0
- mobile_agent/login.py +354 -0
- mobile_agent/longform/__init__.py +15 -0
- mobile_agent/longform/ads.py +191 -0
- mobile_agent/longform/blocks.py +117 -0
- mobile_agent/longform/budget.py +265 -0
- mobile_agent/longform/context.py +285 -0
- mobile_agent/longform/costs.py +93 -0
- mobile_agent/longform/digest.py +145 -0
- mobile_agent/longform/extract.py +508 -0
- mobile_agent/longform/feed.py +505 -0
- mobile_agent/longform/followup.py +168 -0
- mobile_agent/longform/frames.py +82 -0
- mobile_agent/longform/identity.py +87 -0
- mobile_agent/longform/inbox.py +221 -0
- mobile_agent/longform/items.py +425 -0
- mobile_agent/longform/models.py +115 -0
- mobile_agent/longform/notebook.py +329 -0
- mobile_agent/longform/plan.py +614 -0
- mobile_agent/longform/recall.py +195 -0
- mobile_agent/longform/receipts.py +81 -0
- mobile_agent/longform/register.py +124 -0
- mobile_agent/longform/report.py +343 -0
- mobile_agent/longform/routes.py +102 -0
- mobile_agent/longform/session.py +644 -0
- mobile_agent/longform/store.py +146 -0
- mobile_agent/longform/synthesis.py +347 -0
- mobile_agent/mac_tools.py +223 -0
- mobile_agent/manual_control.py +143 -0
- mobile_agent/maps.py +149 -0
- mobile_agent/marks.py +92 -0
- mobile_agent/mcp_server/__init__.py +3 -0
- mobile_agent/mcp_server/__main__.py +29 -0
- mobile_agent/mcp_server/action_journal.py +24 -0
- mobile_agent/mcp_server/action_tools.py +638 -0
- mobile_agent/mcp_server/approvals.py +219 -0
- mobile_agent/mcp_server/catalog.py +92 -0
- mobile_agent/mcp_server/cli.py +194 -0
- mobile_agent/mcp_server/codes.py +147 -0
- mobile_agent/mcp_server/device_tools.py +306 -0
- mobile_agent/mcp_server/direct.py +272 -0
- mobile_agent/mcp_server/expo_tools.py +105 -0
- mobile_agent/mcp_server/phone_tools.py +510 -0
- mobile_agent/mcp_server/protocol.py +364 -0
- mobile_agent/mcp_server/registry.py +55 -0
- mobile_agent/mcp_server/run_tools.py +531 -0
- mobile_agent/mcp_server/schemas.py +425 -0
- mobile_agent/mcp_server/screen_tools.py +168 -0
- mobile_agent/mcp_server/session.py +469 -0
- mobile_agent/mcp_server/support.py +145 -0
- mobile_agent/mcp_server/tools.py +493 -0
- mobile_agent/memory/__init__.py +9 -0
- mobile_agent/memory/cli.py +207 -0
- mobile_agent/memory/extract.py +191 -0
- mobile_agent/memory/listener.py +100 -0
- mobile_agent/memory/model.py +144 -0
- mobile_agent/memory/project.py +96 -0
- mobile_agent/memory/provider.py +127 -0
- mobile_agent/memory/register.py +21 -0
- mobile_agent/memory/retrieval.py +172 -0
- mobile_agent/memory/routes.py +216 -0
- mobile_agent/memory/runs.py +124 -0
- mobile_agent/memory/store.py +806 -0
- mobile_agent/memory/writer.py +278 -0
- mobile_agent/mirroring.py +585 -0
- mobile_agent/mirroring_runtime.py +221 -0
- mobile_agent/model_clients.py +604 -0
- mobile_agent/narrate.py +813 -0
- mobile_agent/native_helpers.py +84 -0
- mobile_agent/netxray/__init__.py +1 -0
- mobile_agent/netxray/analyze.py +322 -0
- mobile_agent/netxray/attribution.py +62 -0
- mobile_agent/netxray/capture.py +88 -0
- mobile_agent/netxray/cli.py +142 -0
- mobile_agent/netxray/job.py +219 -0
- mobile_agent/netxray/mobster-pcap.c +560 -0
- mobile_agent/netxray/packets.py +410 -0
- mobile_agent/netxray/pcapng.py +223 -0
- mobile_agent/netxray/register.py +93 -0
- mobile_agent/netxray/report.py +214 -0
- mobile_agent/netxray/store.py +48 -0
- mobile_agent/netxray/summary.py +157 -0
- mobile_agent/netxray/target.py +67 -0
- mobile_agent/netxray/tool.py +66 -0
- mobile_agent/netxray/trackers.py +194 -0
- mobile_agent/notify_triggers/__init__.py +1 -0
- mobile_agent/notify_triggers/rules.py +39 -0
- mobile_agent/notify_triggers/sources.py +315 -0
- mobile_agent/notify_triggers/watch.py +135 -0
- mobile_agent/offers.py +86 -0
- mobile_agent/orchestrate.py +215 -0
- mobile_agent/outlets/__init__.py +1 -0
- mobile_agent/outlets/connect.py +249 -0
- mobile_agent/outlets/folder.py +180 -0
- mobile_agent/outlets/hooks.py +453 -0
- mobile_agent/outlets/inbound.py +472 -0
- mobile_agent/outlets/inbound_reply.py +57 -0
- mobile_agent/outlets/inbound_routes.py +81 -0
- mobile_agent/outlets/kinds.py +308 -0
- mobile_agent/outlets/mail.py +110 -0
- mobile_agent/outlets/net.py +86 -0
- mobile_agent/outlets/notion.py +244 -0
- mobile_agent/outlets/register.py +56 -0
- mobile_agent/outlets/routes.py +135 -0
- mobile_agent/outlets/service.py +580 -0
- mobile_agent/outlets/sheets.py +169 -0
- mobile_agent/outlets/slack.py +177 -0
- mobile_agent/outlets/store.py +194 -0
- mobile_agent/outlets/vault.py +80 -0
- mobile_agent/outlets/webhook.py +316 -0
- mobile_agent/output_contract.py +141 -0
- mobile_agent/owner_presence.py +211 -0
- mobile_agent/ownership.py +219 -0
- mobile_agent/packs/__init__.py +1 -0
- mobile_agent/packs/calendar.py +243 -0
- mobile_agent/packs/context.py +68 -0
- mobile_agent/packs/core.py +757 -0
- mobile_agent/packs/front_door.py +316 -0
- mobile_agent/packs/heal.py +214 -0
- mobile_agent/packs/health.py +122 -0
- mobile_agent/packs/insights.py +76 -0
- mobile_agent/packs/instagram.py +640 -0
- mobile_agent/packs/linkedin.py +106 -0
- mobile_agent/packs/maps.py +196 -0
- mobile_agent/packs/mcp.py +149 -0
- mobile_agent/packs/messages.py +270 -0
- mobile_agent/packs/notes.py +226 -0
- mobile_agent/packs/photos.py +166 -0
- mobile_agent/packs/pins.py +47 -0
- mobile_agent/packs/reddit.py +110 -0
- mobile_agent/packs/register.py +276 -0
- mobile_agent/packs/rows.py +108 -0
- mobile_agent/packs/safari.py +143 -0
- mobile_agent/packs/settings.py +253 -0
- mobile_agent/packs/social.py +300 -0
- mobile_agent/packs/threads.py +97 -0
- mobile_agent/packs/tiktok.py +455 -0
- mobile_agent/packs/tools.py +290 -0
- mobile_agent/packs/x.py +127 -0
- mobile_agent/packs/youtube.py +199 -0
- mobile_agent/parity.py +270 -0
- mobile_agent/paths.py +99 -0
- mobile_agent/perception.py +470 -0
- mobile_agent/permissions/__init__.py +32 -0
- mobile_agent/permissions/consequence.py +535 -0
- mobile_agent/permissions/exceptions.py +99 -0
- mobile_agent/permissions/gate.py +316 -0
- mobile_agent/permissions/outside.py +316 -0
- mobile_agent/permissions/policy.py +288 -0
- mobile_agent/permissions/project_policy.py +411 -0
- mobile_agent/permissions/register.py +148 -0
- mobile_agent/permissions/rules.py +291 -0
- mobile_agent/phone_io/__init__.py +146 -0
- mobile_agent/phone_io/cli.py +427 -0
- mobile_agent/phone_io/clipboard.py +78 -0
- mobile_agent/phone_io/controls.py +78 -0
- mobile_agent/phone_io/files.py +289 -0
- mobile_agent/phone_io/handoff.py +123 -0
- mobile_agent/phone_io/icloud.py +118 -0
- mobile_agent/phone_io/install.py +188 -0
- mobile_agent/phone_io/network_capture.py +188 -0
- mobile_agent/phone_io/network_cli.py +138 -0
- mobile_agent/phone_io/network_profiles.py +138 -0
- mobile_agent/phone_io/photos.py +512 -0
- mobile_agent/phone_io/recording.py +115 -0
- mobile_agent/phone_io/secrets.py +146 -0
- mobile_agent/phone_io/transfer.py +895 -0
- mobile_agent/phone_io/tunnel.py +129 -0
- mobile_agent/phone_ready.py +91 -0
- mobile_agent/pinning.py +158 -0
- mobile_agent/plan_gate.py +807 -0
- mobile_agent/plugins/__init__.py +3 -0
- mobile_agent/plugins/install.py +163 -0
- mobile_agent/plugins/manifest.py +242 -0
- mobile_agent/plugins/register.py +15 -0
- mobile_agent/plugins/routes.py +82 -0
- mobile_agent/plugins/service.py +322 -0
- mobile_agent/plugins/skills.py +76 -0
- mobile_agent/plugins/store.py +79 -0
- mobile_agent/pool.py +285 -0
- mobile_agent/power_watch.py +275 -0
- mobile_agent/premium_model.py +22 -0
- mobile_agent/project_env.py +291 -0
- mobile_agent/project_env_cli.py +74 -0
- mobile_agent/project_env_routes.py +55 -0
- mobile_agent/projects/__init__.py +27 -0
- mobile_agent/projects/ci.py +332 -0
- mobile_agent/projects/clone.py +339 -0
- mobile_agent/projects/drafts.py +158 -0
- mobile_agent/projects/folder.py +436 -0
- mobile_agent/projects/github.py +131 -0
- mobile_agent/projects/register.py +31 -0
- mobile_agent/projects/revisions.py +253 -0
- mobile_agent/projects/routes.py +234 -0
- mobile_agent/projects/runs.py +297 -0
- mobile_agent/projects/service.py +695 -0
- mobile_agent/projects/setup.py +84 -0
- mobile_agent/projects/store.py +379 -0
- mobile_agent/projects/sync.py +380 -0
- mobile_agent/projects/workflow_files.py +270 -0
- mobile_agent/replay_core/__init__.py +26 -0
- mobile_agent/replay_core/checks.py +594 -0
- mobile_agent/replay_core/engine.py +596 -0
- mobile_agent/replay_core/heal.py +364 -0
- mobile_agent/replay_core/path.py +240 -0
- mobile_agent/replay_core/recorder.py +206 -0
- mobile_agent/replay_core/store.py +171 -0
- mobile_agent/result_blocks.py +941 -0
- mobile_agent/run.py +642 -0
- mobile_agent/run_delivery.py +123 -0
- mobile_agent/run_executor.py +200 -0
- mobile_agent/run_export.py +284 -0
- mobile_agent/run_gif.py +891 -0
- mobile_agent/runner_hints.py +86 -0
- mobile_agent/runner_renewal.py +205 -0
- mobile_agent/runtime_create.py +549 -0
- mobile_agent/runtime_driver.py +458 -0
- mobile_agent/runtime_limits.py +319 -0
- mobile_agent/runtime_queue.py +394 -0
- mobile_agent/runtime_runs.py +383 -0
- mobile_agent/runtime_status.py +181 -0
- mobile_agent/runtime_teach.py +295 -0
- mobile_agent/runtime_work.py +599 -0
- mobile_agent/screen_text.py +101 -0
- mobile_agent/secret_filter.py +113 -0
- mobile_agent/serve.py +160 -0
- mobile_agent/server.py +363 -0
- mobile_agent/service_support.py +42 -0
- mobile_agent/settle/__init__.py +1 -0
- mobile_agent/settle/predict.py +102 -0
- mobile_agent/settle/replay.py +92 -0
- mobile_agent/settle/store.py +93 -0
- mobile_agent/settle/stream.py +43 -0
- mobile_agent/setup_cli.py +225 -0
- mobile_agent/setup_errors.py +87 -0
- mobile_agent/setup_service.py +527 -0
- mobile_agent/share.py +104 -0
- mobile_agent/signing.py +184 -0
- mobile_agent/sim/__init__.py +6 -0
- mobile_agent/sim/__main__.py +26 -0
- mobile_agent/sim/api.py +952 -0
- mobile_agent/sim/apps.py +172 -0
- mobile_agent/sim/biometrics.py +84 -0
- mobile_agent/sim/cli.py +271 -0
- mobile_agent/sim/doctor.py +293 -0
- mobile_agent/sim/governor.py +90 -0
- mobile_agent/sim/holders.py +88 -0
- mobile_agent/sim/models.py +62 -0
- mobile_agent/sim/registry.py +161 -0
- mobile_agent/sim/simctl.py +269 -0
- mobile_agent/sim/wda.py +618 -0
- mobile_agent/sim/xcode.py +74 -0
- mobile_agent/skills/__init__.py +12 -0
- mobile_agent/skills/codes.py +597 -0
- mobile_agent/skills/notifications.py +281 -0
- mobile_agent/skills/open_url.py +127 -0
- mobile_agent/smart_events.py +588 -0
- mobile_agent/smart_result.py +490 -0
- mobile_agent/smart_run.py +413 -0
- mobile_agent/state.py +662 -0
- mobile_agent/states.py +217 -0
- mobile_agent/steering.py +70 -0
- mobile_agent/style.py +178 -0
- mobile_agent/supervised.py +234 -0
- mobile_agent/swift/helper_bt_hid.swift +534 -0
- mobile_agent/swift/helper_doc_text.swift +184 -0
- mobile_agent/swift/helper_draft_reply.swift +170 -0
- mobile_agent/swift/helper_map.swift +110 -0
- mobile_agent/swift/helper_mirroring.swift +1099 -0
- mobile_agent/swift/helper_mjpeg_mp4.swift +103 -0
- mobile_agent/swift/helper_pdf.swift +100 -0
- mobile_agent/swift/helper_phone_audio.swift +993 -0
- mobile_agent/swift/helper_phone_screen.swift +416 -0
- mobile_agent/swift/helper_screen_text.swift +24 -0
- mobile_agent/swift/helper_transfer.swift +246 -0
- mobile_agent/swift/ocr.swift +22 -0
- mobile_agent/task_policy.py +337 -0
- mobile_agent/team_signing/__init__.py +34 -0
- mobile_agent/team_signing/apple_ca.py +37 -0
- mobile_agent/team_signing/bundle.py +136 -0
- mobile_agent/team_signing/cli.py +208 -0
- mobile_agent/team_signing/identity.py +248 -0
- mobile_agent/team_signing/ledger.py +105 -0
- mobile_agent/team_signing/prebuilt.py +61 -0
- mobile_agent/team_signing/resign.py +256 -0
- mobile_agent/team_signing/service.py +382 -0
- mobile_agent/team_signing/tooling.py +243 -0
- mobile_agent/team_signing/writes.py +268 -0
- mobile_agent/terminal_image.py +81 -0
- mobile_agent/testkit/__init__.py +1 -0
- mobile_agent/testkit/a11y_baseline.py +132 -0
- mobile_agent/testkit/api.py +403 -0
- mobile_agent/testkit/capsule.py +400 -0
- mobile_agent/testkit/capsule_cli.py +57 -0
- mobile_agent/testkit/cli.py +948 -0
- mobile_agent/testkit/cli_arguments.py +193 -0
- mobile_agent/testkit/comparison.py +288 -0
- mobile_agent/testkit/comparison_cli.py +41 -0
- mobile_agent/testkit/discover.py +257 -0
- mobile_agent/testkit/editor.py +243 -0
- mobile_agent/testkit/execute.py +515 -0
- mobile_agent/testkit/github_checks.py +248 -0
- mobile_agent/testkit/gitref.py +66 -0
- mobile_agent/testkit/heal_review.py +176 -0
- mobile_agent/testkit/health.py +480 -0
- mobile_agent/testkit/history.py +255 -0
- mobile_agent/testkit/hosted_results.py +321 -0
- mobile_agent/testkit/input_files.py +173 -0
- mobile_agent/testkit/jobs.py +285 -0
- mobile_agent/testkit/junit_verdict.py +33 -0
- mobile_agent/testkit/maestro.py +802 -0
- mobile_agent/testkit/mcp.py +562 -0
- mobile_agent/testkit/mcp_comparison.py +88 -0
- mobile_agent/testkit/minimize.py +471 -0
- mobile_agent/testkit/minimizer_cli.py +48 -0
- mobile_agent/testkit/notify.py +212 -0
- mobile_agent/testkit/phone_video.py +191 -0
- mobile_agent/testkit/pr.py +491 -0
- mobile_agent/testkit/pr_report.py +562 -0
- mobile_agent/testkit/prbuild.py +148 -0
- mobile_agent/testkit/proof_report.py +55 -0
- mobile_agent/testkit/provenance.py +119 -0
- mobile_agent/testkit/prrun.py +244 -0
- mobile_agent/testkit/recent.py +166 -0
- mobile_agent/testkit/recipes.py +146 -0
- mobile_agent/testkit/record.py +204 -0
- mobile_agent/testkit/register.py +288 -0
- mobile_agent/testkit/reports.py +998 -0
- mobile_agent/testkit/repro.py +432 -0
- mobile_agent/testkit/scenario.py +440 -0
- mobile_agent/testkit/scenario_cli.py +37 -0
- mobile_agent/testkit/snapshots_cli.py +60 -0
- mobile_agent/testkit/suite.py +670 -0
- mobile_agent/testkit/targets.py +497 -0
- mobile_agent/testkit/xctest.py +378 -0
- mobile_agent/threads/__init__.py +1 -0
- mobile_agent/threads/chat_once.py +264 -0
- mobile_agent/threads/cli.py +740 -0
- mobile_agent/threads/client.py +326 -0
- mobile_agent/threads/context.py +246 -0
- mobile_agent/threads/mcp.py +739 -0
- mobile_agent/threads/recent.py +34 -0
- mobile_agent/threads/register.py +54 -0
- mobile_agent/threads/routes.py +188 -0
- mobile_agent/threads/service.py +975 -0
- mobile_agent/threads/store.py +361 -0
- mobile_agent/threads/summary.py +117 -0
- mobile_agent/threads/term.py +101 -0
- mobile_agent/threads/workflow_cli.py +104 -0
- mobile_agent/tls.py +36 -0
- mobile_agent/traces.py +271 -0
- mobile_agent/tracks.py +235 -0
- mobile_agent/transport.py +271 -0
- mobile_agent/triggers/__init__.py +1 -0
- mobile_agent/triggers/actions.py +204 -0
- mobile_agent/triggers/model.py +146 -0
- mobile_agent/triggers/register.py +29 -0
- mobile_agent/triggers/routes.py +101 -0
- mobile_agent/triggers/service.py +387 -0
- mobile_agent/triggers/store.py +142 -0
- mobile_agent/tui/__init__.py +89 -0
- mobile_agent/tui/app.py +582 -0
- mobile_agent/tui/chrome.py +321 -0
- mobile_agent/tui/commands.py +375 -0
- mobile_agent/tui/demo_phone.py +369 -0
- mobile_agent/tui/images.py +29 -0
- mobile_agent/tui/keys.py +64 -0
- mobile_agent/tui/prompt.py +195 -0
- mobile_agent/tui/render.py +395 -0
- mobile_agent/tui/screens.py +180 -0
- mobile_agent/tui/session.py +308 -0
- mobile_agent/tui/tasks.py +287 -0
- mobile_agent/tui/theme.py +118 -0
- mobile_agent/tui/widgets.py +272 -0
- mobile_agent/update_check.py +213 -0
- mobile_agent/usage.py +184 -0
- mobile_agent/verify/__init__.py +0 -0
- mobile_agent/verify/__main__.py +41 -0
- mobile_agent/verify/a11y.py +189 -0
- mobile_agent/verify/actions.py +290 -0
- mobile_agent/verify/assertions.py +570 -0
- mobile_agent/verify/audit.py +312 -0
- mobile_agent/verify/business.py +221 -0
- mobile_agent/verify/business_run.py +131 -0
- mobile_agent/verify/business_state.py +168 -0
- mobile_agent/verify/check_schema.py +398 -0
- mobile_agent/verify/checks.py +614 -0
- mobile_agent/verify/cli.py +423 -0
- mobile_agent/verify/conditions.py +483 -0
- mobile_agent/verify/crashes.py +321 -0
- mobile_agent/verify/device_audit.py +246 -0
- mobile_agent/verify/dialogs.py +287 -0
- mobile_agent/verify/evidence.py +705 -0
- mobile_agent/verify/fixtures.py +305 -0
- mobile_agent/verify/flow.py +213 -0
- mobile_agent/verify/l10n.py +141 -0
- mobile_agent/verify/netcerts.py +67 -0
- mobile_agent/verify/netproxy.py +356 -0
- mobile_agent/verify/netrun.py +81 -0
- mobile_agent/verify/netshim.m +28 -0
- mobile_agent/verify/netshim.py +48 -0
- mobile_agent/verify/netstubs.py +132 -0
- mobile_agent/verify/overlay.py +174 -0
- mobile_agent/verify/perf.py +239 -0
- mobile_agent/verify/proof.py +192 -0
- mobile_agent/verify/replay_admission.py +48 -0
- mobile_agent/verify/report.py +952 -0
- mobile_agent/verify/run_cleanup.py +67 -0
- mobile_agent/verify/run_links.py +48 -0
- mobile_agent/verify/run_perf.py +44 -0
- mobile_agent/verify/run_secrets.py +39 -0
- mobile_agent/verify/runner.py +976 -0
- mobile_agent/verify/runs.py +57 -0
- mobile_agent/verify/screen_coverage.py +181 -0
- mobile_agent/verify/smart.py +106 -0
- mobile_agent/verify/snapshot_run.py +114 -0
- mobile_agent/verify/snapshots.py +306 -0
- mobile_agent/verify/sound.py +335 -0
- mobile_agent/verify/source_observation.py +130 -0
- mobile_agent/verify/system_surfaces/__init__.py +7 -0
- mobile_agent/verify/system_surfaces/play.py +106 -0
- mobile_agent/verify/system_surfaces/spec.py +139 -0
- mobile_agent/verify/system_surfaces/stage.py +138 -0
- mobile_agent/verify/system_surfaces/surfaces.py +121 -0
- mobile_agent/verify/tree.py +223 -0
- mobile_agent/verify/verdict.py +377 -0
- mobile_agent/verify/wire.py +392 -0
- mobile_agent/verify/wirestore.py +176 -0
- mobile_agent/waiting/__init__.py +1 -0
- mobile_agent/waiting/contacts.py +73 -0
- mobile_agent/waiting/drafts.py +136 -0
- mobile_agent/waiting/job.py +145 -0
- mobile_agent/waiting/messages.py +174 -0
- mobile_agent/waiting/rank.py +115 -0
- mobile_agent/waiting/register.py +26 -0
- mobile_agent/watch.py +502 -0
- mobile_agent/wda/__init__.py +2 -0
- mobile_agent/wda/frame_clock.py +646 -0
- mobile_agent/wda/frame_source.py +325 -0
- mobile_agent/wda/frame_store.py +115 -0
- mobile_agent/wda/gate.py +163 -0
- mobile_agent/wda/gestures.py +291 -0
- mobile_agent/wda/guards.py +94 -0
- mobile_agent/wda/identity.py +55 -0
- mobile_agent/wda/observe.py +884 -0
- mobile_agent/wda/patches.py +856 -0
- mobile_agent/wda/point_gestures.py +150 -0
- mobile_agent/wda/source.py +32 -0
- mobile_agent/wda/system_dialogs.py +571 -0
- mobile_agent/wda/text.py +694 -0
- mobile_agent/wda/timeouts.py +77 -0
- mobile_agent/wda/video.py +505 -0
- mobile_agent/wda/watchdog.py +243 -0
- mobile_agent/wireless/__init__.py +15 -0
- mobile_agent/wireless/api.py +354 -0
- mobile_agent/wireless/cli.py +287 -0
- mobile_agent/wireless/devicectl.py +229 -0
- mobile_agent/wireless/exposure.py +33 -0
- mobile_agent/wireless/mobster-wifi-pair.c +122 -0
- mobile_agent/wireless/monitor.py +332 -0
- mobile_agent/wireless/network.py +334 -0
- mobile_agent/wireless/onboarding.py +293 -0
- mobile_agent/wireless/pair.py +124 -0
- mobile_agent/wireless/probe.py +322 -0
- mobile_agent/wireless/register.py +8 -0
- mobile_agent/wireless/relay.py +179 -0
- mobile_agent/wireless/result.py +87 -0
- mobile_agent/wireless/store.py +85 -0
- mobile_agent/wireless/switch.py +77 -0
- mobile_agent/wireless/transport.py +118 -0
- mobile_agent/wireless/words.py +81 -0
- mobile_agent/wireless/xctestrun.py +83 -0
- mobile_agent/workflow_tools.py +59 -0
- mobile_agent/workflows/__init__.py +2 -0
- mobile_agent/workflows/brief.py +201 -0
- mobile_agent/workflows/cron.py +110 -0
- mobile_agent/workflows/health.py +257 -0
- mobile_agent/workflows/history.py +126 -0
- mobile_agent/workflows/records.py +442 -0
- mobile_agent/workflows/runs.py +1000 -0
- mobile_agent/workflows/scheduler.py +134 -0
- mobile_agent/workflows/service.py +973 -0
- mobile_agent/workflows/starters.py +285 -0
- mobile_agent/workflows/triggers.py +248 -0
- mobile_agent/xray/__init__.py +1 -0
- mobile_agent/xray/app_cli.py +116 -0
- mobile_agent/xray/cli.py +21 -0
- mobile_agent/xray/crawl.py +754 -0
- mobile_agent/xray/diff.py +171 -0
- mobile_agent/xray/diff_cli.py +43 -0
- mobile_agent/xray/job.py +184 -0
- mobile_agent/xray/paywall.py +188 -0
- mobile_agent/xray/policy.py +305 -0
- mobile_agent/xray/register.py +43 -0
- mobile_agent/xray/report.py +283 -0
- mobile_agent/xray/screens.py +117 -0
- mobster_cli-0.4.3.dist-info/METADATA +93 -0
- mobster_cli-0.4.3.dist-info/RECORD +1009 -0
- mobster_cli-0.4.3.dist-info/WHEEL +5 -0
- mobster_cli-0.4.3.dist-info/entry_points.txt +3 -0
- mobster_cli-0.4.3.dist-info/licenses/LICENSE +21 -0
- mobster_cli-0.4.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
"""Conversations in one Runtime: the store, message routing, the run listener, the bus and the thread sink."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
from .. import harness_api, secret_filter
|
|
10
|
+
from ..api_errors import APIError, nothing_started
|
|
11
|
+
from ..service_support import ServiceRegistry
|
|
12
|
+
from . import store as thread_store
|
|
13
|
+
from .store import (
|
|
14
|
+
CORE_KINDS,
|
|
15
|
+
KIND,
|
|
16
|
+
MAX_RULES,
|
|
17
|
+
MAX_SINK_ITEM_BYTES,
|
|
18
|
+
RULES_CHARS,
|
|
19
|
+
ThreadFull,
|
|
20
|
+
ThreadMissing,
|
|
21
|
+
ThreadStore,
|
|
22
|
+
failed_request,
|
|
23
|
+
public_item,
|
|
24
|
+
request_result,
|
|
25
|
+
request_unknown,
|
|
26
|
+
)
|
|
27
|
+
from .summary import context_of, group_fields, referents_of, result_of
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("mobster.threads")
|
|
30
|
+
|
|
31
|
+
VIA = ("composer", "voice", "cli", "tui", "mcp", "remote")
|
|
32
|
+
# Where a request came from (X-Mobster-Origin) -> the item's "via" and the steering source (S8). "api" (no header,
|
|
33
|
+
# a script) steers as the terminal does: never as the Mac app.
|
|
34
|
+
ORIGIN_VIA = {"app": "composer", "voice": "voice", "tui": "tui", "cli": "cli", "mcp": "mcp", "api": "cli",
|
|
35
|
+
"remote": "remote"}
|
|
36
|
+
ORIGIN_SOURCE = {"app": "app", "tui": "tui", "cli": "cli", "mcp": "mcp", "api": "cli", "remote": "remote"}
|
|
37
|
+
START_CHARS = 4000
|
|
38
|
+
STEER_CHARS = 2000
|
|
39
|
+
ANSWER_CHARS = 500
|
|
40
|
+
UNREAD_NOTE = "Mobster finished before reading this."
|
|
41
|
+
STEER_UNSUPPORTED = "This task can't take messages while it works. Wait for it to finish, or stop it."
|
|
42
|
+
ARCHIVED = "This conversation is archived. Restore it to continue."
|
|
43
|
+
# run.APPROVAL_PENDING, the sentence Run.steer refuses with (a test keeps them equal).
|
|
44
|
+
APPROVAL_PENDING = "Choose Send or Don't send above, or say what to do instead there."
|
|
45
|
+
# run.Run.steer's sentence for a message from a surface the task doesn't hear (a test keeps them equal).
|
|
46
|
+
STEER_NOT_ALLOWED = "This task takes messages only where it was started."
|
|
47
|
+
STEER_FILES ="Mobster reads files only when a task starts. Send them after this task, or stop it first."
|
|
48
|
+
REMOTE_FILES = "Files can't be sent through Mobster Remote."
|
|
49
|
+
STEPS_RUNNING = ("This workflow is running several steps at once. Send your message when they finish.")
|
|
50
|
+
GROUP_RUNNING = ("This task is running on several devices. Send your message when they finish, or answer each device’s "
|
|
51
|
+
"question on its tab.")
|
|
52
|
+
FILES_MISSING = "Attaching files isn't in this build yet."
|
|
53
|
+
ACCOUNTS_MISSING = "Profiles aren't available right now. Restart Mobster, then try again."
|
|
54
|
+
OUTLETS_MISSING = "Sending results is part of Mobster for Mac. Open it to send a result to another tool."
|
|
55
|
+
|
|
56
|
+
# A rule in a message: a sentence with "don't", "never", "only look" or "without".
|
|
57
|
+
_PROHIBITION = re.compile(r"[^.!?]*\b(?:do not|don't|never|only look|without)\b[^.!?]*[.!?]", re.I)
|
|
58
|
+
_services = ServiceRegistry()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def service_for(runtime):
|
|
62
|
+
"""The live ThreadService of ``runtime``, or None (the track is off, or the runtime closed)."""
|
|
63
|
+
return _services.get(runtime)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def services():
|
|
67
|
+
return _services.all()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def start(runtime):
|
|
71
|
+
"""register_service's start: a ThreadService on the runtime's journal."""
|
|
72
|
+
service = ThreadService(runtime)
|
|
73
|
+
_services.add(runtime, service)
|
|
74
|
+
service.reconcile()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def close(runtime):
|
|
78
|
+
service = _services.pop(runtime)
|
|
79
|
+
if service is not None:
|
|
80
|
+
service.closed = True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def clean_text(text, limit, what="A message"):
|
|
84
|
+
if not isinstance(text, str):
|
|
85
|
+
raise ValueError(f"{what} is text")
|
|
86
|
+
text = text.strip()
|
|
87
|
+
if not text:
|
|
88
|
+
raise ValueError("Write a message first")
|
|
89
|
+
if len(text) > limit:
|
|
90
|
+
raise ValueError(f"{what} can be at most {limit:,} characters")
|
|
91
|
+
return text
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def may_message(run, source, remote_grant=None):
|
|
95
|
+
"""APIError 403 steer_not_allowed when ``source`` may not send ``run`` a message: Run.steer's rule (S5), applied
|
|
96
|
+
to answers too, which reach the run through Run.answer_approval instead."""
|
|
97
|
+
if source not in harness_api.steer_sources(getattr(run, "origin", None)):
|
|
98
|
+
raise APIError(STEER_NOT_ALLOWED, 403, "steer_not_allowed")
|
|
99
|
+
if source == "remote":
|
|
100
|
+
extras = run.extras if isinstance(getattr(run, "extras", None), dict) else {}
|
|
101
|
+
if not remote_grant or extras.get("remoteGrant") != remote_grant:
|
|
102
|
+
raise APIError(STEER_NOT_ALLOWED, 403, "steer_not_allowed")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def fingerprint(value):
|
|
106
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=True, allow_nan=False).encode()).hexdigest()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run_request_key(thread_id, key):
|
|
110
|
+
return f"thread-{thread_id}-{fingerprint(key)}" if key else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def prohibitions(text):
|
|
114
|
+
"""The rules in a message ("don't…", "never…", "only look…", "without…"), copied by code."""
|
|
115
|
+
text = " ".join(str(text or "").split())
|
|
116
|
+
if text and text[-1] not in ".!?":
|
|
117
|
+
text += "." # a chat message often ends without a full stop; the rule reads to the end of a sentence
|
|
118
|
+
return [m.group(0).strip() for m in _PROHIBITION.finditer(text) if m.group(0).strip()]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def merge_rules(rules, text):
|
|
122
|
+
"""``rules`` with the new ones in ``text`` added; the newest kept within MAX_RULES and RULES_CHARS."""
|
|
123
|
+
out = list(rules or [])
|
|
124
|
+
for rule in prohibitions(text):
|
|
125
|
+
rule = rule[:300]
|
|
126
|
+
if rule.casefold() in (r.casefold() for r in out):
|
|
127
|
+
continue
|
|
128
|
+
out.append(rule)
|
|
129
|
+
while out and (len(out) > MAX_RULES or sum(len(r) + 3 for r in out) > RULES_CHARS):
|
|
130
|
+
out.pop(0)
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ThreadService:
|
|
135
|
+
def __init__(self, runtime):
|
|
136
|
+
self.runtime = runtime
|
|
137
|
+
self.store = ThreadStore(runtime.journal)
|
|
138
|
+
self.closed = False
|
|
139
|
+
# Serializes routing per process: a message's state check and its effect are one step.
|
|
140
|
+
self.lock = threading.RLock()
|
|
141
|
+
self.runs = {} # run id -> thread id, for the runs this process started in a thread
|
|
142
|
+
self.last_app = {} # run id -> the last app name its events named
|
|
143
|
+
|
|
144
|
+
def publish(self, topic, event):
|
|
145
|
+
publish = getattr(self.runtime, "publish", None)
|
|
146
|
+
if callable(publish):
|
|
147
|
+
publish(topic, event)
|
|
148
|
+
|
|
149
|
+
def published_thread(self, thread, event):
|
|
150
|
+
self.publish("threads", {"event": event, "thread": thread})
|
|
151
|
+
|
|
152
|
+
def published_item(self, thread_id, item, event="thread_item"):
|
|
153
|
+
self.publish(f"thread:{thread_id}", {"event": event, "item": public_item(item)})
|
|
154
|
+
|
|
155
|
+
def thread(self, thread_id):
|
|
156
|
+
thread = self.store.get(thread_id)
|
|
157
|
+
if thread is None:
|
|
158
|
+
raise ThreadMissing()
|
|
159
|
+
return thread
|
|
160
|
+
|
|
161
|
+
def create_thread(self, title=None, device=None):
|
|
162
|
+
device_id = device_name = None
|
|
163
|
+
if device is not None:
|
|
164
|
+
phone = self.resolve(device)
|
|
165
|
+
device_id, device_name = getattr(phone, "id", None), getattr(phone, "name", None)
|
|
166
|
+
thread = self.store.create_thread(title=title, device=device_id, device_name=device_name)
|
|
167
|
+
self.published_thread(thread, "thread_created")
|
|
168
|
+
return thread
|
|
169
|
+
|
|
170
|
+
def resolve(self, device):
|
|
171
|
+
if not isinstance(device, str) or not 1 <= len(device) <= 128:
|
|
172
|
+
raise ValueError("device must be a device's id, UDID or name")
|
|
173
|
+
resolve = getattr(self.runtime, "resolve_device", None)
|
|
174
|
+
if not callable(resolve) or getattr(self.runtime, "fleet", None) is None:
|
|
175
|
+
raise ValueError("This Mobster runs every task on its own device; leave device out")
|
|
176
|
+
return resolve(device)
|
|
177
|
+
|
|
178
|
+
def edit_thread(self, thread_id, changes):
|
|
179
|
+
self.thread(thread_id)
|
|
180
|
+
clean = {}
|
|
181
|
+
if "title" in changes:
|
|
182
|
+
title = changes["title"]
|
|
183
|
+
if not isinstance(title, str) or not 1 <= len(" ".join(title.split())) <= thread_store.TITLE_CHARS:
|
|
184
|
+
raise ValueError("A title is 1 to 80 characters")
|
|
185
|
+
clean["title"] = " ".join(title.split())
|
|
186
|
+
for flag in ("archived", "pinned"):
|
|
187
|
+
if flag in changes:
|
|
188
|
+
if type(changes[flag]) is not bool:
|
|
189
|
+
raise ValueError(f"{flag} is true or false")
|
|
190
|
+
clean[flag] = changes[flag]
|
|
191
|
+
thread = self.store.update_thread(thread_id, clean)
|
|
192
|
+
self.published_thread(thread, "thread_updated")
|
|
193
|
+
return thread
|
|
194
|
+
|
|
195
|
+
def active_run(self, thread):
|
|
196
|
+
"""The thread's run that is still going, or None."""
|
|
197
|
+
runtime = self.runtime
|
|
198
|
+
with runtime.lock:
|
|
199
|
+
candidates = [runtime.runs.get(thread.get("lastRunId"))] + [
|
|
200
|
+
run for run in runtime.runs.values()
|
|
201
|
+
if isinstance(run.extras, dict) and run.extras.get("threadId") == thread["id"]]
|
|
202
|
+
for run in candidates:
|
|
203
|
+
if run is not None and run.finished_at is None:
|
|
204
|
+
return run
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def delete_thread(self, thread_id, runs="delete"):
|
|
208
|
+
if runs not in ("delete", "keep"):
|
|
209
|
+
raise ValueError("runs is delete or keep")
|
|
210
|
+
with self.lock:
|
|
211
|
+
thread = self.thread(thread_id)
|
|
212
|
+
if self.active_run(thread) is not None:
|
|
213
|
+
raise APIError("Stop the task in this conversation before deleting it.", 409, "run_active")
|
|
214
|
+
run_ids = self.store.run_ids(thread_id)
|
|
215
|
+
deleted_runs = 0
|
|
216
|
+
if runs == "delete":
|
|
217
|
+
for run_id in run_ids:
|
|
218
|
+
if run_id in self.runtime.runs:
|
|
219
|
+
try:
|
|
220
|
+
self.runtime.delete_run(run_id)
|
|
221
|
+
deleted_runs += 1
|
|
222
|
+
except APIError:
|
|
223
|
+
pass # it started meanwhile: kept, and the thread still goes
|
|
224
|
+
self.store.delete_thread(thread_id)
|
|
225
|
+
self.published_thread({"id": thread_id}, "thread_deleted")
|
|
226
|
+
return {"deleted": True, "runsDeleted": deleted_runs}
|
|
227
|
+
|
|
228
|
+
def message(self, thread_id, body, *, origin="api", key=None, remote_grant=None):
|
|
229
|
+
"""Route one message (SPEC §3.2 feature 2)."""
|
|
230
|
+
if origin == "remote" and not remote_grant:
|
|
231
|
+
raise ValueError("A message through Mobster Remote names its grant")
|
|
232
|
+
fields = {"text", "attachmentIds", "appId", "engine", "outputFormat", "outputSchema", "device", "phones",
|
|
233
|
+
"profiles", "mode",
|
|
234
|
+
"via", "account", "longform", "pinnedApps", "sendTo",
|
|
235
|
+
"permissionMode", "queue"}
|
|
236
|
+
if not isinstance(body, dict) or set(body) - fields:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
"A message has text, and optionally attachmentIds, appId, pinnedApps, engine, outputFormat, "
|
|
239
|
+
"outputSchema, device or phones, account, mode (auto or new_task), via, longform, sendTo "
|
|
240
|
+
"and permissionMode")
|
|
241
|
+
if body.get("device") is not None and body.get("phones") is not None:
|
|
242
|
+
raise ValueError("Name the phones in phones, or one phone in device, not both")
|
|
243
|
+
if body.get("account") is not None and body.get("phones") is not None:
|
|
244
|
+
raise ValueError("A profile runs on its own iPhone: leave phones out")
|
|
245
|
+
if body.get("profiles") is not None and any(body.get(name) is not None for name in ("phones", "device",
|
|
246
|
+
"account")):
|
|
247
|
+
raise ValueError("Run on several profiles with profiles alone: each runs on its own iPhone")
|
|
248
|
+
mode = body.get("mode", "auto")
|
|
249
|
+
if mode not in ("auto", "new_task"):
|
|
250
|
+
raise ValueError("mode is auto or new_task")
|
|
251
|
+
via = self.via(body.get("via"), origin)
|
|
252
|
+
attachments = body.get("attachmentIds")
|
|
253
|
+
if attachments is not None and (not isinstance(attachments, list) or
|
|
254
|
+
not all(isinstance(a, str) for a in attachments)):
|
|
255
|
+
raise ValueError("attachmentIds is a list of attachment ids")
|
|
256
|
+
with self.lock:
|
|
257
|
+
if key is not None:
|
|
258
|
+
saved = self.store.request(key)
|
|
259
|
+
if saved is not None:
|
|
260
|
+
if saved[0] != thread_id or saved[1] != fingerprint(body):
|
|
261
|
+
raise APIError("This request key belongs to a different message", 409,
|
|
262
|
+
"idempotency_conflict")
|
|
263
|
+
response = request_result(saved[2])
|
|
264
|
+
return response["status"], {**response["response"], "replayed": True}
|
|
265
|
+
thread = self.thread(thread_id)
|
|
266
|
+
if thread.get("archived"):
|
|
267
|
+
raise APIError(ARCHIVED, 409, "thread_archived")
|
|
268
|
+
if key is not None:
|
|
269
|
+
self.store.remember(key, thread_id, fingerprint(body), None)
|
|
270
|
+
try:
|
|
271
|
+
run = self.active_run(thread) if mode == "auto" else None
|
|
272
|
+
routed = self.to_run(thread, run, body, via, origin, attachments, remote_grant) if run else None
|
|
273
|
+
if routed is None:
|
|
274
|
+
routed = self.start(thread, body, via, origin, attachments, key, remote_grant)
|
|
275
|
+
except (APIError, ValueError) as error:
|
|
276
|
+
if key is not None and not (isinstance(error, APIError) and error.code == "request_outcome_unknown"):
|
|
277
|
+
self.store.remember(key, thread_id, fingerprint(body), failed_request(error))
|
|
278
|
+
raise
|
|
279
|
+
if key is not None:
|
|
280
|
+
self.store.remember(key, thread_id, fingerprint(body), {"status": routed[0], "response": routed[1]})
|
|
281
|
+
return routed
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def via(value, origin):
|
|
285
|
+
"""The item's "via": the request's surface (its X-Mobster-Origin)."""
|
|
286
|
+
default = ORIGIN_VIA.get(origin, "cli")
|
|
287
|
+
if value is None:
|
|
288
|
+
return default
|
|
289
|
+
if value not in VIA:
|
|
290
|
+
raise ValueError("via is composer, voice, cli, tui, mcp or remote")
|
|
291
|
+
return value if origin == "app" and value in ("composer", "voice") else default
|
|
292
|
+
|
|
293
|
+
def to_run(self, thread, run, body, via, origin, attachments, remote_grant=None):
|
|
294
|
+
"""Route a message to the thread's running task. None when it finished meanwhile (start a new one)."""
|
|
295
|
+
if getattr(run, "group_id", None):
|
|
296
|
+
# A task on several phones takes no messages while it runs: each phone's run is its own, and one message
|
|
297
|
+
# can't steer them all the same way. Questions are answered on their phone's tab.
|
|
298
|
+
raise APIError(GROUP_RUNNING, 409, "group_running", groupId=run.group_id)
|
|
299
|
+
with self.runtime.lock:
|
|
300
|
+
going = sum(1 for other in self.runtime.runs.values() if isinstance(other.extras, dict)
|
|
301
|
+
and other.extras.get("threadId") == thread["id"] and other.finished_at is None)
|
|
302
|
+
if going > 1:
|
|
303
|
+
# A workflow running steps side by side: one message can't steer them all the same way.
|
|
304
|
+
raise APIError(STEPS_RUNNING, 409, "steps_running")
|
|
305
|
+
pending = run.approval
|
|
306
|
+
if origin == "remote":
|
|
307
|
+
# Through Mobster Remote only the app that started the task reaches it, and never with files.
|
|
308
|
+
may_message(run, "remote", remote_grant)
|
|
309
|
+
if attachments:
|
|
310
|
+
raise APIError(REMOTE_FILES, 409, "steer_attachments")
|
|
311
|
+
if pending is not None and pending.get("kind") == "clarify":
|
|
312
|
+
# An answer is a message to the task, so it follows Run.steer's rule for who may send one: a task
|
|
313
|
+
# started over MCP hears only MCP and the Mac app, and any other task never hears MCP.
|
|
314
|
+
may_message(run, self.source(body, origin), remote_grant)
|
|
315
|
+
text = clean_text(body["text"] if "text" in body else None, ANSWER_CHARS, "An answer")
|
|
316
|
+
# The question is in the thread before its answer, even when the listener hasn't caught up yet.
|
|
317
|
+
self.clarify_item(thread["id"], run, {"approval_id": pending["id"], "label": pending.get("label")},
|
|
318
|
+
pending=pending)
|
|
319
|
+
if not run.answer_approval(pending["id"], True, answer=text):
|
|
320
|
+
if run.finished_at is not None:
|
|
321
|
+
return None
|
|
322
|
+
raise APIError("Mobster's question is no longer waiting.", 409, "approval_not_pending")
|
|
323
|
+
try:
|
|
324
|
+
item = self.add_user(thread["id"], text, via, "answer", run.id, [], approvalId=pending["id"])
|
|
325
|
+
self.answered(thread["id"], run.id, pending["id"], text)
|
|
326
|
+
except Exception:
|
|
327
|
+
raise request_unknown() from None
|
|
328
|
+
return 202, {"item": public_item(item), "routed": "answer"}
|
|
329
|
+
if pending is not None:
|
|
330
|
+
# An approval waits: refused as Run.steer refuses it on every surface, and nothing is queued.
|
|
331
|
+
raise APIError(APPROVAL_PENDING, 409, "approval_pending", approvalId=pending.get("id"))
|
|
332
|
+
if not run.steerable:
|
|
333
|
+
from .. import ai_plans
|
|
334
|
+
if ai_plans.is_agent(run.engine):
|
|
335
|
+
from ..ai_plans import agents
|
|
336
|
+
raise APIError(f"{agents.get(run.engine).name} can't take messages while it works. Wait for it to "
|
|
337
|
+
"finish, or stop it.", 409, "steer_unsupported")
|
|
338
|
+
raise APIError(STEER_UNSUPPORTED, 409, "steer_unsupported")
|
|
339
|
+
if attachments:
|
|
340
|
+
raise APIError(STEER_FILES, 409, "steer_attachments")
|
|
341
|
+
text = clean_text(body.get("text"), STEER_CHARS)
|
|
342
|
+
try:
|
|
343
|
+
message = run.steer(text, source=self.source(body, origin))
|
|
344
|
+
except APIError as error:
|
|
345
|
+
if error.code == "run_not_active":
|
|
346
|
+
return None
|
|
347
|
+
raise
|
|
348
|
+
try:
|
|
349
|
+
item = self.add_steer(thread["id"], run.id, message["id"], message["text"], via)
|
|
350
|
+
except Exception:
|
|
351
|
+
raise request_unknown() from None
|
|
352
|
+
return 202, {"item": public_item(item), "routed": "steer"}
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def source(body, origin):
|
|
356
|
+
source = ORIGIN_SOURCE.get(origin, "cli")
|
|
357
|
+
return "voice" if source == "app" and body.get("via") == "voice" else source
|
|
358
|
+
|
|
359
|
+
def start(self, thread, body, via, origin, attachments, key, remote_grant=None):
|
|
360
|
+
"""A new task in the thread."""
|
|
361
|
+
if origin == "remote" and attachments:
|
|
362
|
+
raise APIError(REMOTE_FILES, 409, "steer_attachments")
|
|
363
|
+
text = clean_text(body.get("text"), START_CHARS)
|
|
364
|
+
count, _size = self.store.usage(thread["id"])
|
|
365
|
+
if count + 2 + thread_store.RESERVE > thread_store.MAX_ITEMS:
|
|
366
|
+
raise ThreadFull()
|
|
367
|
+
if not attachments and origin in harness_api.INTERACTIVE and body.get("mode", "auto") == "auto":
|
|
368
|
+
offered = self.offer_to_remember(thread, text, via, body)
|
|
369
|
+
if offered is not None:
|
|
370
|
+
return offered
|
|
371
|
+
extras = {"threadId": thread["id"], "via": via}
|
|
372
|
+
if origin == "remote":
|
|
373
|
+
extras["remoteGrant"] = remote_grant
|
|
374
|
+
if origin in harness_api.INTERACTIVE and "askUser" in harness_api.run_fields():
|
|
375
|
+
# Every surface that shows a conversation shows its questions (a clarify item the Mac app, `mobster
|
|
376
|
+
# chat` and the terminal UI answer inline), so a task a person started here may ask one (harness's
|
|
377
|
+
# ASK_USER). MCP and the plain API never: nobody there is watching.
|
|
378
|
+
extras["askUser"] = True
|
|
379
|
+
if body.get("permissionMode") is not None:
|
|
380
|
+
# The composer's per-task mode (track permissions); asking less than Settings needs the Mac app's session,
|
|
381
|
+
# which the server checked before this.
|
|
382
|
+
if "permissionMode" not in harness_api.run_fields():
|
|
383
|
+
raise APIError("This Mobster can't set a task's permission mode. Update Mobster.", 409,
|
|
384
|
+
"permissions_unavailable")
|
|
385
|
+
extras["permissionMode"] = body["permissionMode"]
|
|
386
|
+
if attachments:
|
|
387
|
+
if "attachmentIds" not in harness_api.run_fields():
|
|
388
|
+
raise APIError(FILES_MISSING, 409, "attachments_unavailable")
|
|
389
|
+
extras["attachmentIds"] = attachments
|
|
390
|
+
account = self.account_for(thread, body)
|
|
391
|
+
if account is not None:
|
|
392
|
+
extras["account"] = account
|
|
393
|
+
if body.get("sendTo") is not None:
|
|
394
|
+
# Where the new task's result goes when it finishes (track outlets): the run field, as POST /api/runs.
|
|
395
|
+
if "sendTo" not in harness_api.run_fields():
|
|
396
|
+
raise APIError(OUTLETS_MISSING, 409, "outlets_unavailable")
|
|
397
|
+
extras["sendTo"] = body["sendTo"]
|
|
398
|
+
if body.get("longform") is not None:
|
|
399
|
+
# A long task started from the conversation (its plan card's Start): the run field, as POST /api/runs.
|
|
400
|
+
if "longform" not in harness_api.run_fields():
|
|
401
|
+
raise APIError("Long tasks aren't in this build.", 409, "longform_unavailable")
|
|
402
|
+
extras["longform"] = body["longform"]
|
|
403
|
+
device = body.get("device")
|
|
404
|
+
if device is not None and not isinstance(device, str):
|
|
405
|
+
raise ValueError("device must be a device's id, UDID or name")
|
|
406
|
+
if body.get("profiles") is not None and getattr(self.runtime, "fanout", None) is not None:
|
|
407
|
+
extras.pop("account", None) # each member is its own profile (fanout.start_profiles)
|
|
408
|
+
return self.start_group(thread, body, text, via, origin, extras, None, key, profiles=body["profiles"])
|
|
409
|
+
phones = body.get("phones")
|
|
410
|
+
if phones is None and device is None and account is None and (thread.get("group") or {}).get("phones"):
|
|
411
|
+
phones = list(thread["group"]["phones"]) # the next task runs where the last one did: on every phone
|
|
412
|
+
if phones is not None and getattr(self.runtime, "fanout", None) is not None:
|
|
413
|
+
extras.pop("account", None) # each phone acts as itself; an account runs on its own iPhone
|
|
414
|
+
return self.start_group(thread, body, text, via, origin, extras, phones, key)
|
|
415
|
+
if account is not None and "device" not in body:
|
|
416
|
+
device = None # the account's phone (accounts/routing.py), not the conversation's last one
|
|
417
|
+
elif device is None and thread.get("device") and getattr(self.runtime, "pool", None) is None:
|
|
418
|
+
device = thread["device"]
|
|
419
|
+
kwargs = {}
|
|
420
|
+
if device is not None and getattr(self.runtime, "fleet", None) is not None and \
|
|
421
|
+
getattr(self.runtime, "pool", None) is None:
|
|
422
|
+
kwargs["device"] = device
|
|
423
|
+
run_key = run_request_key(thread["id"], key)
|
|
424
|
+
if body.get("pinnedApps") is not None:
|
|
425
|
+
kwargs["pinned_apps"] = body["pinnedApps"]
|
|
426
|
+
if key and self.runtime.journal.lookup(f"thread-{thread['id']}-{key}"[:128]) is not None:
|
|
427
|
+
# A previous build may have admitted this key before it saved the conversation response.
|
|
428
|
+
raise request_unknown()
|
|
429
|
+
try:
|
|
430
|
+
created = self.runtime.create(body.get("appId"), text, "live", run_key,
|
|
431
|
+
output_schema=body.get("outputSchema"),
|
|
432
|
+
output_format=body.get("outputFormat", "auto"), engine=body.get("engine"),
|
|
433
|
+
extras=extras, origin=origin if origin in harness_api.ORIGINS else "api",
|
|
434
|
+
queue=body.get("queue", True) is not False, **kwargs)
|
|
435
|
+
except (APIError, ValueError) as error:
|
|
436
|
+
if run_key is not None and self.runtime.journal.lookup(run_key) is not None:
|
|
437
|
+
raise request_unknown() from None
|
|
438
|
+
if run_key is not None and isinstance(error, APIError):
|
|
439
|
+
raise nothing_started(error) from None # no task under this request's key: none was recorded or sent
|
|
440
|
+
raise
|
|
441
|
+
run = created[0] if isinstance(created, tuple) else created
|
|
442
|
+
try:
|
|
443
|
+
user, _run_item = self.exchange(thread["id"], run, text=text, via=via)
|
|
444
|
+
return 201, {"item": public_item(user) if user else None, "run": run.public(include_events=False)}
|
|
445
|
+
except Exception:
|
|
446
|
+
raise request_unknown() from None
|
|
447
|
+
|
|
448
|
+
def account_for(self, thread, body):
|
|
449
|
+
"""The account a new task acts as (track accounts' ``account`` run field): the one the message names (null:
|
|
450
|
+
none), else the one the conversation's last task acted as, so "now reply to the next one" stays @acme."""
|
|
451
|
+
if "account" in body:
|
|
452
|
+
if body["account"] is not None and "account" not in harness_api.run_fields():
|
|
453
|
+
raise APIError(ACCOUNTS_MISSING, 409, "accounts_unavailable")
|
|
454
|
+
return body["account"]
|
|
455
|
+
if "account" not in harness_api.run_fields():
|
|
456
|
+
return None
|
|
457
|
+
last = self.runtime.runs.get(thread.get("lastRunId") or "")
|
|
458
|
+
previous = (getattr(last, "extras", None) or {}).get("account")
|
|
459
|
+
return previous["id"] if isinstance(previous, dict) and isinstance(previous.get("id"), str) else None
|
|
460
|
+
|
|
461
|
+
def start_group(self, thread, body, text, via, origin, extras, phones, key, profiles=None):
|
|
462
|
+
"""A new task in the thread on several phones at once (fanout.py): one user item, one group item that
|
|
463
|
+
follows the group, and each phone's run item."""
|
|
464
|
+
if key and self.runtime.fanout.has_request(f"thread-{thread['id']}-{key}"[:128]):
|
|
465
|
+
raise request_unknown()
|
|
466
|
+
run_key = run_request_key(thread["id"], key)
|
|
467
|
+
try:
|
|
468
|
+
group, _replayed = (self.runtime.start_profiles if profiles is not None else self.runtime.start_group)(
|
|
469
|
+
profiles if profiles is not None else phones, body.get("appId"), text, idempotency_key=run_key,
|
|
470
|
+
fingerprint=fingerprint(body), thread_id=thread["id"], output_schema=body.get("outputSchema"),
|
|
471
|
+
output_format=body.get("outputFormat", "auto"), engine=body.get("engine"), extras=extras,
|
|
472
|
+
origin=origin if origin in harness_api.ORIGINS else "api")
|
|
473
|
+
except (APIError, ValueError) as error:
|
|
474
|
+
if run_key is not None and self.runtime.fanout.has_request(run_key):
|
|
475
|
+
raise request_unknown() from None
|
|
476
|
+
if run_key is not None and isinstance(error, APIError) and "refused" not in error.details:
|
|
477
|
+
# A group takes its key before its first phone's run (fanout._open), so without it nothing was recorded.
|
|
478
|
+
# The 409 for a group no phone started ("refused" lists each phone) isn't marked: a phone's run may
|
|
479
|
+
# have been recorded, then interrupted (start_failed). It is a 4xx refusal all the same.
|
|
480
|
+
raise nothing_started(error) from None
|
|
481
|
+
raise
|
|
482
|
+
try:
|
|
483
|
+
user, _group_item = self.exchange_group(thread["id"], group, text=text, via=via)
|
|
484
|
+
first = None
|
|
485
|
+
for member in group["members"]:
|
|
486
|
+
run = self.runtime.runs.get(member.get("runId")) if member.get("runId") else None
|
|
487
|
+
if run is not None:
|
|
488
|
+
self.exchange(thread["id"], run)
|
|
489
|
+
first = first or run
|
|
490
|
+
public = self.runtime.fanout.public(group)
|
|
491
|
+
return 201, {"item": public_item(user) if user else None,
|
|
492
|
+
"run": first.public(include_events=False) if first is not None else None, "group": public}
|
|
493
|
+
except Exception:
|
|
494
|
+
raise request_unknown() from None
|
|
495
|
+
|
|
496
|
+
def exchange_group(self, thread_id, group, *, text, via):
|
|
497
|
+
"""The user item and the group item of a task started on several phones; idempotent by the group's id."""
|
|
498
|
+
public = self.runtime.fanout.public(group)
|
|
499
|
+
attachments = self.attachment_refs(list(((group.get("create") or {}).get("extras") or {}).get("attachmentIds")
|
|
500
|
+
or []))
|
|
501
|
+
user = {"kind": "user", "text": text, "via": via, "attachments": attachments, "routed": "new_run",
|
|
502
|
+
"runId": None, "groupId": group["id"]}
|
|
503
|
+
item = {"kind": "group", **group_fields(public)}
|
|
504
|
+
with self.lock:
|
|
505
|
+
try:
|
|
506
|
+
added = self.store.append(thread_id, [user, item], unless=lambda found: any(
|
|
507
|
+
i.get("kind") == "group" and i.get("groupId") == group["id"] for i in found))
|
|
508
|
+
except ThreadFull:
|
|
509
|
+
log.warning("thread %s is full; group %s isn't in it", thread_id, group["id"])
|
|
510
|
+
return None, None
|
|
511
|
+
if added is None:
|
|
512
|
+
found = self.store.find(thread_id, match=lambda i: i.get("groupId") == group["id"]
|
|
513
|
+
and i.get("kind") in ("user", "group"))
|
|
514
|
+
return (next((i for i in found if i["kind"] == "user"), None),
|
|
515
|
+
next((i for i in found if i["kind"] == "group"), None))
|
|
516
|
+
by_profile = any(member.get("profile") for member in public["members"])
|
|
517
|
+
# A group by profile doesn't make the thread's next task run on those phones: the phones are the
|
|
518
|
+
# profiles', not a choice of the person's.
|
|
519
|
+
phones = [] if by_profile else [member["device"] for member in public["members"]]
|
|
520
|
+
names = {} if by_profile else {member["device"]: member.get("deviceName") for member in public["members"]}
|
|
521
|
+
first = next((member["runId"] for member in public["members"] if member.get("runId")), None)
|
|
522
|
+
updated = self.store.update_thread(thread_id, {
|
|
523
|
+
"status": "waiting" if public["state"] == "waiting" else "running", "lastRunId": first,
|
|
524
|
+
"lastGroupId": group["id"], "group": {"id": group["id"], "phones": phones, "names": names},
|
|
525
|
+
"device": None, "deviceName": None})
|
|
526
|
+
self.add_rules(thread_id, text)
|
|
527
|
+
for stored in added:
|
|
528
|
+
self.published_item(thread_id, stored)
|
|
529
|
+
if updated is not None:
|
|
530
|
+
self.published_thread(updated, "thread_updated")
|
|
531
|
+
return added[0], added[1]
|
|
532
|
+
|
|
533
|
+
def on_group(self, group, event):
|
|
534
|
+
"""fanout's listener: the group's item follows it (a phone started, was given up, finished)."""
|
|
535
|
+
thread_id = group.get("threadId")
|
|
536
|
+
if not thread_id or self.store.get(thread_id) is None:
|
|
537
|
+
return
|
|
538
|
+
with self.lock:
|
|
539
|
+
found = self.store.find(thread_id, kind="group", match=lambda i: i.get("groupId") == group["id"])
|
|
540
|
+
if not found:
|
|
541
|
+
return
|
|
542
|
+
try:
|
|
543
|
+
updated = self.store.update_item(thread_id, found[0]["id"], group_fields(group), kinds={"group"})
|
|
544
|
+
except ThreadFull:
|
|
545
|
+
updated = self.store.update_item(thread_id, found[0]["id"],
|
|
546
|
+
{k: v for k, v in group_fields(group).items() if k != "summary"},
|
|
547
|
+
kinds={"group"})
|
|
548
|
+
thread = self.store.get(thread_id) or {}
|
|
549
|
+
current = thread.get("lastGroupId") == group["id"]
|
|
550
|
+
status = {"waiting": "waiting", "running": "running", "held": "running"}.get(group["state"], "idle")
|
|
551
|
+
changes = {"status": status} if current and thread.get("status") != status else {}
|
|
552
|
+
updated_thread = self.store.update_thread(thread_id, changes) if changes else None
|
|
553
|
+
if updated is not None:
|
|
554
|
+
self.published_item(thread_id, updated, "thread_item_updated")
|
|
555
|
+
if updated_thread is not None:
|
|
556
|
+
self.published_thread(updated_thread, "thread_updated")
|
|
557
|
+
|
|
558
|
+
def offer_to_remember(self, thread, text, via, body):
|
|
559
|
+
""""Remember that my gym is the one on 5th Street" is a note for Mobster, not a task for the phone."""
|
|
560
|
+
try:
|
|
561
|
+
from ..memory import extract
|
|
562
|
+
from ..memory.listener import publish, thread_item
|
|
563
|
+
from ..memory.store import default_store, normalize
|
|
564
|
+
except ImportError:
|
|
565
|
+
return None
|
|
566
|
+
found = extract.remember_only(text)
|
|
567
|
+
if not found:
|
|
568
|
+
return None
|
|
569
|
+
if body.get("profiles") is not None:
|
|
570
|
+
raise APIError("Choose one saved profile for a thing to remember, or choose No profile to remember it "
|
|
571
|
+
"for every task.", 409, "memory_profile_ambiguous")
|
|
572
|
+
account = self.account_for(thread, body)
|
|
573
|
+
scope = "global"
|
|
574
|
+
if account is not None:
|
|
575
|
+
from ..accounts.routing import validate
|
|
576
|
+
scope = f"profile:{validate(account, self.runtime)['id']}"
|
|
577
|
+
store = default_store()
|
|
578
|
+
if store is None:
|
|
579
|
+
return None
|
|
580
|
+
if any(secret_filter.is_secret(candidate.text) for candidate in found):
|
|
581
|
+
raise APIError("Mobster doesn't remember passwords, codes or card numbers.", 400, "memory_secret")
|
|
582
|
+
known = {normalize(fact.get("text") or "") for fact in store.all_facts() if fact.get("scope") == scope}
|
|
583
|
+
if all(normalize(candidate.text) in known for candidate in found):
|
|
584
|
+
raise APIError("Mobster already remembers this.", 409, "memory_duplicate")
|
|
585
|
+
user = self.add_user(thread["id"], text, via, "remember", None, [])
|
|
586
|
+
proposals = []
|
|
587
|
+
for candidate in found:
|
|
588
|
+
proposal = store.propose(candidate.text, scope, thread_id=thread["id"], pin=candidate.pin)
|
|
589
|
+
if proposal is None:
|
|
590
|
+
continue
|
|
591
|
+
proposals.append(proposal)
|
|
592
|
+
item = self.add_other(thread["id"], thread_item(proposal))
|
|
593
|
+
if isinstance(item, dict) and item.get("id"):
|
|
594
|
+
store.set_proposal_item(proposal["id"], item["id"])
|
|
595
|
+
publish({"event": "proposal_created", "proposal": proposal})
|
|
596
|
+
updated = self.store.update_thread(thread["id"], {})
|
|
597
|
+
if updated is not None:
|
|
598
|
+
self.published_thread(updated, "thread_updated")
|
|
599
|
+
return 201, {"item": public_item(user), "run": None, "routed": "remember",
|
|
600
|
+
"proposals": [{"id": p["id"], "text": p["text"], "scope": p["scope"], "pin": p["pin"]}
|
|
601
|
+
for p in proposals]}
|
|
602
|
+
|
|
603
|
+
def add_other(self, thread_id, item):
|
|
604
|
+
"""Another track's item in a thread (a suggestion to remember), published as harness_api's sink does."""
|
|
605
|
+
try:
|
|
606
|
+
stored = self.store.append(thread_id, [item])[0]
|
|
607
|
+
except Exception: # noqa: BLE001 -- a thread that can't take the item never breaks the message
|
|
608
|
+
log.exception("could not add a %s item", item.get("kind"))
|
|
609
|
+
return None
|
|
610
|
+
self.published_item(thread_id, stored)
|
|
611
|
+
return stored
|
|
612
|
+
|
|
613
|
+
def attachment_refs(self, ids):
|
|
614
|
+
"""AttachmentRef for each id (files track's table, SPEC §3.4); a bare ref when it can't be read."""
|
|
615
|
+
if not ids:
|
|
616
|
+
return []
|
|
617
|
+
refs = {}
|
|
618
|
+
try:
|
|
619
|
+
with self.runtime.journal.lock:
|
|
620
|
+
rows = self.runtime.journal.connection.execute(
|
|
621
|
+
f"SELECT id, data FROM attachments WHERE id IN ({','.join('?' * len(ids))})", list(ids)).fetchall()
|
|
622
|
+
for ident, data in rows:
|
|
623
|
+
value = json.loads(data)
|
|
624
|
+
refs[ident] = {"id": ident, "name": str(value.get("name") or ""), "mime": str(value.get("mime") or ""),
|
|
625
|
+
"bytes": int(value.get("bytes") or 0), "kind": str(value.get("kind") or "")}
|
|
626
|
+
except Exception: # noqa: BLE001 -- no files track, or another shape: bare refs
|
|
627
|
+
pass
|
|
628
|
+
return [refs.get(i) or {"id": i, "name": "", "mime": "", "bytes": 0, "kind": ""} for i in ids]
|
|
629
|
+
|
|
630
|
+
def add_rules(self, thread_id, text):
|
|
631
|
+
thread = self.store.get(thread_id)
|
|
632
|
+
if thread is None:
|
|
633
|
+
return
|
|
634
|
+
rules = merge_rules(thread.get("rules"), text)
|
|
635
|
+
if rules != (thread.get("rules") or []):
|
|
636
|
+
updated = self.store.update_thread(thread_id, {"rules": rules}, touch=False)
|
|
637
|
+
if updated is not None:
|
|
638
|
+
self.published_thread(updated, "thread_updated")
|
|
639
|
+
|
|
640
|
+
def add_user(self, thread_id, text, via, routed, run_id, attachments, **extra):
|
|
641
|
+
item = {"kind": "user", "text": text, "via": via, "attachments": attachments, "routed": routed,
|
|
642
|
+
"runId": run_id, **extra}
|
|
643
|
+
stored = self.store.append(thread_id, [item])[0]
|
|
644
|
+
self.add_rules(thread_id, text)
|
|
645
|
+
self.published_item(thread_id, stored)
|
|
646
|
+
return stored
|
|
647
|
+
|
|
648
|
+
def add_steer(self, thread_id, run_id, message_id, text, via):
|
|
649
|
+
"""The user item for a message sent to a running task (idempotent by the message's id)."""
|
|
650
|
+
added = self.store.append(thread_id, [{"kind": "user", "text": text, "via": via, "attachments": [],
|
|
651
|
+
"routed": "steer", "runId": run_id, "messageId": message_id}],
|
|
652
|
+
unless=lambda found: any(i.get("messageId") == message_id for i in found))
|
|
653
|
+
if added is None:
|
|
654
|
+
return self.store.find(thread_id, kind="user", run_id=run_id,
|
|
655
|
+
match=lambda i: i.get("messageId") == message_id)[0]
|
|
656
|
+
self.add_rules(thread_id, text)
|
|
657
|
+
self.published_item(thread_id, added[0])
|
|
658
|
+
return added[0]
|
|
659
|
+
|
|
660
|
+
def exchange(self, thread_id, run, *, text=None, via=None):
|
|
661
|
+
"""The user item and the run item of a task started in the thread; idempotent by the run's id (the route
|
|
662
|
+
and the listener's on_created both call it)."""
|
|
663
|
+
extras = run.extras if isinstance(run.extras, dict) else {}
|
|
664
|
+
if getattr(run, "group_id", None):
|
|
665
|
+
return None, self.exchange_member(thread_id, run)
|
|
666
|
+
via = via or extras.get("via") or ORIGIN_VIA.get(run.origin, "cli")
|
|
667
|
+
attachments = self.attachment_refs(list(extras.get("attachmentIds") or []))
|
|
668
|
+
user = {"kind": "user", "text": text if text is not None else run.goal, "via": via,
|
|
669
|
+
"attachments": attachments, "routed": "new_run", "runId": run.id}
|
|
670
|
+
start_app = run.app.get("name") if isinstance(run.app, dict) and run.app.get("bundleId") else None
|
|
671
|
+
run_item = {"kind": "run", "runId": run.id, "goal": run.goal, "appName": start_app, "engine": run.engine,
|
|
672
|
+
"device": run.device_id}
|
|
673
|
+
with self.lock:
|
|
674
|
+
self.runs[run.id] = thread_id
|
|
675
|
+
try:
|
|
676
|
+
added = self.store.append(
|
|
677
|
+
thread_id, [user, run_item],
|
|
678
|
+
unless=lambda found: any(i.get("kind") == "run" and i.get("runId") == run.id for i in found))
|
|
679
|
+
except ThreadFull:
|
|
680
|
+
log.warning("thread %s is full; run %s isn't in it", thread_id, run.id)
|
|
681
|
+
return None, None
|
|
682
|
+
if added is None:
|
|
683
|
+
found = self.store.find(thread_id, run_id=run.id)
|
|
684
|
+
return (next((i for i in found if i["kind"] == "user" and i.get("routed") == "new_run"), None),
|
|
685
|
+
next((i for i in found if i["kind"] == "run"), None))
|
|
686
|
+
changes = {"status": "running", "lastRunId": run.id}
|
|
687
|
+
thread = self.store.get(thread_id) or {}
|
|
688
|
+
if run.device_id and (not thread.get("device") or thread.get("device") != run.device_id):
|
|
689
|
+
changes.update(device=run.device_id, deviceName=run.device_name)
|
|
690
|
+
if thread.get("group"):
|
|
691
|
+
changes.update(group=None, lastGroupId=None) # the newest task runs on one phone
|
|
692
|
+
updated = self.store.update_thread(thread_id, changes)
|
|
693
|
+
self.add_rules(thread_id, user["text"])
|
|
694
|
+
for item in added:
|
|
695
|
+
self.published_item(thread_id, item)
|
|
696
|
+
if updated is not None:
|
|
697
|
+
self.published_thread(self.store.get(thread_id) or updated, "thread_updated")
|
|
698
|
+
return added[0], added[1]
|
|
699
|
+
|
|
700
|
+
def exchange_member(self, thread_id, run):
|
|
701
|
+
"""One phone's run item in a task started on several phones (the user and group items are the group's)."""
|
|
702
|
+
start_app = run.app.get("name") if isinstance(run.app, dict) and run.app.get("bundleId") else None
|
|
703
|
+
item = {"kind": "run", "runId": run.id, "goal": run.goal, "appName": start_app, "engine": run.engine,
|
|
704
|
+
"device": run.device_id, "deviceName": run.device_name, "groupId": run.group_id}
|
|
705
|
+
with self.lock:
|
|
706
|
+
self.runs[run.id] = thread_id
|
|
707
|
+
try:
|
|
708
|
+
added = self.store.append(thread_id, [item], unless=lambda found: any(
|
|
709
|
+
i.get("kind") == "run" and i.get("runId") == run.id for i in found))
|
|
710
|
+
except ThreadFull:
|
|
711
|
+
log.warning("thread %s is full; run %s isn't in it", thread_id, run.id)
|
|
712
|
+
return None
|
|
713
|
+
if added is None:
|
|
714
|
+
return next(iter(self.store.find(thread_id, kind="run", run_id=run.id)), None)
|
|
715
|
+
thread = self.store.get(thread_id) or {}
|
|
716
|
+
updated = None
|
|
717
|
+
if not thread.get("lastRunId") and thread.get("lastGroupId") == run.group_id:
|
|
718
|
+
updated = self.store.update_thread(thread_id, {"lastRunId": run.id})
|
|
719
|
+
self.published_item(thread_id, added[0])
|
|
720
|
+
if updated is not None:
|
|
721
|
+
self.published_thread(updated, "thread_updated")
|
|
722
|
+
return added[0]
|
|
723
|
+
|
|
724
|
+
def answered(self, thread_id, run_id, approval_id, answer):
|
|
725
|
+
for item in self.store.find(thread_id, kind="clarify", run_id=run_id,
|
|
726
|
+
match=lambda i: i.get("approvalId") == approval_id):
|
|
727
|
+
updated = self.store.update_item(thread_id, item["id"], {"answer": answer})
|
|
728
|
+
if updated is not None:
|
|
729
|
+
self.published_item(thread_id, updated, "thread_item_updated")
|
|
730
|
+
|
|
731
|
+
def set_status(self, thread_id, status):
|
|
732
|
+
thread = self.store.get(thread_id)
|
|
733
|
+
if thread is None or thread.get("status") == status:
|
|
734
|
+
return
|
|
735
|
+
updated = self.store.update_thread(thread_id, {"status": status})
|
|
736
|
+
if updated is not None:
|
|
737
|
+
self.published_thread(updated, "thread_updated")
|
|
738
|
+
|
|
739
|
+
def thread_of(self, run):
|
|
740
|
+
thread_id = self.runs.get(run.id)
|
|
741
|
+
if thread_id is None and isinstance(getattr(run, "extras", None), dict):
|
|
742
|
+
thread_id = run.extras.get("threadId")
|
|
743
|
+
return thread_id
|
|
744
|
+
|
|
745
|
+
def on_created(self, run):
|
|
746
|
+
thread_id = run.extras.get("threadId") if isinstance(run.extras, dict) else None
|
|
747
|
+
if thread_id and self.store.get(thread_id) is not None:
|
|
748
|
+
self.exchange(thread_id, run)
|
|
749
|
+
|
|
750
|
+
def on_event(self, run, event):
|
|
751
|
+
thread_id = self.thread_of(run)
|
|
752
|
+
if not thread_id:
|
|
753
|
+
return
|
|
754
|
+
# The thread view reads everything from its one /api/events stream: the run's events too, as the run SSE
|
|
755
|
+
# sends them.
|
|
756
|
+
self.publish(f"thread:{thread_id}", {"event": "run_event", "runId": run.id, "data": event})
|
|
757
|
+
kind = event.get("event")
|
|
758
|
+
if isinstance(event.get("app_name"), str) and event["app_name"]:
|
|
759
|
+
self.last_app[run.id] = event["app_name"][:80]
|
|
760
|
+
if kind == "user_message":
|
|
761
|
+
source = event.get("source")
|
|
762
|
+
self.add_steer(thread_id, run.id, event.get("id"), event.get("text") or "", ORIGIN_VIA.get(source, "cli"))
|
|
763
|
+
elif kind == "steer_applied":
|
|
764
|
+
ids = set(event.get("ids") or ())
|
|
765
|
+
for item in self.store.find(thread_id, kind="user", run_id=run.id,
|
|
766
|
+
match=lambda i: i.get("messageId") in ids and not i.get("readAt")):
|
|
767
|
+
updated = self.store.update_item(thread_id, item["id"], {"readAt": event.get("timestamp")})
|
|
768
|
+
if updated is not None:
|
|
769
|
+
self.published_item(thread_id, updated, "thread_item_updated")
|
|
770
|
+
elif kind == "steer_unread":
|
|
771
|
+
ids = set(event.get("ids") or ())
|
|
772
|
+
for item in self.store.find(thread_id, kind="user", run_id=run.id,
|
|
773
|
+
match=lambda i: i.get("messageId") in ids):
|
|
774
|
+
updated = self.store.update_item(thread_id, item["id"], {"routed": "unread", "note": UNREAD_NOTE})
|
|
775
|
+
if updated is not None:
|
|
776
|
+
self.published_item(thread_id, updated, "thread_item_updated")
|
|
777
|
+
elif kind == "approval_requested":
|
|
778
|
+
self.set_status(thread_id, "waiting")
|
|
779
|
+
if event.get("kind") == "clarify":
|
|
780
|
+
self.clarify_item(thread_id, run, event)
|
|
781
|
+
if getattr(run, "group_id", None):
|
|
782
|
+
self.group_changed(run)
|
|
783
|
+
elif kind == "approval_resolved":
|
|
784
|
+
if getattr(run, "group_id", None):
|
|
785
|
+
self.group_changed(run) # still waiting while another phone waits
|
|
786
|
+
else:
|
|
787
|
+
self.set_status(thread_id, "running")
|
|
788
|
+
decision = str(event.get("decision") or "")
|
|
789
|
+
for item in self.store.find(thread_id, kind="clarify", run_id=run.id,
|
|
790
|
+
match=lambda i: i.get("approvalId") == event.get("approval_id")):
|
|
791
|
+
changes = {"resolution": "answered" if decision.startswith(("answer", "choice")) else decision}
|
|
792
|
+
if decision.startswith("choice:"):
|
|
793
|
+
choice = decision.split(":", 1)[1]
|
|
794
|
+
label = next((c["label"] for c in item.get("choices") or () if c.get("id") == choice), choice)
|
|
795
|
+
changes["answer"] = item.get("answer") or label
|
|
796
|
+
updated = self.store.update_item(thread_id, item["id"], changes)
|
|
797
|
+
if updated is not None:
|
|
798
|
+
self.published_item(thread_id, updated, "thread_item_updated")
|
|
799
|
+
|
|
800
|
+
def group_changed(self, run):
|
|
801
|
+
"""A phone of a group asked or was answered: the group's item and the thread's state follow."""
|
|
802
|
+
fanout = getattr(self.runtime, "fanout", None)
|
|
803
|
+
if fanout is None:
|
|
804
|
+
return
|
|
805
|
+
try:
|
|
806
|
+
group = fanout.get(run.group_id)
|
|
807
|
+
except APIError:
|
|
808
|
+
return
|
|
809
|
+
self.on_group(fanout.public(group), "updated")
|
|
810
|
+
|
|
811
|
+
def clarify_item(self, thread_id, run, event, pending=None):
|
|
812
|
+
approval_id = event.get("approval_id")
|
|
813
|
+
if pending is None:
|
|
814
|
+
with run.condition:
|
|
815
|
+
pending = dict(run.approval) if run.approval and run.approval.get("id") == approval_id else None
|
|
816
|
+
question = (pending or {}).get("question") or event.get("label") or ""
|
|
817
|
+
choices = ([{"id": c["id"], "label": c["label"], **({"detail": c["detail"]} if c.get("detail") else {})}
|
|
818
|
+
for c in (pending or {}).get("choices") or () if isinstance(c, dict)]
|
|
819
|
+
or [{"id": c, "label": c} for c in event.get("choices") or ()])
|
|
820
|
+
item = {"kind": "clarify", "runId": run.id, "approvalId": approval_id, "question": question[:300],
|
|
821
|
+
"choices": choices[:6], "allowText": bool((pending or {}).get("allowText", True))}
|
|
822
|
+
# Answered here before this item existed (the listener runs after the answer): keep the answer.
|
|
823
|
+
answer = self.store.find(thread_id, kind="user", run_id=run.id,
|
|
824
|
+
match=lambda i: i.get("routed") == "answer" and i.get("approvalId") == approval_id)
|
|
825
|
+
if answer:
|
|
826
|
+
item["answer"] = answer[0]["text"]
|
|
827
|
+
try:
|
|
828
|
+
added = self.store.append(thread_id, [item],
|
|
829
|
+
unless=lambda found: any(i.get("approvalId") == approval_id for i in found))
|
|
830
|
+
except ThreadFull:
|
|
831
|
+
return
|
|
832
|
+
if added:
|
|
833
|
+
self.published_item(thread_id, added[0])
|
|
834
|
+
|
|
835
|
+
def on_finished(self, run, summary, private):
|
|
836
|
+
thread_id = self.thread_of(run)
|
|
837
|
+
self.runs.pop(run.id, None)
|
|
838
|
+
last_app = self.last_app.pop(run.id, None)
|
|
839
|
+
if not thread_id or self.store.get(thread_id) is None:
|
|
840
|
+
return
|
|
841
|
+
self.record_result(thread_id, run, summary or {}, private or {}, last_app)
|
|
842
|
+
if run.events and run.events[-1].get("event") == "run_finished":
|
|
843
|
+
self.publish(f"thread:{thread_id}", {"event": "run_event", "runId": run.id, "data": run.events[-1]})
|
|
844
|
+
|
|
845
|
+
def record_result(self, thread_id, run, summary, private, last_app=None):
|
|
846
|
+
"""Keep the run's result in its item, so the conversation outlives the run's 100-run retention."""
|
|
847
|
+
items = self.store.find(thread_id, kind="run", run_id=run.id)
|
|
848
|
+
if not items:
|
|
849
|
+
self.exchange(thread_id, run)
|
|
850
|
+
items = self.store.find(thread_id, kind="run", run_id=run.id)
|
|
851
|
+
if not items:
|
|
852
|
+
return
|
|
853
|
+
item = items[0]
|
|
854
|
+
result = result_of(summary, run)
|
|
855
|
+
context = context_of(summary, private, last_app)
|
|
856
|
+
turn = sum(1 for i in self.store.items(thread_id) if i.get("kind") == "run"
|
|
857
|
+
and i.get("seq", 0) <= item.get("seq", 0))
|
|
858
|
+
context["referents"] = [{**ref, "turn": turn, "task": run.goal[:120]} for ref in referents_of(summary)]
|
|
859
|
+
changes = {"result": result, "_context": context}
|
|
860
|
+
if not item.get("appName") and last_app:
|
|
861
|
+
changes["appName"] = last_app
|
|
862
|
+
try:
|
|
863
|
+
updated = self.store.update_item(thread_id, item["id"], changes)
|
|
864
|
+
except ThreadFull:
|
|
865
|
+
updated = self.store.update_item(thread_id, item["id"], {"result": result})
|
|
866
|
+
if updated is not None:
|
|
867
|
+
self.published_item(thread_id, updated, "thread_item_updated")
|
|
868
|
+
thread = self.store.get(thread_id)
|
|
869
|
+
if getattr(run, "group_id", None):
|
|
870
|
+
return # the group's item and the thread's state follow the whole group (on_group)
|
|
871
|
+
if thread is not None and thread.get("lastRunId") in (run.id, None):
|
|
872
|
+
changes = {"status": "idle"}
|
|
873
|
+
updated_thread = self.store.update_thread(thread_id, changes)
|
|
874
|
+
if updated_thread is not None:
|
|
875
|
+
self.published_thread(updated_thread, "thread_updated")
|
|
876
|
+
|
|
877
|
+
def reconcile(self):
|
|
878
|
+
"""After a restart: a thread left running or waiting gets its run's result (the run was marked
|
|
879
|
+
interrupted when the journal reloaded) and goes back to idle."""
|
|
880
|
+
try:
|
|
881
|
+
stale = self.store.threads_with_status(("running", "waiting"))
|
|
882
|
+
except Exception: # noqa: BLE001
|
|
883
|
+
log.exception("could not read the conversations")
|
|
884
|
+
return
|
|
885
|
+
for thread in stale:
|
|
886
|
+
run = self.runtime.runs.get(thread.get("lastRunId"))
|
|
887
|
+
if run is not None and run.finished_at is None:
|
|
888
|
+
continue
|
|
889
|
+
if run is not None:
|
|
890
|
+
for item in self.store.find(thread["id"], kind="run", run_id=run.id):
|
|
891
|
+
if not item.get("result"):
|
|
892
|
+
self.store.update_item(thread["id"], item["id"], {"result": result_of(run.summary or {}, run)})
|
|
893
|
+
self.store.update_thread(thread["id"], {"status": "idle"}, touch=False)
|
|
894
|
+
|
|
895
|
+
def sink_append(self, thread_id, item):
|
|
896
|
+
if not isinstance(item, dict) or not isinstance(item.get("kind"), str) or not KIND.fullmatch(item["kind"]):
|
|
897
|
+
raise ValueError("A thread item has a kind of 1 to 40 lowercase letters or underscores")
|
|
898
|
+
if item["kind"] in CORE_KINDS:
|
|
899
|
+
raise ValueError("user, run and clarify items come only from conversations")
|
|
900
|
+
clean = {k: v for k, v in item.items() if k not in ("id", "seq", "at") and not k.startswith("_")}
|
|
901
|
+
if len(json.dumps(clean, ensure_ascii=False, allow_nan=False).encode()) > MAX_SINK_ITEM_BYTES - 200:
|
|
902
|
+
raise ValueError(f"A thread item is at most {MAX_SINK_ITEM_BYTES:,} bytes")
|
|
903
|
+
if self.store.get(thread_id) is None:
|
|
904
|
+
return None
|
|
905
|
+
added = self.store.append(thread_id, [clean])
|
|
906
|
+
if added:
|
|
907
|
+
self.published_item(thread_id, added[0])
|
|
908
|
+
return public_item(added[0])
|
|
909
|
+
return None
|
|
910
|
+
|
|
911
|
+
def sink_update(self, thread_id, item_id, changes):
|
|
912
|
+
if not isinstance(changes, dict):
|
|
913
|
+
raise ValueError("changes is an object")
|
|
914
|
+
clean = {k: v for k, v in changes.items() if not k.startswith("_")}
|
|
915
|
+
found = self.store.find(thread_id, match=lambda i: i.get("id") == item_id)
|
|
916
|
+
if not found or found[0].get("kind") in CORE_KINDS:
|
|
917
|
+
return None
|
|
918
|
+
kinds = {found[0]["kind"]}
|
|
919
|
+
updated = self.store.update_item(thread_id, item_id, clean, kinds=kinds, max_bytes=MAX_SINK_ITEM_BYTES)
|
|
920
|
+
if updated is not None:
|
|
921
|
+
self.published_item(thread_id, updated, "thread_item_updated")
|
|
922
|
+
return public_item(updated)
|
|
923
|
+
return None
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
class Listener:
|
|
927
|
+
"""The run listener conversations registers (harness_api.register_run_listener)."""
|
|
928
|
+
|
|
929
|
+
@staticmethod
|
|
930
|
+
def _service(run):
|
|
931
|
+
for service in services():
|
|
932
|
+
if run.id in service.runs or service.runtime.runs.get(run.id) is run:
|
|
933
|
+
return service
|
|
934
|
+
return None
|
|
935
|
+
|
|
936
|
+
def on_created(self, run, runtime):
|
|
937
|
+
service = service_for(runtime)
|
|
938
|
+
if service is not None and isinstance(run.extras, dict) and run.extras.get("threadId"):
|
|
939
|
+
service.on_created(run)
|
|
940
|
+
|
|
941
|
+
def on_event(self, run, event):
|
|
942
|
+
if not (isinstance(getattr(run, "extras", None), dict) and run.extras.get("threadId")):
|
|
943
|
+
return
|
|
944
|
+
service = self._service(run)
|
|
945
|
+
if service is not None:
|
|
946
|
+
service.on_event(run, event)
|
|
947
|
+
|
|
948
|
+
def on_finished(self, run, summary, private):
|
|
949
|
+
if not (isinstance(getattr(run, "extras", None), dict) and run.extras.get("threadId")):
|
|
950
|
+
return
|
|
951
|
+
service = self._service(run)
|
|
952
|
+
if service is not None:
|
|
953
|
+
service.on_finished(run, summary, private)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def group_listener(runtime, group, event):
|
|
957
|
+
"""fanout.add_listener: the group's item in its conversation follows the group."""
|
|
958
|
+
service = service_for(runtime)
|
|
959
|
+
if service is not None:
|
|
960
|
+
service.on_group(group, event)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def sink_append(thread_id, item):
|
|
964
|
+
"""harness_api.post_thread_item lands here: the item in the thread of whichever Runtime has it."""
|
|
965
|
+
for service in services():
|
|
966
|
+
if service.store.get(thread_id) is not None:
|
|
967
|
+
return service.sink_append(thread_id, item)
|
|
968
|
+
return None
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def sink_update(thread_id, item_id, changes):
|
|
972
|
+
for service in services():
|
|
973
|
+
if service.store.get(thread_id) is not None:
|
|
974
|
+
return service.sink_update(thread_id, item_id, changes)
|
|
975
|
+
return None
|