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.
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/PKG-INFO +5 -4
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/README.md +1 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/README.md +2 -1
- otto_cli_agent-0.1.4/agent/phone/actions.py +111 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/backend.py +13 -1
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/tools.py +96 -10
- otto_cli_agent-0.1.4/agent/pipeline/documents.py +323 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/nodes.py +70 -23
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/research.py +19 -5
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/tools.py +48 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/usage.py +9 -1
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/vision.py +34 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/anthropic_provider.py +10 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/base.py +30 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/gemini_provider.py +4 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/inception_provider.py +35 -16
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/openai_provider.py +10 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/mapping.py +23 -36
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/router.py +17 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/pyproject.toml +8 -7
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_automap.py +2 -2
- otto_cli_agent-0.1.4/tests/test_documents.py +133 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_model_policies.py +10 -8
- otto_cli_agent-0.1.4/tests/test_phone_actions.py +213 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_call_budget.py +5 -2
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_decision.py +60 -5
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_tools.py +5 -1
- otto_cli_agent-0.1.4/tests/test_prompt_caching.py +108 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_research_workflow.py +8 -3
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_tools_stubs.py +1 -1
- otto_cli_agent-0.1.4/tests/test_vision_fallback.py +112 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/uv.lock +33 -7
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.gitattributes +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.github/workflows/publish.yml +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.github/workflows/tests.yml +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.gitignore +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/.python-version +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/CODE_OF_CONDUCT.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/CONTRIBUTING.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/LICENSE +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/SECURITY.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/__init__.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/__init__.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/art.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/chat.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/clipboard.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/context.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/doctor.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/errors.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/eval.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/eval_claw.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/eval_compaction.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/eval_hle.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/eval_memory.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/eval_swe.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/lessons.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/main.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/modals.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/models.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/output.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/route.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/serve.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/sessions.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/setup_screen.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/shell.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/tui.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/ui.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/cli/usage_panel.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/config/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/config/__init__.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/config/envfile.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/config/home.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/embed.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/__init__.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/claw_bench.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/compaction_bench.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/data/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/data/claw/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/data/claw/llm_judge-gemini.patch +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/data/claw/otto.yaml +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/failures.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/code_01.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/code_02.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/code_03.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/code_04.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/code_05.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/code_06.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/math_01.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/math_02.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/math_03.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/math_04.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/math_05.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/math_06.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_gcp_01.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_ksp_01.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_math_binpacking_01.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_math_clique_01.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_math_setcover_01.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_math_subsetsum_01.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_tsp_01.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/golden/nphard_tsp_02.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/hle_bench.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/langfuse_sync.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/memory_bench.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/runner.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/single_agent.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/swe_bench.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/eval/terminal_bench.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/__init__.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/embeddings.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/hashing.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/lessons.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/queue.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/retrieval.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/session.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/sessions.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/store.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/tokens.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/memory/wiring.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/__init__.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/assets/app_notes/com.android.settings.md +0 -0
- {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
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/assets/guard_pages.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/assets/guard_rules.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/digest.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/guard.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/phone/notes.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/__init__.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/_python_session_shim.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/browsing.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/budget.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/codemap.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/evidence.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/execution.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/modes.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/native.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/pricing.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/profile.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/progress.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/python_session.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/rag.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/run.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/screen.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/state.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/toolkit.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/tracing.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/walkthrough.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/pipeline/workspace.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/__init__.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/automap.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/health.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/__init__.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/custom.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/retired.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/llm_provider/temperature.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/outcomes.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/overrides.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/reload.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/router/setup.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/server/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/server/__init__.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/server/app.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/server/protocol.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/agent/server/proxy.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/containers/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/containers/otto-desktop/Dockerfile +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/containers/otto-desktop/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/containers/otto-desktop/start-desktop.sh +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/debug_pipeline.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/HISTORY.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/RESEARCH.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/_config.yml +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/design/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/design/tiered-memory.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/index.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/codebase-code-map.png +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/exercise-todo-cli.png +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/otto-demo.gif +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/palette-doctor-route.png +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/run-trace-and-judge.png +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/session-resumed-thinking.png +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/sessions-picker.png +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/setup-screen.png +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/media/social-preview.png +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/docs/pypi.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/scripts/phone_page_fixture.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/scripts/phone_page_manifest.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/README.md +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/conftest.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_address_form_pincode.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_address_step.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_buy_now_sheet.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_cart.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_cart_pens.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_checkout_payment.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_checkout_payment_scrolled.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_checkout_upsell.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_filters.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_home.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_order_review.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_order_review_scrolled.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_product.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_product_buybox.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_product_ids.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_results_after_review.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_results_offers.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_results_search.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_results_sorted.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_search_suggestions.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_sign_in.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_sort_price.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_sort_sheet.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/amazon_upi_pin_screen.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/bank_pin_numpw.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/blinkit_cart_pay.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/blinkit_search.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/chat_with_otp.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/phonepe_upi_pin.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/settings_display.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/shop_onboarding_continue.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/shop_order_summary_continue.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/shop_otp_boxes.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/shop_otp_caption.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/fixtures/phone_pages/shop_qty_total_pay.json +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/phone_fakes.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_agent_loop.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_art.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_ask_user_node.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_background_distil.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_browsing.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_budget.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_call_budget.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_chat_fast_path.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_claw_bench.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_clipboard.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_codemap.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_compaction_bench.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_custom_endpoints.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_diffusion_retry.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_embed.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_embedding_backends.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_envfile.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_eval_honesty.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_eval_runner.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_evaluator_node.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_evicted_context.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_evidence.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_failures.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_health.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_hle_bench.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_home.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_inception_provider.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_inception_stream.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_langfuse_sync.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_lessons.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_lessons_cli.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_mapping.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_bench.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_embeddings.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_hashing.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_queue.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_retrieval.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_session.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_store.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_tokens.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_memory_wiring.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_modes.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_native.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_no_import_shadowing.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_optional_vendor.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_output.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_overrides.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_app_notes.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_backend.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_digest.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_do.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_element_ids.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_fold.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_guard.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_learned_notes.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_page_corpus.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_prompt.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_seats.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_phone_shopping_digest.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_pipeline_nodes.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_pipeline_run.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_profile.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_progress.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_prompt_tool_sync.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_provider_connection_detail.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_python_session.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_reload.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_research_router.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_retrieval_method.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_router.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_run_scoped_toolkit.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_screen.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_seat_outcomes.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_seed_lessons.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_serve.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_sessions.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_setup.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_swe_bench.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_temperature_learning.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_terminal_bench_adapter.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_tool_loop.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_tracing.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_tui.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_tui_progress.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_tui_setup.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_usage.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_vision_tool.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_walkthrough.py +0 -0
- {otto_cli_agent-0.1.2 → otto_cli_agent-0.1.4}/tests/test_workspace_session.py +0 -0
- {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.
|
|
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
|
|
49
|
+
Requires-Dist: openpyxl>=3.1.5
|
|
50
50
|
Requires-Dist: prompt-toolkit>=3.0.53
|
|
51
|
-
Requires-Dist: python-docx>=1.1.2
|
|
51
|
+
Requires-Dist: python-docx>=1.1.2
|
|
52
52
|
Requires-Dist: python-dotenv>=1.2.3
|
|
53
|
-
Requires-Dist:
|
|
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
|
-
| `
|
|
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
|
|
132
|
-
from agent.router.mapping import Task
|
|
134
|
+
from agent.pipeline.vision import describe_with_fallback
|
|
133
135
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|