otto-cli-agent 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (327) hide show
  1. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/PKG-INFO +5 -4
  2. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/README.md +1 -0
  3. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/README.md +2 -1
  4. otto_cli_agent-0.1.4/agent/phone/actions.py +111 -0
  5. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/backend.py +13 -1
  6. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/tools.py +96 -10
  7. otto_cli_agent-0.1.4/agent/pipeline/documents.py +323 -0
  8. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/nodes.py +70 -23
  9. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/research.py +19 -5
  10. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/tools.py +48 -0
  11. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/usage.py +9 -1
  12. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/vision.py +34 -0
  13. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/anthropic_provider.py +10 -0
  14. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/base.py +30 -0
  15. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/gemini_provider.py +4 -0
  16. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/inception_provider.py +35 -16
  17. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/openai_provider.py +10 -0
  18. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/mapping.py +23 -36
  19. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/router.py +17 -0
  20. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/pyproject.toml +8 -7
  21. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_automap.py +2 -2
  22. otto_cli_agent-0.1.4/tests/test_documents.py +133 -0
  23. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_model_policies.py +10 -8
  24. otto_cli_agent-0.1.4/tests/test_phone_actions.py +213 -0
  25. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_call_budget.py +5 -2
  26. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_decision.py +60 -5
  27. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_tools.py +5 -1
  28. otto_cli_agent-0.1.4/tests/test_prompt_caching.py +108 -0
  29. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_research_workflow.py +8 -3
  30. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_tools_stubs.py +1 -1
  31. otto_cli_agent-0.1.4/tests/test_vision_fallback.py +112 -0
  32. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/uv.lock +33 -7
  33. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.gitattributes +0 -0
  34. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  35. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  36. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  37. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.github/workflows/publish.yml +0 -0
  38. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.github/workflows/tests.yml +0 -0
  39. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.gitignore +0 -0
  40. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.python-version +0 -0
  41. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/CODE_OF_CONDUCT.md +0 -0
  42. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/CONTRIBUTING.md +0 -0
  43. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/LICENSE +0 -0
  44. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/SECURITY.md +0 -0
  45. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/README.md +0 -0
  46. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/__init__.py +0 -0
  47. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/README.md +0 -0
  48. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/__init__.py +0 -0
  49. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/art.py +0 -0
  50. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/chat.py +0 -0
  51. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/clipboard.py +0 -0
  52. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/context.py +0 -0
  53. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/doctor.py +0 -0
  54. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/errors.py +0 -0
  55. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/eval.py +0 -0
  56. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/eval_claw.py +0 -0
  57. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/eval_compaction.py +0 -0
  58. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/eval_hle.py +0 -0
  59. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/eval_memory.py +0 -0
  60. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/eval_swe.py +0 -0
  61. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/lessons.py +0 -0
  62. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/main.py +0 -0
  63. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/modals.py +0 -0
  64. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/models.py +0 -0
  65. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/output.py +0 -0
  66. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/route.py +0 -0
  67. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/serve.py +0 -0
  68. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/sessions.py +0 -0
  69. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/setup_screen.py +0 -0
  70. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/shell.py +0 -0
  71. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/tui.py +0 -0
  72. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/ui.py +0 -0
  73. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/usage_panel.py +0 -0
  74. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/config/README.md +0 -0
  75. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/config/__init__.py +0 -0
  76. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/config/envfile.py +0 -0
  77. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/config/home.py +0 -0
  78. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/embed.py +0 -0
  79. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/README.md +0 -0
  80. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/__init__.py +0 -0
  81. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/claw_bench.py +0 -0
  82. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/compaction_bench.py +0 -0
  83. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/data/README.md +0 -0
  84. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/data/claw/README.md +0 -0
  85. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/data/claw/llm_judge-gemini.patch +0 -0
  86. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/data/claw/otto.yaml +0 -0
  87. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/failures.py +0 -0
  88. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/README.md +0 -0
  89. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/code_01.json +0 -0
  90. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/code_02.json +0 -0
  91. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/code_03.json +0 -0
  92. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/code_04.json +0 -0
  93. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/code_05.json +0 -0
  94. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/code_06.json +0 -0
  95. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/math_01.json +0 -0
  96. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/math_02.json +0 -0
  97. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/math_03.json +0 -0
  98. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/math_04.json +0 -0
  99. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/math_05.json +0 -0
  100. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/math_06.json +0 -0
  101. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_gcp_01.json +0 -0
  102. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_ksp_01.json +0 -0
  103. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_math_binpacking_01.json +0 -0
  104. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_math_clique_01.json +0 -0
  105. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_math_setcover_01.json +0 -0
  106. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_math_subsetsum_01.json +0 -0
  107. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_tsp_01.json +0 -0
  108. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_tsp_02.json +0 -0
  109. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/hle_bench.py +0 -0
  110. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/langfuse_sync.py +0 -0
  111. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/memory_bench.py +0 -0
  112. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/runner.py +0 -0
  113. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/single_agent.py +0 -0
  114. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/swe_bench.py +0 -0
  115. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/terminal_bench.py +0 -0
  116. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/README.md +0 -0
  117. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/__init__.py +0 -0
  118. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/embeddings.py +0 -0
  119. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/hashing.py +0 -0
  120. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/lessons.py +0 -0
  121. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/queue.py +0 -0
  122. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/retrieval.py +0 -0
  123. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/session.py +0 -0
  124. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/sessions.py +0 -0
  125. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/store.py +0 -0
  126. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/tokens.py +0 -0
  127. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/wiring.py +0 -0
  128. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/__init__.py +0 -0
  129. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/assets/app_notes/com.android.settings.md +0 -0
  130. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/assets/app_notes/in.amazon.mShop.android.shopping.md +0 -0
  131. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/assets/guard_pages.json +0 -0
  132. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/assets/guard_rules.json +0 -0
  133. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/digest.py +0 -0
  134. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/guard.py +0 -0
  135. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/notes.py +0 -0
  136. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/README.md +0 -0
  137. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/__init__.py +0 -0
  138. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/_python_session_shim.py +0 -0
  139. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/browsing.py +0 -0
  140. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/budget.py +0 -0
  141. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/codemap.py +0 -0
  142. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/evidence.py +0 -0
  143. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/execution.py +0 -0
  144. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/modes.py +0 -0
  145. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/native.py +0 -0
  146. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/pricing.py +0 -0
  147. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/profile.py +0 -0
  148. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/progress.py +0 -0
  149. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/python_session.py +0 -0
  150. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/rag.py +0 -0
  151. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/run.py +0 -0
  152. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/screen.py +0 -0
  153. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/state.py +0 -0
  154. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/toolkit.py +0 -0
  155. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/tracing.py +0 -0
  156. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/walkthrough.py +0 -0
  157. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/workspace.py +0 -0
  158. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/README.md +0 -0
  159. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/__init__.py +0 -0
  160. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/automap.py +0 -0
  161. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/health.py +0 -0
  162. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/README.md +0 -0
  163. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/__init__.py +0 -0
  164. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/custom.py +0 -0
  165. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/retired.py +0 -0
  166. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/temperature.py +0 -0
  167. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/outcomes.py +0 -0
  168. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/overrides.py +0 -0
  169. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/reload.py +0 -0
  170. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/setup.py +0 -0
  171. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/server/README.md +0 -0
  172. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/server/__init__.py +0 -0
  173. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/server/app.py +0 -0
  174. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/server/protocol.py +0 -0
  175. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/server/proxy.py +0 -0
  176. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/containers/README.md +0 -0
  177. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/containers/otto-desktop/Dockerfile +0 -0
  178. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/containers/otto-desktop/README.md +0 -0
  179. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/containers/otto-desktop/start-desktop.sh +0 -0
  180. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/debug_pipeline.py +0 -0
  181. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/HISTORY.md +0 -0
  182. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/README.md +0 -0
  183. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/RESEARCH.md +0 -0
  184. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/_config.yml +0 -0
  185. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/design/README.md +0 -0
  186. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/design/tiered-memory.md +0 -0
  187. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/index.md +0 -0
  188. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/codebase-code-map.png +0 -0
  189. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/exercise-todo-cli.png +0 -0
  190. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/otto-demo.gif +0 -0
  191. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/palette-doctor-route.png +0 -0
  192. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/run-trace-and-judge.png +0 -0
  193. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/session-resumed-thinking.png +0 -0
  194. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/sessions-picker.png +0 -0
  195. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/setup-screen.png +0 -0
  196. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/social-preview.png +0 -0
  197. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/pypi.md +0 -0
  198. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/scripts/phone_page_fixture.py +0 -0
  199. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/scripts/phone_page_manifest.py +0 -0
  200. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/README.md +0 -0
  201. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/conftest.py +0 -0
  202. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_address_form_pincode.json +0 -0
  203. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_address_step.json +0 -0
  204. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_buy_now_sheet.json +0 -0
  205. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_cart.json +0 -0
  206. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_cart_pens.json +0 -0
  207. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_checkout_payment.json +0 -0
  208. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_checkout_payment_scrolled.json +0 -0
  209. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_checkout_upsell.json +0 -0
  210. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_filters.json +0 -0
  211. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_home.json +0 -0
  212. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_order_review.json +0 -0
  213. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_order_review_scrolled.json +0 -0
  214. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_product.json +0 -0
  215. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_product_buybox.json +0 -0
  216. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_product_ids.json +0 -0
  217. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_results_after_review.json +0 -0
  218. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_results_offers.json +0 -0
  219. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_results_search.json +0 -0
  220. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_results_sorted.json +0 -0
  221. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_search_suggestions.json +0 -0
  222. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_sign_in.json +0 -0
  223. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_sort_price.json +0 -0
  224. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_sort_sheet.json +0 -0
  225. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_upi_pin_screen.json +0 -0
  226. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/bank_pin_numpw.json +0 -0
  227. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/blinkit_cart_pay.json +0 -0
  228. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/blinkit_search.json +0 -0
  229. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/chat_with_otp.json +0 -0
  230. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/phonepe_upi_pin.json +0 -0
  231. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/settings_display.json +0 -0
  232. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/shop_onboarding_continue.json +0 -0
  233. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/shop_order_summary_continue.json +0 -0
  234. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/shop_otp_boxes.json +0 -0
  235. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/shop_otp_caption.json +0 -0
  236. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/shop_qty_total_pay.json +0 -0
  237. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/phone_fakes.py +0 -0
  238. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_agent_loop.py +0 -0
  239. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_art.py +0 -0
  240. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_ask_user_node.py +0 -0
  241. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_background_distil.py +0 -0
  242. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_browsing.py +0 -0
  243. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_budget.py +0 -0
  244. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_call_budget.py +0 -0
  245. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_chat_fast_path.py +0 -0
  246. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_claw_bench.py +0 -0
  247. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_clipboard.py +0 -0
  248. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_codemap.py +0 -0
  249. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_compaction_bench.py +0 -0
  250. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_custom_endpoints.py +0 -0
  251. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_diffusion_retry.py +0 -0
  252. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_embed.py +0 -0
  253. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_embedding_backends.py +0 -0
  254. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_envfile.py +0 -0
  255. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_eval_honesty.py +0 -0
  256. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_eval_runner.py +0 -0
  257. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_evaluator_node.py +0 -0
  258. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_evicted_context.py +0 -0
  259. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_evidence.py +0 -0
  260. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_failures.py +0 -0
  261. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_health.py +0 -0
  262. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_hle_bench.py +0 -0
  263. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_home.py +0 -0
  264. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_inception_provider.py +0 -0
  265. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_inception_stream.py +0 -0
  266. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_langfuse_sync.py +0 -0
  267. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_lessons.py +0 -0
  268. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_lessons_cli.py +0 -0
  269. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_mapping.py +0 -0
  270. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_bench.py +0 -0
  271. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_embeddings.py +0 -0
  272. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_hashing.py +0 -0
  273. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_queue.py +0 -0
  274. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_retrieval.py +0 -0
  275. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_session.py +0 -0
  276. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_store.py +0 -0
  277. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_tokens.py +0 -0
  278. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_wiring.py +0 -0
  279. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_modes.py +0 -0
  280. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_native.py +0 -0
  281. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_no_import_shadowing.py +0 -0
  282. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_optional_vendor.py +0 -0
  283. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_output.py +0 -0
  284. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_overrides.py +0 -0
  285. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_app_notes.py +0 -0
  286. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_backend.py +0 -0
  287. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_digest.py +0 -0
  288. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_do.py +0 -0
  289. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_element_ids.py +0 -0
  290. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_fold.py +0 -0
  291. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_guard.py +0 -0
  292. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_learned_notes.py +0 -0
  293. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_page_corpus.py +0 -0
  294. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_prompt.py +0 -0
  295. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_seats.py +0 -0
  296. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_shopping_digest.py +0 -0
  297. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_pipeline_nodes.py +0 -0
  298. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_pipeline_run.py +0 -0
  299. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_profile.py +0 -0
  300. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_progress.py +0 -0
  301. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_prompt_tool_sync.py +0 -0
  302. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_provider_connection_detail.py +0 -0
  303. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_python_session.py +0 -0
  304. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_reload.py +0 -0
  305. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_research_router.py +0 -0
  306. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_retrieval_method.py +0 -0
  307. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_router.py +0 -0
  308. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_run_scoped_toolkit.py +0 -0
  309. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_screen.py +0 -0
  310. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_seat_outcomes.py +0 -0
  311. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_seed_lessons.py +0 -0
  312. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_serve.py +0 -0
  313. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_sessions.py +0 -0
  314. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_setup.py +0 -0
  315. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_swe_bench.py +0 -0
  316. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_temperature_learning.py +0 -0
  317. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_terminal_bench_adapter.py +0 -0
  318. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_tool_loop.py +0 -0
  319. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_tracing.py +0 -0
  320. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_tui.py +0 -0
  321. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_tui_progress.py +0 -0
  322. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_tui_setup.py +0 -0
  323. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_usage.py +0 -0
  324. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_vision_tool.py +0 -0
  325. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_walkthrough.py +0 -0
  326. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_workspace_session.py +0 -0
  327. {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_workspace_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: otto-cli-agent
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Otto: a terminal AI agent built around measured results -- one agent loop, a rubric-first evaluator, tiered memory, multi-vendor routing, and six benchmark harnesses.
5
5
  Project-URL: Repository, https://github.com/siddharth23P/otto_agent
6
6
  Project-URL: Issues, https://github.com/siddharth23P/otto_agent/issues
@@ -46,11 +46,12 @@ Requires-Dist: langchain>=1.4.0
46
46
  Requires-Dist: langfuse>=4.15.1
47
47
  Requires-Dist: langgraph>=1.2.11
48
48
  Requires-Dist: numpy>=1.26.2
49
- Requires-Dist: openpyxl>=3.1.5; sys_platform != 'android'
49
+ Requires-Dist: openpyxl>=3.1.5
50
50
  Requires-Dist: prompt-toolkit>=3.0.53
51
- Requires-Dist: python-docx>=1.1.2; sys_platform != 'android'
51
+ Requires-Dist: python-docx>=1.1.2
52
52
  Requires-Dist: python-dotenv>=1.2.3
53
- Requires-Dist: reportlab>=4.2; sys_platform != 'android'
53
+ Requires-Dist: python-pptx>=1.0.2
54
+ Requires-Dist: reportlab>=4.2
54
55
  Requires-Dist: textual>=8.2.8
55
56
  Requires-Dist: tiktoken>=0.14.0
56
57
  Requires-Dist: typer>=0.27.2
@@ -138,6 +138,7 @@ one interpreter per run), `OTTO_PYTHON_SESSION_MEMORY_MB` (that interpreter's
138
138
  address-space ceiling on Linux, default 8192, 0 for none),
139
139
  `OTTO_EMBEDDING_MODEL` (`provider:model`; local BGE is the floor),
140
140
  `OTTO_MODEL_PRICES` (a JSON file that overrides the price table),
141
+ `OTTO_PROMPT_CACHE=0` (turn off input-token caching, on by default for all four vendors),
141
142
  `OTTO_IGNORE_ROUTES=1` (use the shipped routing table untouched; evals do),
142
143
  `OTTO_BROWSER_PYTHON` (an interpreter with Playwright and `pyte`, which
143
144
  enables the browser and terminal tools), `OTTO_HOME` (where state lives,
@@ -11,7 +11,8 @@ and binds these through [agent/embed.py](../embed.py).
11
11
  | `backend.py` | `PhoneBackend`, the Protocol a host implements (tree, tap, type, press, swipe, scroll, screenshot, apps, launch, settings, install); `PhoneError` with a code and a hand-over flag; `JsonBackend`, the adapter over a bridge whose methods return JSON envelopes |
12
12
  | `digest.py` | the accessibility snapshot as bounded, inert text: one `[index] "label" role flags @x,y` line per element, password fields never shown; `find_node` resolves a text target (exact, then unique substring, else the candidates) |
13
13
  | `guard.py` + `assets/guard_rules.json` | the rules the phone enforces and this side pre-checks: denied packages, money words, sensitive-screen patterns (two signals required), pay words (never tappable), forward words that become pay words next to a checkout signal, commit words (only through `phone_commit`); a broken rules file is a `GuardRulesError` on every verdict |
14
- | `tools.py` | `phone_tools(backend)`: nine `ExtraTool`s, `PHONE_GUIDANCE`, and the standing tools a phone cannot run |
14
+ | `actions.py` | the phone's premapped actions as a searchable dictionary: `search`, `describe`; never listed in the prompt |
15
+ | `tools.py` | `phone_tools(backend)`: nine `ExtraTool`s, plus `phone_find` / `phone_action` when the bridge offers `actions()`; `PHONE_GUIDANCE`, and the standing tools a phone cannot run |
15
16
  | `notes.py` + `assets/app_notes/` | per-app notes shown after an app's screen the first time it is in front in a run: seeded `<package>.md` files (facts checked on a real phone; Amazon and Settings only) and notes learned by earlier runs, 800 characters in all; guidance only, never read by the guard |
16
17
 
17
18
  ## The tools
@@ -0,0 +1,111 @@
1
+ """The phone's premapped actions as a dictionary the agent searches.
2
+
3
+ The app (otto_android actions/ActionCatalog.kt) offers any number of actions -- set an alarm, write an
4
+ SMS, share a file -- each with typed fields. None of them is written into the prompt: the agent calls
5
+ `phone_find` with what it wants to do, reads the matching entries in full, then `phone_action` runs one
6
+ by name. The prompt stays the same size however many actions the phone has.
7
+
8
+ An entry, as the bridge sends it:
9
+ {"name": "alarm.set", "summary": "set an alarm in the clock app", "effect": "change|read|confirm",
10
+ "group": "clock", "keywords": ["wake", "alarm clock"],
11
+ "params": [{"name": "hour", "type": "integer", "required": true, "doc": "0-23", "min": 0, "max": 23,
12
+ "choices": [...]}]}
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ #: Most entries one search returns in full.
19
+ MAX_FOUND = 5
20
+
21
+ _WORD = re.compile(r"[a-z0-9]+")
22
+ #: Words that say nothing about which action is meant.
23
+ _FILLER = frozenset("a an the to for of on in my me please set up do can you with and or".split())
24
+
25
+
26
+ def clean_menu(menu: object) -> list[dict]:
27
+ """The entries that have a name, in the phone's order; anything else is dropped."""
28
+ if not isinstance(menu, list):
29
+ return []
30
+ return [a for a in menu if isinstance(a, dict) and isinstance(a.get("name"), str) and a["name"]]
31
+
32
+
33
+ def _words(text: str) -> list[str]:
34
+ return [w for w in _WORD.findall(text.lower()) if w not in _FILLER]
35
+
36
+
37
+ def _stem(word: str) -> str:
38
+ """Crude, but enough for "alarms"/"alarm", "sharing"/"share", "texted"/"text"."""
39
+ for suffix in ("ing", "ed", "es", "s"):
40
+ if len(word) > len(suffix) + 2 and word.endswith(suffix):
41
+ return word[: -len(suffix)]
42
+ return word
43
+
44
+
45
+ def search(menu: list[dict], query: str, limit: int = MAX_FOUND) -> list[dict]:
46
+ """The entries that best match `query`: a word in the name or the keywords counts most, then the
47
+ summary, then the group and the fields; ties keep the phone's order. An exact name wins outright."""
48
+ query = query.strip().lower()
49
+ for entry in menu:
50
+ if entry["name"].lower() == query:
51
+ return [entry]
52
+ wanted = {_stem(w) for w in _words(query)}
53
+ if not wanted:
54
+ return []
55
+ scored = []
56
+ for order, entry in enumerate(menu):
57
+ fields = entry.get("params") or []
58
+ places = (
59
+ (3, entry["name"]),
60
+ (3, " ".join(map(str, entry.get("keywords") or []))),
61
+ (2, str(entry.get("summary") or "")),
62
+ (1, str(entry.get("group") or "")),
63
+ (1, " ".join(f"{p.get('name', '')} {p.get('doc', '')} {' '.join(map(str, p.get('choices') or []))}"
64
+ for p in fields if isinstance(p, dict))),
65
+ )
66
+ score = 0
67
+ for weight, text in places:
68
+ have = {_stem(w) for w in _words(text)}
69
+ score += weight * len(wanted & have)
70
+ if score:
71
+ scored.append((-score, order, entry))
72
+ scored.sort(key=lambda s: s[:2])
73
+ # A match far weaker than the best is noise ("call dad" also names a volume stream).
74
+ floor = -scored[0][0] / 3 if scored else 0
75
+ return [entry for score, _, entry in scored[:limit] if -score >= floor]
76
+
77
+
78
+ def describe(entry: dict) -> str:
79
+ """One entry in full: how to call it, and what each field takes.
80
+
81
+ alarm.set -- set an alarm in the clock app
82
+ hour: integer 0..23, required
83
+ days: list of mon|tue|..., optional -- repeat on these days
84
+ """
85
+ you = " (the person finishes it: the phone is handed to them)" if entry.get("effect") == "confirm" else ""
86
+ lines = [f"{entry['name']} -- {entry.get('summary', '')}{you}".rstrip(" -")]
87
+ for p in entry.get("params") or []:
88
+ if not isinstance(p, dict):
89
+ continue
90
+ kind = {"string": "text", "string_list": "list of text"}.get(str(p.get("type")), str(p.get("type", "any")))
91
+ if p.get("choices"):
92
+ kind = ("list of " if p.get("type") == "string_list" else "") + "|".join(map(str, p["choices"]))
93
+ if p.get("min") is not None or p.get("max") is not None:
94
+ kind += f" {_num(p.get('min'))}..{_num(p.get('max'))}"
95
+ need = "required" if p.get("required", True) else "optional"
96
+ doc = f" -- {p['doc']}" if p.get("doc") else ""
97
+ lines.append(f" {p.get('name')}: {kind}, {need}{doc}")
98
+ if len(lines) == 1:
99
+ lines.append(" (no fields)")
100
+ return "\n".join(lines)
101
+
102
+
103
+ def _num(value: object) -> str:
104
+ if value is None:
105
+ return ""
106
+ return str(int(value)) if isinstance(value, float) and value.is_integer() else str(value)
107
+
108
+
109
+ def near(menu: list[dict], name: str) -> list[str]:
110
+ """Names to suggest for a name the phone does not have."""
111
+ return [e["name"] for e in search(menu, name.replace(".", " ").replace("_", " "), limit=3)]
@@ -34,7 +34,7 @@ from typing import Any, Protocol, runtime_checkable
34
34
  #: capability the device lacks (no accessibility service running, no
35
35
  #: screenshot permission); `timeout` a gesture or capture that never
36
36
  #: completed.
37
- ERROR_CODES = ("guard", "refused", "stale", "unsupported", "failed", "timeout")
37
+ ERROR_CODES = ("guard", "refused", "stale", "unsupported", "invalid", "failed", "timeout")
38
38
 
39
39
 
40
40
  class PhoneError(Exception):
@@ -150,6 +150,18 @@ class JsonBackend:
150
150
  def install(self, package: str = "", query: str = "") -> dict:
151
151
  return self._call("install", str(package or ""), str(query or ""))
152
152
 
153
+ # Optional, not part of PhoneBackend: the phone's own action registry (the Android app's
154
+ # actions/ActionCatalog.kt). A backend without it simply offers no phone_action tool.
155
+
156
+ def actions(self) -> list[dict]:
157
+ """[{"name", "summary", "effect": read|change|confirm, "params": [{"name", "type", ...}]}]."""
158
+ reply = self._call("actions")
159
+ return list(reply.get("actions") or []) if isinstance(reply, dict) else []
160
+
161
+ def run_action(self, name: str, args: dict) -> dict:
162
+ """{"done", "handed_over"?, "data"?}"""
163
+ return self._call("run_action", str(name), json.dumps(args or {}))
164
+
153
165
 
154
166
  def _unwrap(method: str, reply: Any) -> Any:
155
167
  """The `data` of an ok envelope, a PhoneError for an error envelope, and
@@ -42,6 +42,7 @@ from collections.abc import Callable
42
42
  from typing import Any
43
43
 
44
44
  from agent.phone import digest as _digest
45
+ from agent.phone import actions as _actions
45
46
  from agent.phone import guard
46
47
  from agent.phone import notes as _notes
47
48
  from agent.phone.backend import PhoneBackend, PhoneError
@@ -61,6 +62,8 @@ PHONE_DISABLED_STANDING_TOOLS: frozenset[str] = frozenset({
61
62
  #: Bound with the tools (agent/pipeline/toolkit.py `guidance=`).
62
63
  PHONE_GUIDANCE = (
63
64
  "You are working on the person's Android phone through phone_* tools. "
65
+ "The phone is the last resort: use phone_* only for steps that need the device, and make "
66
+ "files with make_document, never in an app. "
64
67
  "Look, act, look again: phone_screen shows what is on screen with a [number] per element; "
65
68
  "phone_act acts on one element by its text and shows the screen after. "
66
69
  "When you already know the next few steps (tap the search box, type the query, press enter -- the "
@@ -128,14 +131,13 @@ def _default_vision(question: str, data: bytes, media_type: str) -> str:
128
131
  """The `look` tool's path: the routed vision model, words back, vendor
129
132
  exceptions translated into Otto's own so the loop reads a failed call."""
130
133
  from agent.pipeline import tools as pt
131
- from agent.pipeline.vision import describe_image
132
- from agent.router.mapping import Task
134
+ from agent.pipeline.vision import describe_with_fallback
133
135
 
134
- llm = pt._get_router().chat_model(Task.VISION)
135
- try:
136
- return describe_image(llm, base64.b64encode(data).decode(), media_type, question)
137
- except Exception as exc:
138
- raise pt._translated(llm, exc) from exc
136
+ # Down the VISION chain (Gemini 3.8/3.7/3.6 Flash, then Claude, then GPT-5-mini) when a model
137
+ # cannot answer; one retry each, so an account out of credit costs seconds, not a minute.
138
+ answer, _ = describe_with_fallback(pt._get_router(), base64.b64encode(data).decode(), media_type,
139
+ question, max_retries=1)
140
+ return answer
139
141
 
140
142
 
141
143
  def phone_tools(backend: PhoneBackend, *, vision: Vision | None = None) -> list[ExtraTool]:
@@ -735,6 +737,54 @@ def phone_tools(backend: PhoneBackend, *, vision: Vision | None = None) -> list[
735
737
  except PhoneError as exc:
736
738
  return _failed(name, exc)
737
739
 
740
+ def phone_find(body: str) -> ToolResult:
741
+ name = "phone_find"
742
+ parsed = json_body(name, body)
743
+ if isinstance(parsed, ToolResult):
744
+ return parsed
745
+ query = str(parsed.get("query") or "").strip()
746
+ if not query:
747
+ groups: dict[str, list[str]] = {}
748
+ for entry in menu:
749
+ groups.setdefault(str(entry.get("group") or "other"), []).append(entry["name"])
750
+ listing = "\n".join(f"{group}: {', '.join(names)}" for group, names in groups.items())
751
+ return ToolResult(stdout=f"{len(menu)} actions; search one to see its fields.\n{listing}", stderr="", returncode=0)
752
+ found = _actions.search(menu, query)
753
+ if not found:
754
+ return ToolResult(stdout=f"no premapped action for {query!r} -- use the screen tools", stderr="", returncode=0)
755
+ text = "\n".join(_actions.describe(entry) for entry in found)
756
+ return ToolResult(stdout=text + '\nRun one with phone_action {"action": name, ...fields}.', stderr="", returncode=0)
757
+
758
+ def phone_action(body: str) -> ToolResult:
759
+ name = "phone_action"
760
+ parsed = json_body(name, body)
761
+ if isinstance(parsed, ToolResult):
762
+ return parsed
763
+ action = str(parsed.pop("action", "") or "")
764
+ entry = next((e for e in menu if e["name"] == action), None)
765
+ if entry is None:
766
+ hint = ", ".join(_actions.near(menu, action)) or "none close -- search with phone_find"
767
+ return _bad(name, f"the phone has no action {action!r}; nearest: {hint}")
768
+ if (app := str(parsed.get("app") or "")) and (why := guard.package_verdict(app, "")):
769
+ return _refuse(name, why)
770
+ try:
771
+ result = backend.run_action(action, parsed)
772
+ except PhoneError as exc:
773
+ failed = _failed(name, exc)
774
+ if exc.code == "invalid":
775
+ # The whole entry, so the next call can be right without another search.
776
+ failed = dataclasses.replace(failed, stderr=f"{failed.stderr}\n{_actions.describe(entry)}")
777
+ return failed
778
+ text = str(result.get("done") or "done")
779
+ if result.get("data") is not None:
780
+ text += "\n" + json.dumps(result["data"], ensure_ascii=False)[:MAX_ACTION_DATA]
781
+ if result.get("handed_over"):
782
+ # A message, call or event the person finishes: stop, and say what is ready for them.
783
+ return ToolResult(stdout=f"GUARD: {name}: {text}{HANDOVER_TAIL}", stderr="", returncode=0)
784
+ return ToolResult(stdout=text, stderr="", returncode=0)
785
+
786
+ menu = _action_menu(backend)
787
+
738
788
  tools = [
739
789
  ExtraTool("phone_screen", "The phone's screen as text: the app in front and every element with a "
740
790
  "[number], its text, role, flags and centre. Empty body {}.", phone_screen,
@@ -770,9 +820,45 @@ def phone_tools(backend: PhoneBackend, *, vision: Vision | None = None) -> list[
770
820
  "query": {"type": "string"}}},
771
821
  fold=_digest.fold_result),
772
822
  ]
823
+ # The phone's premapped actions, as a dictionary: searched, never listed in the prompt.
824
+ if menu:
825
+ tools[:0] = [
826
+ ExtraTool("phone_find", FIND_DESCRIPTION, phone_find, mutates=False,
827
+ schema={"type": "object", "properties": {"query": {"type": "string"}}}),
828
+ ExtraTool("phone_action", ACTION_DESCRIPTION, phone_action, mutates=False,
829
+ schema={"type": "object", "required": ["action"], "properties": {"action": {"type": "string"}}}),
830
+ ]
773
831
  return [dataclasses.replace(tool, call=_logged(tool.name, tool.call)) for tool in tools]
774
832
 
775
833
 
834
+ #: How much of an action's returned data (contacts found) goes back to the model.
835
+ MAX_ACTION_DATA = 1500
836
+
837
+
838
+ def _action_menu(backend) -> list[dict]:
839
+ """The phone's action registry, or [] when this phone has none (an older app, a fake)."""
840
+ lister = getattr(backend, "actions", None)
841
+ if lister is None:
842
+ return []
843
+ try:
844
+ menu = lister()
845
+ except Exception as exc: # noqa: BLE001 -- no registry is a phone without the tool, not a failed turn
846
+ logger.info("phone actions unavailable: %s", exc)
847
+ return []
848
+ return _actions.clean_menu(menu)
849
+
850
+
851
+ FIND_DESCRIPTION = (
852
+ "Search the phone's premapped actions (alarms, timers, calendar, torch, volume, media, DND, messages, calls, "
853
+ "contacts, sharing, links, maps, clipboard, notes, more) by what you want done; returns matches with their "
854
+ "fields. Search before reading screens. Empty query lists all."
855
+ )
856
+ ACTION_DESCRIPTION = (
857
+ "Run an action phone_find returned: {\"action\": name, ...its fields}. One marked 'the person finishes it' "
858
+ "opens the screen and hands the phone to them."
859
+ )
860
+
861
+
776
862
  #: How much of a body or a result one log line carries.
777
863
  MAX_LOGGED = 160
778
864
 
@@ -799,9 +885,9 @@ def _asked(body: str) -> str:
799
885
  if not isinstance(step, dict):
800
886
  shown.append("?")
801
887
  continue
802
- fields = {k: v for k, v in step.items() if k != "text"}
803
- if "text" in step:
804
- fields["text"] = f"<{len(str(step['text']))} chars>"
888
+ # What a person would write (a typed text, a message, a note) or whom it goes to is logged as its length.
889
+ private = ("text", "body", "subject", "notes", "to", "cc", "phone", "number", "query")
890
+ fields = {k: (f"<{len(str(v))} chars>" if k in private else v) for k, v in step.items()}
805
891
  shown.append(" ".join(f"{k}={_digest.inert_text(str(v), 60)}" for k, v in fields.items()))
806
892
  return " ; ".join(shown)[:MAX_LOGGED]
807
893
 
@@ -0,0 +1,323 @@
1
+ """Documents otto makes: Markdown into PDF, Word, Excel or PowerPoint, in this process.
2
+
3
+ The research route converts its report with a script on the workspace's interpreter, which a host
4
+ with code execution off (the Android app) does not have -- so on a phone there was no way to make a
5
+ file, and a request for one went to the phone's own apps instead (2026-09-17: "create a pdf from
6
+ this doc" drove My Files and Google Docs for minutes). These writers need no subprocess: pure-Python
7
+ libraries (reportlab, python-docx, openpyxl, python-pptx; lxml and Pillow have Android builds).
8
+
9
+ One small Markdown reader feeds all four: headings, paragraphs, bullet and numbered lists, tables
10
+ and fenced code. Emphasis and links are reduced to their text.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ FORMATS = ("pdf", "docx", "xlsx", "pptx", "md")
19
+ #: Where make_document writes, inside the workspace.
20
+ OUT_DIR = "documents"
21
+
22
+ _HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
23
+ _BULLET = re.compile(r"^\s*[-*+]\s+(.*)$")
24
+ _NUMBERED = re.compile(r"^\s*\d+[.)]\s+(.*)$")
25
+ _RULE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$")
26
+
27
+
28
+ @dataclass
29
+ class Block:
30
+ kind: str # heading | paragraph | bullets | numbered | table | code
31
+ text: str = ""
32
+ level: int = 0
33
+ items: list[str] = field(default_factory=list)
34
+ rows: list[list[str]] = field(default_factory=list)
35
+
36
+
37
+ def plain(text: str) -> str:
38
+ """Markdown emphasis, code spans and links reduced to their text."""
39
+ text = re.sub(r"\*\*(.+?)\*\*|__(.+?)__", lambda m: m.group(1) or m.group(2), text)
40
+ text = re.sub(r"(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?!\w)", r"\1", text)
41
+ text = re.sub(r"(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)", r"\1", text)
42
+ text = re.sub(r"!?\[([^\]]*)\]\([^)]*\)", r"\1", text)
43
+ return re.sub(r"`([^`]*)`", r"\1", text).strip()
44
+
45
+
46
+ def _cells(line: str) -> list[str]:
47
+ inner = line.strip()
48
+ inner = inner[1:] if inner.startswith("|") else inner
49
+ inner = inner[:-1] if inner.endswith("|") else inner
50
+ return [plain(c) for c in re.split(r"(?<!\\)\|", inner)]
51
+
52
+
53
+ def parse(markdown: str) -> list[Block]:
54
+ blocks: list[Block] = []
55
+ para: list[str] = []
56
+ fence: list[str] | None = None
57
+
58
+ def flush() -> None:
59
+ if para:
60
+ blocks.append(Block("paragraph", plain(" ".join(para))))
61
+ para.clear()
62
+
63
+ lines = markdown.replace("\r\n", "\n").split("\n")
64
+ i = 0
65
+ while i < len(lines):
66
+ line = lines[i]
67
+ if line.strip().startswith("```"):
68
+ if fence is None:
69
+ flush()
70
+ fence = []
71
+ else:
72
+ blocks.append(Block("code", "\n".join(fence)))
73
+ fence = None
74
+ i += 1
75
+ continue
76
+ if fence is not None:
77
+ fence.append(line)
78
+ i += 1
79
+ continue
80
+ s = line.rstrip()
81
+ if not s.strip():
82
+ flush()
83
+ elif m := _HEADING.match(s):
84
+ flush()
85
+ blocks.append(Block("heading", plain(m.group(2)), level=len(m.group(1))))
86
+ elif s.lstrip().startswith("|"):
87
+ flush()
88
+ rows = []
89
+ while i < len(lines) and lines[i].lstrip().startswith("|"):
90
+ if not _RULE.match(lines[i]):
91
+ rows.append(_cells(lines[i]))
92
+ i += 1
93
+ width = max(len(r) for r in rows)
94
+ blocks.append(Block("table", rows=[r + [""] * (width - len(r)) for r in rows]))
95
+ continue
96
+ elif (m := _BULLET.match(s)) or (n := _NUMBERED.match(s)):
97
+ flush()
98
+ kind = "bullets" if m else "numbered"
99
+ item = plain((m or n).group(1))
100
+ if blocks and blocks[-1].kind == kind and not blocks[-1].text:
101
+ blocks[-1].items.append(item)
102
+ else:
103
+ blocks.append(Block(kind, items=[item]))
104
+ else:
105
+ if blocks and blocks[-1].kind in ("bullets", "numbered") and not para and line.startswith(" "):
106
+ blocks[-1].items[-1] += " " + plain(s)
107
+ else:
108
+ para.append(s.strip())
109
+ i += 1
110
+ if fence is not None:
111
+ blocks.append(Block("code", "\n".join(fence)))
112
+ flush()
113
+ return blocks
114
+
115
+
116
+ def title_of(blocks: list[Block], fallback: str) -> str:
117
+ return next((b.text for b in blocks if b.kind == "heading"), fallback)
118
+
119
+
120
+ # -- writers -----------------------------------------------------------------------------------
121
+
122
+ def _docx(blocks: list[Block], target: Path, title: str) -> None:
123
+ from docx import Document
124
+
125
+ doc = Document()
126
+ doc.core_properties.title = title
127
+ for b in blocks:
128
+ if b.kind == "heading":
129
+ doc.add_heading(b.text, min(b.level - 1, 4) if b.level > 1 else 0)
130
+ elif b.kind == "paragraph":
131
+ doc.add_paragraph(b.text)
132
+ elif b.kind in ("bullets", "numbered"):
133
+ style = "List Bullet" if b.kind == "bullets" else "List Number"
134
+ for item in b.items:
135
+ doc.add_paragraph(item, style=style)
136
+ elif b.kind == "table":
137
+ table = doc.add_table(rows=len(b.rows), cols=len(b.rows[0]))
138
+ table.style = "Table Grid"
139
+ for r, row in enumerate(b.rows):
140
+ for c, value in enumerate(row):
141
+ cell = table.cell(r, c)
142
+ cell.text = value
143
+ if r == 0:
144
+ for run in cell.paragraphs[0].runs:
145
+ run.bold = True
146
+ elif b.kind == "code":
147
+ for code_line in b.text.split("\n"):
148
+ doc.add_paragraph(code_line, style="No Spacing")
149
+ doc.save(str(target))
150
+
151
+
152
+ def _pdf(blocks: list[Block], target: Path, title: str) -> None:
153
+ from xml.sax.saxutils import escape
154
+
155
+ from reportlab.lib import colors
156
+ from reportlab.lib.pagesizes import A4
157
+ from reportlab.lib.styles import getSampleStyleSheet
158
+ from reportlab.lib.units import cm
159
+ from reportlab.platypus import ListFlowable, ListItem, Paragraph, Preformatted, SimpleDocTemplate, Spacer, Table, TableStyle
160
+
161
+ styles = getSampleStyleSheet()
162
+ heading = {1: styles["Title"], 2: styles["Heading1"], 3: styles["Heading2"]}
163
+ body = styles["BodyText"]
164
+ story = []
165
+ for b in blocks:
166
+ if b.kind == "heading":
167
+ story.append(Paragraph(escape(b.text), heading.get(b.level, styles["Heading3"])))
168
+ elif b.kind == "paragraph":
169
+ story += [Paragraph(escape(b.text), body), Spacer(1, 0.2 * cm)]
170
+ elif b.kind in ("bullets", "numbered"):
171
+ story.append(ListFlowable([ListItem(Paragraph(escape(i), body)) for i in b.items],
172
+ bulletType="bullet" if b.kind == "bullets" else "1"))
173
+ story.append(Spacer(1, 0.2 * cm))
174
+ elif b.kind == "table":
175
+ data = [[Paragraph(escape(v), body) for v in row] for row in b.rows]
176
+ table = Table(data, repeatRows=1, hAlign="LEFT")
177
+ table.setStyle(TableStyle([
178
+ ("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
179
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#eeeeee")),
180
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
181
+ ]))
182
+ story += [table, Spacer(1, 0.3 * cm)]
183
+ elif b.kind == "code":
184
+ story.append(Preformatted(b.text, styles["Code"]))
185
+ SimpleDocTemplate(str(target), pagesize=A4, title=title, leftMargin=2 * cm, rightMargin=2 * cm,
186
+ topMargin=2 * cm, bottomMargin=2 * cm).build(story or [Paragraph("", body)])
187
+
188
+
189
+ def _number(value: str):
190
+ """A cell's value as a number when it reads as one ("1,200", "12.5", "40%"), else the text."""
191
+ s = value.strip().replace(",", "")
192
+ try:
193
+ if s.endswith("%"):
194
+ return float(s[:-1]) / 100
195
+ return int(s) if re.fullmatch(r"-?\d+", s) else float(s)
196
+ except ValueError:
197
+ return value
198
+
199
+
200
+ def _xlsx(blocks: list[Block], target: Path, title: str) -> None:
201
+ """Every table on a sheet of its own (named by the heading above it), numbers as numbers; any
202
+ other text on a "Notes" sheet."""
203
+ from openpyxl import Workbook
204
+ from openpyxl.styles import Font
205
+ from openpyxl.utils import get_column_letter
206
+
207
+ wb = Workbook()
208
+ wb.remove(wb.active)
209
+ used: set[str] = set()
210
+
211
+ def sheet(name: str):
212
+ base = re.sub(r"[\[\]:*?/\\]", " ", name).strip()[:28] or "Sheet"
213
+ unique, n = base, 2
214
+ while unique.lower() in used:
215
+ unique, n = f"{base[:25]}-{n}", n + 1
216
+ used.add(unique.lower())
217
+ return wb.create_sheet(unique)
218
+
219
+ last_heading = title
220
+ notes = []
221
+ for b in blocks:
222
+ if b.kind == "heading":
223
+ last_heading = b.text
224
+ notes.append([b.text])
225
+ elif b.kind == "table":
226
+ ws = sheet(last_heading)
227
+ for r, row in enumerate(b.rows):
228
+ ws.append(row if r == 0 else [_number(v) for v in row])
229
+ for cell in ws[1]:
230
+ cell.font = Font(bold=True)
231
+ ws.freeze_panes = "A2"
232
+ for c in range(1, len(b.rows[0]) + 1):
233
+ width = max(len(str(ws.cell(r, c).value or "")) for r in range(1, ws.max_row + 1))
234
+ ws.column_dimensions[get_column_letter(c)].width = min(max(10, width + 2), 60)
235
+ elif b.kind == "paragraph" or b.kind == "code":
236
+ notes.append([b.text])
237
+ else:
238
+ notes += [[("• " if b.kind == "bullets" else f"{k}. ") + item] for k, item in enumerate(b.items, 1)]
239
+ has_text = any(b.kind not in ("heading", "table") for b in blocks)
240
+ if has_text or (notes and not wb.sheetnames):
241
+ ws = sheet("Notes")
242
+ for row in notes:
243
+ ws.append(row)
244
+ ws.column_dimensions["A"].width = 100
245
+ if not wb.sheetnames:
246
+ wb.create_sheet("Sheet")
247
+ wb.save(str(target))
248
+
249
+
250
+ def _pptx(blocks: list[Block], target: Path, title: str) -> None:
251
+ """A title slide, then a slide per heading: its paragraphs and list items as bullets (at most
252
+ eight a slide, the rest on continuation slides), and a table as a table."""
253
+ from pptx import Presentation
254
+ from pptx.util import Inches, Pt
255
+
256
+ prs = Presentation()
257
+ prs.slide_width, prs.slide_height = Inches(13.333), Inches(7.5)
258
+ first = prs.slides.add_slide(prs.slide_layouts[0])
259
+ first.shapes.title.text = title
260
+ subtitle = next((b.text for b in blocks if b.kind == "paragraph"), "")
261
+ if len(first.placeholders) > 1:
262
+ first.placeholders[1].text = subtitle[:200]
263
+
264
+ slides: list[tuple[str, list]] = []
265
+ current: tuple[str, list] | None = None
266
+ for b in blocks:
267
+ if b.kind == "heading":
268
+ if b.text == title and current is None:
269
+ continue
270
+ current = (b.text, [])
271
+ slides.append(current)
272
+ continue
273
+ if current is None:
274
+ current = (title, [])
275
+ slides.append(current)
276
+ if b.kind == "paragraph" and b.text != subtitle:
277
+ current[1].append(b.text)
278
+ elif b.kind in ("bullets", "numbered"):
279
+ current[1].extend(b.items)
280
+ elif b.kind == "table":
281
+ current[1].append(b)
282
+ elif b.kind == "code":
283
+ current[1].extend(b.text.split("\n")[:8])
284
+
285
+ for heading, items in slides:
286
+ bullets = [i for i in items if isinstance(i, str)]
287
+ tables = [i for i in items if isinstance(i, Block)]
288
+ chunks = [bullets[k:k + 8] for k in range(0, len(bullets), 8)] or ([[]] if not tables else [])
289
+ for n, chunk in enumerate(chunks):
290
+ slide = prs.slides.add_slide(prs.slide_layouts[1])
291
+ slide.shapes.title.text = heading if n == 0 else f"{heading} (cont.)"
292
+ frame = slide.placeholders[1].text_frame
293
+ for k, text in enumerate(chunk):
294
+ para = frame.paragraphs[0] if k == 0 else frame.add_paragraph()
295
+ para.text = text[:300]
296
+ para.font.size = Pt(20 if len(chunk) <= 5 else 16)
297
+ for table_block in tables:
298
+ slide = prs.slides.add_slide(prs.slide_layouts[5])
299
+ slide.shapes.title.text = heading
300
+ rows, cols = len(table_block.rows), len(table_block.rows[0])
301
+ shape = slide.shapes.add_table(rows, cols, Inches(0.5), Inches(1.5), Inches(12.3), Inches(0.4) * rows)
302
+ for r, row in enumerate(table_block.rows):
303
+ for c, value in enumerate(row):
304
+ shape.table.cell(r, c).text = value
305
+ shape.table.cell(r, c).text_frame.paragraphs[0].font.size = Pt(14)
306
+ prs.save(str(target))
307
+
308
+
309
+ WRITERS = {"docx": _docx, "pdf": _pdf, "xlsx": _xlsx, "pptx": _pptx}
310
+
311
+
312
+ def write(markdown: str, target: Path, fmt: str, title: str = "") -> Path:
313
+ """`markdown` as a `fmt` file at `target`. Raises ValueError for an unknown format, and the
314
+ writer's own error (a missing library, a bad table) otherwise."""
315
+ if fmt not in FORMATS:
316
+ raise ValueError(f"no {fmt!r} format; choose one of {', '.join(FORMATS)}")
317
+ target.parent.mkdir(parents=True, exist_ok=True)
318
+ if fmt == "md":
319
+ target.write_text(markdown, encoding="utf-8")
320
+ return target
321
+ blocks = parse(markdown)
322
+ WRITERS[fmt](blocks, target, title or title_of(blocks, target.stem))
323
+ return target