ayder-cli 2.2.7__tar.gz → 2.2.9__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.
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/PKG-INFO +1 -1
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/config.toml.example +14 -12
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/pyproject.toml +1 -1
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/config.py +24 -1
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama.py +106 -3
- ayder_cli-2.2.9/src/ayder_cli/tools/builtins/filesystem.py +383 -0
- ayder_cli-2.2.9/src/ayder_cli/tools/builtins/search.py +533 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/search_definitions.py +20 -8
- ayder_cli-2.2.9/src/ayder_cli/tui/rendering.py +12 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/widgets.py +13 -11
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_config_v2.py +33 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_provider_integration.py +109 -0
- ayder_cli-2.2.9/tests/tools/test_file_editor_hardening.py +155 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_impl.py +15 -7
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_impl_coverage.py +4 -2
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_search_codebase.py +3 -2
- ayder_cli-2.2.9/tests/tools/test_search_hardening.py +381 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_status_bar_badges.py +8 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_widgets.py +52 -1
- ayder_cli-2.2.7/src/ayder_cli/tools/builtins/filesystem.py +0 -306
- ayder_cli-2.2.7/src/ayder_cli/tools/builtins/search.py +0 -335
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/.github/workflows/ci.yml +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/.github/workflows/python-package.yml +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/.gitignore +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/.python-version +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/AGENTS.md +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/LICENSE +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/README.md +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/PROJECT_STRUCTURE.md +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/README_AGENT_WORKFLOW.md +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/README_OLLAMA.md +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/cc.png +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/superpowers/plans/2026-06-26-file-editor-improvements.md +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/superpowers/plans/2026-06-26-session-save-resume.md +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/superpowers/specs/2026-06-26-file-editor-improvements-design.md +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/superpowers/specs/2026-06-26-session-save-resume-design.md +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/__main__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/callbacks.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/config.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/registry.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/run.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/runner.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/tool.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/worktree.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/application/README.md +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/application/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/application/execution_policy.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/application/message_contract.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/application/runtime_factory.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/application/validation.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/cli.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/cli_callbacks.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/cli_runner.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/console.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/cache_monitor.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/config_migration.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/context.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/context_manager.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/context_manager_factory.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/default_context_manager.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/ollama_context_manager.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/result.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/session.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/logging_config.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/loops/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/loops/chat_loop.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/parser.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/process_manager.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/prompts.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/base.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/claude.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/deepseek.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/gemini.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/glm.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/_errors.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/base.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/deepseek.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/generic_native.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/generic_xml.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/matrix.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/minimax.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/qwen3.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/registry.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_inspector.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/openai.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/qwen.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/orchestrator.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/retry.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/services/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/services/interactions.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/themes/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/themes/ayder.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/themes/claude.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/background_process_tool.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/context.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/context_definitions.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/filesystem_definitions.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/note_tool.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/notes.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/notes_definitions.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/process_manager_definitions.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/shell.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/shell_definitions.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/skill.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/skill_definitions.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/task_definitions.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/task_tool.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/tasks.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/utils_tools.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/utils_tools_definitions.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/web.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/web_definitions.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/definition.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/execution.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/hooks.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/normalization.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/plugin_api.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/plugin_github.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/plugin_manager.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/plugin_status.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/registry.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/schemas.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/utils.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/adapter.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/app.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/commands.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/helpers.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/keybindings.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/parser.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/screens.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/theme_manager.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/types.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/ui.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/version.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/COVERAGE.md +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_agent_consolidated.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_agent_tools.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_callbacks.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_config.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_integration.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_queue.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_registry.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_run.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_runner.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_runner_provider_unavailable.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_tool.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_worktree_helpers.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_worktree_isolation.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/application/test_message_contract.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/application/test_runtime_factory.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/client/test_main.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/convergence/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/convergence/test_execution_policy_parity.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/convergence/test_runtime_wiring.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/convergence/test_validation_path.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_cache_monitor.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_config.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_config_coverage.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_config_migration.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_config_provider_logging.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_config_retry.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_context_manager_factory.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_default_context_manager.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_default_context_manager_should_compact.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_ollama_context_manager.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_ollama_context_manager_cache_monitor.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_ollama_context_manager_max_history.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_parameter_aliasing.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_parser.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_session.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/loops/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/loops/test_base.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/loops/test_chat_loop_hook.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/loops/test_chat_loop_tool_truncation.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_base.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_deepseek.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_errors.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_fallback.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_generic_native.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_generic_xml.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_matrix.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_minimax.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_qwen3.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_registry.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_glm_async.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_ollama_inspector.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_ollama_native.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_ollama_xml.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_ollama_xml_autoroute.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_ollama_xml_usage.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_orchestrator_capabilities.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_qwen_async.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_retry_classifier.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_retry_integration.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_retrying_provider.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/services/test_boundary.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/services/test_interaction_sink.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_cli.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_cli_callbacks.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_context.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_env_manager.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_logging_config.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_notes.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_process_manager.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_tasks.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_agent_notes.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_background_process_tool.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_bash_tool.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_definition_discovery.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_di_injection.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_note_tool.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_notes_api.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_path_security.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_plugin_api.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_plugin_github.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_plugin_loading.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_plugin_manager.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_plugin_status.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_registry.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_registry_coverage.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_result.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_schemas.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_skill_tool.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_task_tool.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_utils.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_web.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/__init__.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_agent_list_screen.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_do_clear_coordination.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_follow_mode.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_help_screen.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_input_box.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_keybindings.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_multi_select_screen.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_multiline_input.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_paste.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_pending_compact_consumer.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_pull_delivery.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_resume.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_themes.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_turn_consumer.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_confirm_screen.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_diff_preview.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_plugin_autoenable.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_provider_switch.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_run_tui_startup.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_assistant_sanitization.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_chat_loop.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_commands_agent.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_commands_logging.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_commands_plugin.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_commands_provider.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_commands_skill.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_helpers.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_temporal_command.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_widgets.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_ui.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_ui_coverage.py +0 -0
- {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_ui_verbose.py +0 -0
|
@@ -48,11 +48,12 @@
|
|
|
48
48
|
# `export OLLAMA_API_KEY="<key>"`. The api_key config field is INERT on the
|
|
49
49
|
# native path, so a missing env var ⇒ 401 Unauthorized.
|
|
50
50
|
#
|
|
51
|
-
# NOTE on `think`: on the native `ollama` driver, ayder
|
|
52
|
-
# (think=
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
51
|
+
# NOTE on `think`: on the native `ollama` driver, ayder defaults thinking ON
|
|
52
|
+
# (think = true) and forwards either a boolean or Ollama's "low",
|
|
53
|
+
# "medium", "high" levels. If a selected model rejects thinking mode,
|
|
54
|
+
# ayder retries that request once with think=false. Set `think = false`
|
|
55
|
+
# in an [llm.*] profile for known non-thinking models. The `openai`
|
|
56
|
+
# driver sends no reasoning-effort parameter at all.
|
|
56
57
|
#
|
|
57
58
|
# NOTE on tuning knobs — which actually take effect depends on the DRIVER. These
|
|
58
59
|
# agents run on the native `ollama` driver, which ignores most of them:
|
|
@@ -60,17 +61,16 @@
|
|
|
60
61
|
# • max_output_tokens — NOT sent on the ollama path (Ollama sizes
|
|
61
62
|
# generation itself); honored only by the
|
|
62
63
|
# openai-family drivers as max_tokens. Default 4096.
|
|
63
|
-
# • think —
|
|
64
|
-
# sends
|
|
64
|
+
# • think — sent on the ollama path (default true; accepts
|
|
65
|
+
# false or "low"/"medium"/"high"); openai sends
|
|
66
|
+
# none.
|
|
65
67
|
# • num_ctx — on ollama it is provisional only (the real window
|
|
66
68
|
# is auto-detected from /api/show); on the openai
|
|
67
69
|
# path it sizes the context-manager budget.
|
|
68
70
|
# Default 65536.
|
|
69
|
-
# The per-agent temperature / max_output_tokens
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
# profile. To actually tune these, use the `openai` driver — see
|
|
73
|
-
# [llm.openai] below.
|
|
71
|
+
# The per-agent temperature / max_output_tokens values below are largely
|
|
72
|
+
# INERT on this Ollama pipeline; per-agent `think` is applied when the
|
|
73
|
+
# agent uses the native ollama driver.
|
|
74
74
|
# =============================================================================
|
|
75
75
|
|
|
76
76
|
config_version = "2.0"
|
|
@@ -154,6 +154,7 @@ driver = "ollama"
|
|
|
154
154
|
base_url = "http://localhost:11434" # route :cloud models through the LOCAL daemon (auth via ~/.ollama/id_ed25519, one-time `ollama signin`)
|
|
155
155
|
model = "deepseek-v4-pro:cloud" # orchestrator default; per-agent `model` overrides this
|
|
156
156
|
num_ctx = 131072
|
|
157
|
+
think = true # default; set false for known non-thinking models
|
|
157
158
|
# To hit ollama.com directly instead, swap the line above for:
|
|
158
159
|
# base_url = "https://ollama.com" # no /v1; then `export OLLAMA_API_KEY=<key>` (the api_key field is inert on this path)
|
|
159
160
|
|
|
@@ -162,6 +163,7 @@ driver = "ollama"
|
|
|
162
163
|
base_url = "http://localhost:11434"
|
|
163
164
|
model = "qwen3.6:35b-a3b-coding-nvfp4"
|
|
164
165
|
num_ctx = 32768 # keep modest: local VRAM is the constraint
|
|
166
|
+
think = true # default; set false if the selected model rejects thinking
|
|
165
167
|
|
|
166
168
|
# Budget/throughput alternative — swap any cloud agent's `provider` to this, or
|
|
167
169
|
# point a duplicate reviewer at it for cheap parallel passes.
|
|
@@ -2,7 +2,7 @@ import logging
|
|
|
2
2
|
import tomllib
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
5
|
-
from typing import Any, Callable, Dict
|
|
5
|
+
from typing import Any, Callable, Dict, Literal
|
|
6
6
|
|
|
7
7
|
logger = logging.getLogger(__name__)
|
|
8
8
|
|
|
@@ -319,6 +319,10 @@ class Config(BaseModel):
|
|
|
319
319
|
max_history_messages: int = Field(default=0)
|
|
320
320
|
prompt: str = Field(default="STANDARD")
|
|
321
321
|
chat_protocol: str = Field(default="ollama")
|
|
322
|
+
# Ollama thinking mode. Defaults on because most local models in the
|
|
323
|
+
# primary path support it; set ``think = false`` in an [llm.*] profile for
|
|
324
|
+
# models that do not. Ollama also accepts "low", "medium", and "high".
|
|
325
|
+
think: bool | Literal["low", "medium", "high"] | None = Field(default=True)
|
|
322
326
|
stop_sequences: list[str] = Field(default_factory=list)
|
|
323
327
|
tool_tags: list[str] = Field(default_factory=lambda: ["core", "metadata"])
|
|
324
328
|
temporal: TemporalConfig = Field(default_factory=TemporalConfig)
|
|
@@ -459,6 +463,25 @@ class Config(BaseModel):
|
|
|
459
463
|
raise ValueError("max_history_messages must be non-negative (0 = unlimited)")
|
|
460
464
|
return v
|
|
461
465
|
|
|
466
|
+
@field_validator("think", mode="before")
|
|
467
|
+
@classmethod
|
|
468
|
+
def validate_think(cls, v: Any) -> bool | Literal["low", "medium", "high"] | None:
|
|
469
|
+
if v is None or isinstance(v, bool):
|
|
470
|
+
return v
|
|
471
|
+
if isinstance(v, str):
|
|
472
|
+
normalized = v.strip().lower()
|
|
473
|
+
if normalized in {"true", "yes", "on", "1"}:
|
|
474
|
+
return True
|
|
475
|
+
if normalized in {"false", "no", "off", "0"}:
|
|
476
|
+
return False
|
|
477
|
+
if normalized == "low":
|
|
478
|
+
return "low"
|
|
479
|
+
if normalized == "medium":
|
|
480
|
+
return "medium"
|
|
481
|
+
if normalized == "high":
|
|
482
|
+
return "high"
|
|
483
|
+
raise ValueError("think must be true, false, low, medium, high, or null")
|
|
484
|
+
|
|
462
485
|
@field_validator("agent_timeout")
|
|
463
486
|
@classmethod
|
|
464
487
|
def validate_agent_timeout(cls, v: int) -> int:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Ollama provider routed through per-family chat drivers."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
from typing import Any, AsyncGenerator, Dict, List, Optional
|
|
4
|
+
from typing import Any, AsyncGenerator, Dict, List, Literal, Optional
|
|
5
5
|
|
|
6
6
|
from loguru import logger
|
|
7
7
|
from ollama import AsyncClient
|
|
@@ -19,6 +19,8 @@ from ayder_cli.providers.impl.ollama_drivers.base import ChatDriver, DriverMode
|
|
|
19
19
|
from ayder_cli.providers.impl.ollama_drivers.registry import DriverRegistry
|
|
20
20
|
from ayder_cli.providers.impl.ollama_inspector import OllamaInspector
|
|
21
21
|
|
|
22
|
+
ThinkOption = bool | Literal["low", "medium", "high"] | None
|
|
23
|
+
|
|
22
24
|
|
|
23
25
|
class OllamaProvider(AIProvider):
|
|
24
26
|
"""Native wire-protocol provider for local Ollama models.
|
|
@@ -116,6 +118,50 @@ class OllamaProvider(AIProvider):
|
|
|
116
118
|
):
|
|
117
119
|
yield chunk
|
|
118
120
|
|
|
121
|
+
def _configured_think(self) -> ThinkOption:
|
|
122
|
+
"""Return the configured Ollama think option.
|
|
123
|
+
|
|
124
|
+
``Config`` defaults this to True. Tests often pass loose mocks without a
|
|
125
|
+
real ``think`` field; those should behave like the real default.
|
|
126
|
+
"""
|
|
127
|
+
value = getattr(self.config, "think", True)
|
|
128
|
+
if value is None or isinstance(value, bool):
|
|
129
|
+
return value
|
|
130
|
+
if isinstance(value, str):
|
|
131
|
+
normalized = value.strip().lower()
|
|
132
|
+
if normalized in {"true", "yes", "on", "1"}:
|
|
133
|
+
return True
|
|
134
|
+
if normalized in {"false", "no", "off", "0"}:
|
|
135
|
+
return False
|
|
136
|
+
if normalized == "low":
|
|
137
|
+
return "low"
|
|
138
|
+
if normalized == "medium":
|
|
139
|
+
return "medium"
|
|
140
|
+
if normalized == "high":
|
|
141
|
+
return "high"
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _is_unsupported_thinking_error(exc: BaseException) -> bool:
|
|
146
|
+
"""Return True when Ollama rejected the requested thinking mode."""
|
|
147
|
+
message = str(exc).lower()
|
|
148
|
+
if "think" not in message and "thinking" not in message:
|
|
149
|
+
return False
|
|
150
|
+
return any(
|
|
151
|
+
marker in message
|
|
152
|
+
for marker in (
|
|
153
|
+
"not support",
|
|
154
|
+
"unsupported",
|
|
155
|
+
"invalid",
|
|
156
|
+
"unknown field",
|
|
157
|
+
"unrecognized",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _chunk_committed(chunk: NormalizedStreamChunk) -> bool:
|
|
163
|
+
return bool(chunk.content or chunk.reasoning or chunk.tool_calls)
|
|
164
|
+
|
|
119
165
|
async def _stream_with_driver(
|
|
120
166
|
self,
|
|
121
167
|
driver: ChatDriver,
|
|
@@ -145,6 +191,34 @@ class OllamaProvider(AIProvider):
|
|
|
145
191
|
model: str,
|
|
146
192
|
tools: list[dict[str, Any]] | None,
|
|
147
193
|
options: dict[str, Any] | None,
|
|
194
|
+
) -> AsyncGenerator[NormalizedStreamChunk, None]:
|
|
195
|
+
think = self._configured_think()
|
|
196
|
+
committed = False
|
|
197
|
+
try:
|
|
198
|
+
async for chunk in self._stream_native_once(
|
|
199
|
+
messages, model, tools, options, think
|
|
200
|
+
):
|
|
201
|
+
if self._chunk_committed(chunk):
|
|
202
|
+
committed = True
|
|
203
|
+
yield chunk
|
|
204
|
+
except BaseException as exc:
|
|
205
|
+
if committed or think is False or not self._is_unsupported_thinking_error(exc):
|
|
206
|
+
raise
|
|
207
|
+
logger.info(
|
|
208
|
+
f"Ollama model {model!r} rejected think={think!r}; retrying with think=False"
|
|
209
|
+
)
|
|
210
|
+
async for chunk in self._stream_native_once(
|
|
211
|
+
messages, model, tools, options, False
|
|
212
|
+
):
|
|
213
|
+
yield chunk
|
|
214
|
+
|
|
215
|
+
async def _stream_native_once(
|
|
216
|
+
self,
|
|
217
|
+
messages: list[dict[str, Any]],
|
|
218
|
+
model: str,
|
|
219
|
+
tools: list[dict[str, Any]] | None,
|
|
220
|
+
options: dict[str, Any] | None,
|
|
221
|
+
think: ThinkOption,
|
|
148
222
|
) -> AsyncGenerator[NormalizedStreamChunk, None]:
|
|
149
223
|
# Let Ollama size the context window from the model itself (recent
|
|
150
224
|
# versions do this automatically); do not force num_ctx.
|
|
@@ -156,7 +230,7 @@ class OllamaProvider(AIProvider):
|
|
|
156
230
|
messages=ollama_messages,
|
|
157
231
|
tools=ollama_tools,
|
|
158
232
|
keep_alive=-1,
|
|
159
|
-
think=
|
|
233
|
+
think=think,
|
|
160
234
|
stream=True,
|
|
161
235
|
)
|
|
162
236
|
|
|
@@ -202,6 +276,35 @@ class OllamaProvider(AIProvider):
|
|
|
202
276
|
model: str,
|
|
203
277
|
tools: list[dict[str, Any]] | None,
|
|
204
278
|
options: dict[str, Any] | None,
|
|
279
|
+
) -> AsyncGenerator[NormalizedStreamChunk, None]:
|
|
280
|
+
think = self._configured_think()
|
|
281
|
+
committed = False
|
|
282
|
+
try:
|
|
283
|
+
async for chunk in self._stream_in_content_once(
|
|
284
|
+
driver, messages, model, tools, options, think
|
|
285
|
+
):
|
|
286
|
+
if self._chunk_committed(chunk):
|
|
287
|
+
committed = True
|
|
288
|
+
yield chunk
|
|
289
|
+
except BaseException as exc:
|
|
290
|
+
if committed or think is False or not self._is_unsupported_thinking_error(exc):
|
|
291
|
+
raise
|
|
292
|
+
logger.info(
|
|
293
|
+
f"Ollama model {model!r} rejected think={think!r}; retrying with think=False"
|
|
294
|
+
)
|
|
295
|
+
async for chunk in self._stream_in_content_once(
|
|
296
|
+
driver, messages, model, tools, options, False
|
|
297
|
+
):
|
|
298
|
+
yield chunk
|
|
299
|
+
|
|
300
|
+
async def _stream_in_content_once(
|
|
301
|
+
self,
|
|
302
|
+
driver: ChatDriver,
|
|
303
|
+
messages: list[dict[str, Any]],
|
|
304
|
+
model: str,
|
|
305
|
+
tools: list[dict[str, Any]] | None,
|
|
306
|
+
options: dict[str, Any] | None,
|
|
307
|
+
think: ThinkOption,
|
|
205
308
|
) -> AsyncGenerator[NormalizedStreamChunk, None]:
|
|
206
309
|
# Let Ollama size the context window from the model itself; do not
|
|
207
310
|
# force num_ctx.
|
|
@@ -213,7 +316,7 @@ class OllamaProvider(AIProvider):
|
|
|
213
316
|
messages=ollama_messages,
|
|
214
317
|
tools=None,
|
|
215
318
|
keep_alive=-1,
|
|
216
|
-
think=
|
|
319
|
+
think=think,
|
|
217
320
|
stream=True,
|
|
218
321
|
)
|
|
219
322
|
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Filesystem tools for ayder-cli.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import difflib
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
|
|
14
|
+
from ayder_cli.core.context import ProjectContext
|
|
15
|
+
from ayder_cli.core.result import ToolSuccess, ToolError
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Maximum file size allowed for read_file() to prevent DoS/memory exhaustion
|
|
20
|
+
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
21
|
+
|
|
22
|
+
# The chat loop executes auto-approved tool calls concurrently (asyncio.to_thread),
|
|
23
|
+
# so two edits targeting the same file race read-modify-write. Serialize per
|
|
24
|
+
# resolved path; edits to different files still run in parallel.
|
|
25
|
+
_PATH_LOCKS: dict[str, threading.Lock] = {}
|
|
26
|
+
_PATH_LOCKS_GUARD = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _path_lock(abs_path) -> threading.Lock:
|
|
30
|
+
with _PATH_LOCKS_GUARD:
|
|
31
|
+
return _PATH_LOCKS.setdefault(str(abs_path), threading.Lock())
|
|
32
|
+
|
|
33
|
+
# Default page size for read_file when no explicit range is given. Mirrors
|
|
34
|
+
# Claude Code's Read tool — large enough for most source files in one shot,
|
|
35
|
+
# small enough that small-context LLMs don't drown.
|
|
36
|
+
READ_FILE_DEFAULT_LINES = 2000
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _format_size(n_bytes: int) -> str:
|
|
40
|
+
if n_bytes < 1024:
|
|
41
|
+
return f"{n_bytes}B"
|
|
42
|
+
if n_bytes < 1024 * 1024:
|
|
43
|
+
return f"{n_bytes / 1024:.1f}KB"
|
|
44
|
+
return f"{n_bytes / (1024 * 1024):.1f}MB"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_file(
|
|
48
|
+
project_ctx: ProjectContext,
|
|
49
|
+
file_path: str,
|
|
50
|
+
start_line: int | None = None,
|
|
51
|
+
end_line: int | None = None,
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Read a file with explicit pagination semantics.
|
|
54
|
+
|
|
55
|
+
Behavior:
|
|
56
|
+
- With no range: reads up to ``READ_FILE_DEFAULT_LINES`` starting at line 1.
|
|
57
|
+
If the file is longer, the result includes a footer naming the total
|
|
58
|
+
line count and the exact follow-up call needed to read the next page.
|
|
59
|
+
- With ``start_line`` / ``end_line``: reads that explicit slice (1-based,
|
|
60
|
+
inclusive). The same pagination footer is appended if more content
|
|
61
|
+
remains beyond the slice.
|
|
62
|
+
- The returned body always uses ``"<line_no>: <text>"`` formatting so the
|
|
63
|
+
caller can never confuse partial output for a complete file.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
abs_path = project_ctx.validate_path(file_path)
|
|
67
|
+
|
|
68
|
+
if not abs_path.exists():
|
|
69
|
+
rel_path = project_ctx.to_relative(abs_path)
|
|
70
|
+
return ToolError(f"Error: File '{rel_path}' does not exist.")
|
|
71
|
+
|
|
72
|
+
file_size = os.path.getsize(abs_path)
|
|
73
|
+
if file_size > MAX_FILE_SIZE:
|
|
74
|
+
rel_path = project_ctx.to_relative(abs_path)
|
|
75
|
+
return ToolError(
|
|
76
|
+
f"Error: File '{rel_path}' is too large "
|
|
77
|
+
f"({file_size / (1024 * 1024):.1f}MB). "
|
|
78
|
+
f"Maximum allowed size is {MAX_FILE_SIZE / (1024 * 1024):.0f}MB."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
with open(abs_path, "r", encoding="utf-8") as f:
|
|
82
|
+
lines = f.readlines()
|
|
83
|
+
|
|
84
|
+
total_lines = len(lines)
|
|
85
|
+
rel_path = str(project_ctx.to_relative(abs_path))
|
|
86
|
+
|
|
87
|
+
explicit_range = start_line is not None or end_line is not None
|
|
88
|
+
|
|
89
|
+
if explicit_range:
|
|
90
|
+
start = int(start_line) if start_line else 1
|
|
91
|
+
end = int(end_line) if end_line else total_lines
|
|
92
|
+
else:
|
|
93
|
+
start = 1
|
|
94
|
+
end = min(total_lines, READ_FILE_DEFAULT_LINES)
|
|
95
|
+
|
|
96
|
+
start_idx = max(0, start - 1)
|
|
97
|
+
end_idx = min(total_lines, end)
|
|
98
|
+
selected = lines[start_idx:end_idx]
|
|
99
|
+
|
|
100
|
+
body = "".join(f"{start + i}: {line}" for i, line in enumerate(selected))
|
|
101
|
+
|
|
102
|
+
# Always append a footer so the caller never has to infer completeness
|
|
103
|
+
# from absence. Two shapes:
|
|
104
|
+
# - more remaining → name the next call to make
|
|
105
|
+
# - reached EOF → explicit [COMPLETE] marker
|
|
106
|
+
last_returned = start_idx + len(selected)
|
|
107
|
+
more_remaining = last_returned < total_lines
|
|
108
|
+
|
|
109
|
+
if more_remaining:
|
|
110
|
+
footer = (
|
|
111
|
+
f"\n[FILE: {rel_path} | {total_lines} total lines, "
|
|
112
|
+
f"{_format_size(file_size)} | showing lines "
|
|
113
|
+
f"{start_idx + 1}-{last_returned}. "
|
|
114
|
+
f"To read more: read_file(file_path={rel_path!r}, "
|
|
115
|
+
f"start_line={last_returned + 1})]"
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
footer = (
|
|
119
|
+
f"\n[FILE: {rel_path} | COMPLETE: {total_lines} lines, "
|
|
120
|
+
f"{_format_size(file_size)} | showing lines "
|
|
121
|
+
f"{start_idx + 1}-{last_returned}]"
|
|
122
|
+
)
|
|
123
|
+
return ToolSuccess(body + footer)
|
|
124
|
+
|
|
125
|
+
except ValueError as e:
|
|
126
|
+
return ToolError(f"Security Error: {str(e)}", "security")
|
|
127
|
+
except Exception as e:
|
|
128
|
+
return ToolError(f"Error reading file: {str(e)}", "execution")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def file_explorer(project_ctx: ProjectContext, path: str = ".") -> str:
|
|
132
|
+
"""List files in a directory or get metadata for a specific file."""
|
|
133
|
+
try:
|
|
134
|
+
abs_path = project_ctx.validate_path(path)
|
|
135
|
+
|
|
136
|
+
if abs_path.is_dir():
|
|
137
|
+
files = [item.name for item in abs_path.iterdir()]
|
|
138
|
+
return ToolSuccess(json.dumps(files))
|
|
139
|
+
|
|
140
|
+
elif abs_path.exists():
|
|
141
|
+
rel_path = project_ctx.to_relative(abs_path)
|
|
142
|
+
stat = abs_path.stat()
|
|
143
|
+
size_bytes = stat.st_size
|
|
144
|
+
|
|
145
|
+
# Human-readable size
|
|
146
|
+
if size_bytes < 1024:
|
|
147
|
+
size_human = f"{size_bytes}B"
|
|
148
|
+
elif size_bytes < 1024 * 1024:
|
|
149
|
+
size_human = f"{size_bytes / 1024:.1f}KB"
|
|
150
|
+
else:
|
|
151
|
+
size_human = f"{size_bytes / (1024 * 1024):.1f}MB"
|
|
152
|
+
|
|
153
|
+
# Line count (only for files)
|
|
154
|
+
line_count = None
|
|
155
|
+
if abs_path.is_file():
|
|
156
|
+
try:
|
|
157
|
+
with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
|
|
158
|
+
line_count = sum(1 for _ in f)
|
|
159
|
+
except Exception:
|
|
160
|
+
line_count = None
|
|
161
|
+
|
|
162
|
+
info = {
|
|
163
|
+
"path": str(rel_path),
|
|
164
|
+
"size_bytes": size_bytes,
|
|
165
|
+
"size_human": size_human,
|
|
166
|
+
"line_count": line_count,
|
|
167
|
+
"extension": abs_path.suffix or None,
|
|
168
|
+
"is_file": abs_path.is_file(),
|
|
169
|
+
"is_directory": abs_path.is_dir(),
|
|
170
|
+
"is_symlink": abs_path.is_symlink(),
|
|
171
|
+
}
|
|
172
|
+
return ToolSuccess(json.dumps(info, indent=2))
|
|
173
|
+
else:
|
|
174
|
+
rel_path = project_ctx.to_relative(abs_path)
|
|
175
|
+
return ToolError(f"Error: '{rel_path}' does not exist.")
|
|
176
|
+
|
|
177
|
+
except ValueError as e:
|
|
178
|
+
return ToolError(f"Security Error: {str(e)}", "security")
|
|
179
|
+
except Exception as e:
|
|
180
|
+
return ToolError(f"Error exploring path: {str(e)}", "execution")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _finalize(
|
|
184
|
+
abs_path,
|
|
185
|
+
rel_path,
|
|
186
|
+
old_content: str,
|
|
187
|
+
new_content: str,
|
|
188
|
+
success_msg: str,
|
|
189
|
+
dry_run: bool,
|
|
190
|
+
) -> str:
|
|
191
|
+
"""Write *new_content* and return *success_msg*, or, when *dry_run* is set,
|
|
192
|
+
return a unified diff and write nothing."""
|
|
193
|
+
if dry_run:
|
|
194
|
+
if old_content == new_content:
|
|
195
|
+
return ToolSuccess(f"[DRY RUN] No changes would be made to {rel_path}.")
|
|
196
|
+
diff = "".join(
|
|
197
|
+
difflib.unified_diff(
|
|
198
|
+
old_content.splitlines(keepends=True),
|
|
199
|
+
new_content.splitlines(keepends=True),
|
|
200
|
+
fromfile=f"a/{rel_path}",
|
|
201
|
+
tofile=f"b/{rel_path}",
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
return ToolSuccess(f"[DRY RUN] No changes written to {rel_path}.\n{diff}")
|
|
205
|
+
# Atomic write: a crash mid-write must never leave a truncated target,
|
|
206
|
+
# and concurrent readers must see either the old or the new content.
|
|
207
|
+
tmp_fd, tmp_name = tempfile.mkstemp(
|
|
208
|
+
dir=str(abs_path.parent), prefix=f".{abs_path.name}.", suffix=".tmp"
|
|
209
|
+
)
|
|
210
|
+
try:
|
|
211
|
+
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
|
212
|
+
f.write(new_content)
|
|
213
|
+
if abs_path.exists():
|
|
214
|
+
os.chmod(tmp_name, os.stat(abs_path).st_mode)
|
|
215
|
+
os.replace(tmp_name, str(abs_path))
|
|
216
|
+
except BaseException:
|
|
217
|
+
with contextlib.suppress(OSError):
|
|
218
|
+
os.unlink(tmp_name)
|
|
219
|
+
raise
|
|
220
|
+
return ToolSuccess(success_msg)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
_HINT_MIN_RATIO = 0.6
|
|
224
|
+
_HINT_MAX_FILE_CHARS = 1_000_000
|
|
225
|
+
_HINT_MAX_PREVIEW_CHARS = 300
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _closest_match_hint(file_content: str, old_string: str) -> str:
|
|
229
|
+
"""Locate the region most similar to a failed 'replace' target.
|
|
230
|
+
|
|
231
|
+
Returns a hint naming the line and a repr() of the region (so tabs,
|
|
232
|
+
trailing spaces, and other invisible mismatches become visible), or ''
|
|
233
|
+
when nothing is similar enough to be worth suggesting.
|
|
234
|
+
"""
|
|
235
|
+
if len(file_content) > _HINT_MAX_FILE_CHARS:
|
|
236
|
+
return ""
|
|
237
|
+
lines = file_content.splitlines()
|
|
238
|
+
if not lines:
|
|
239
|
+
return ""
|
|
240
|
+
window_size = max(1, len(old_string.splitlines()))
|
|
241
|
+
matcher = difflib.SequenceMatcher(autojunk=False)
|
|
242
|
+
matcher.set_seq2(old_string)
|
|
243
|
+
best_ratio = 0.0
|
|
244
|
+
best_line = 0
|
|
245
|
+
best_window = ""
|
|
246
|
+
for i in range(max(1, len(lines) - window_size + 1)):
|
|
247
|
+
window = "\n".join(lines[i : i + window_size])
|
|
248
|
+
matcher.set_seq1(window)
|
|
249
|
+
if (
|
|
250
|
+
matcher.real_quick_ratio() <= best_ratio
|
|
251
|
+
or matcher.quick_ratio() <= best_ratio
|
|
252
|
+
):
|
|
253
|
+
continue
|
|
254
|
+
ratio = matcher.ratio()
|
|
255
|
+
if ratio > best_ratio:
|
|
256
|
+
best_ratio, best_line, best_window = ratio, i + 1, window
|
|
257
|
+
if best_ratio < _HINT_MIN_RATIO:
|
|
258
|
+
return ""
|
|
259
|
+
preview = repr(best_window)
|
|
260
|
+
if len(preview) > _HINT_MAX_PREVIEW_CHARS:
|
|
261
|
+
preview = preview[:_HINT_MAX_PREVIEW_CHARS] + "…"
|
|
262
|
+
return (
|
|
263
|
+
f" Closest match at line {best_line} ({best_ratio:.0%} similar): "
|
|
264
|
+
f"{preview}. old_string must match the file byte-for-byte (repr shown: "
|
|
265
|
+
f"\\t = tab). Retry with the exact text, or dry_run=true to preview."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def file_editor(
|
|
270
|
+
project_ctx: ProjectContext,
|
|
271
|
+
file_path: str,
|
|
272
|
+
operation: str,
|
|
273
|
+
content: str | None = None,
|
|
274
|
+
old_string: str | None = None,
|
|
275
|
+
new_string: str | None = None,
|
|
276
|
+
line_number: int | None = None,
|
|
277
|
+
replace_all: bool = False,
|
|
278
|
+
regex: bool = False,
|
|
279
|
+
dry_run: bool = False,
|
|
280
|
+
) -> str:
|
|
281
|
+
"""Modify files with specific operations."""
|
|
282
|
+
try:
|
|
283
|
+
abs_path = project_ctx.validate_path(file_path)
|
|
284
|
+
rel_path = project_ctx.to_relative(abs_path)
|
|
285
|
+
|
|
286
|
+
with _path_lock(abs_path):
|
|
287
|
+
if operation == "write":
|
|
288
|
+
if content is None:
|
|
289
|
+
return ToolError("Error: 'content' parameter is required for 'write' operation.", "validation")
|
|
290
|
+
old_content = abs_path.read_text(encoding="utf-8") if abs_path.exists() else ""
|
|
291
|
+
if not dry_run:
|
|
292
|
+
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
return _finalize(
|
|
294
|
+
abs_path, rel_path, old_content, content,
|
|
295
|
+
f"Successfully wrote to {rel_path}", dry_run,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
elif operation == "replace":
|
|
299
|
+
if old_string is None or new_string is None:
|
|
300
|
+
return ToolError("Error: 'old_string' and 'new_string' are required for 'replace' operation.", "validation")
|
|
301
|
+
if not abs_path.exists():
|
|
302
|
+
return ToolError(f"Error: File '{rel_path}' does not exist.")
|
|
303
|
+
with open(abs_path, "r", encoding="utf-8") as f:
|
|
304
|
+
file_content = f.read()
|
|
305
|
+
if regex:
|
|
306
|
+
try:
|
|
307
|
+
pattern = re.compile(old_string)
|
|
308
|
+
except re.error as e:
|
|
309
|
+
return ToolError(f"Invalid regex pattern: {e}", "validation")
|
|
310
|
+
count = sum(1 for _ in pattern.finditer(file_content))
|
|
311
|
+
else:
|
|
312
|
+
count = file_content.count(old_string)
|
|
313
|
+
if count == 0:
|
|
314
|
+
hint = "" if regex else _closest_match_hint(file_content, old_string)
|
|
315
|
+
return ToolError(
|
|
316
|
+
f"Error: 'old_string' not found in {rel_path}. No changes made.{hint}"
|
|
317
|
+
)
|
|
318
|
+
if count > 1 and not replace_all:
|
|
319
|
+
return ToolError(
|
|
320
|
+
f"Error: 'old_string' is not unique (found {count} matches) in {rel_path}. "
|
|
321
|
+
f"Pass replace_all=true to replace all occurrences, or add surrounding "
|
|
322
|
+
f"context to make it unique. No changes made.",
|
|
323
|
+
"validation",
|
|
324
|
+
)
|
|
325
|
+
if regex:
|
|
326
|
+
new_content = pattern.sub(new_string, file_content, count=0 if replace_all else 1)
|
|
327
|
+
else:
|
|
328
|
+
new_content = (
|
|
329
|
+
file_content.replace(old_string, new_string)
|
|
330
|
+
if replace_all
|
|
331
|
+
else file_content.replace(old_string, new_string, 1)
|
|
332
|
+
)
|
|
333
|
+
noun = "occurrence" if count == 1 else "occurrences"
|
|
334
|
+
return _finalize(
|
|
335
|
+
abs_path, rel_path, file_content, new_content,
|
|
336
|
+
f"Successfully replaced {count} {noun} in {rel_path}", dry_run,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
elif operation == "insert":
|
|
340
|
+
if line_number is None or content is None:
|
|
341
|
+
return ToolError("Error: 'line_number' and 'content' are required for 'insert' operation.", "validation")
|
|
342
|
+
if not abs_path.exists():
|
|
343
|
+
return ToolError(f"Error: File '{rel_path}' does not exist.")
|
|
344
|
+
with open(abs_path, "r", encoding="utf-8") as f:
|
|
345
|
+
lines = f.readlines()
|
|
346
|
+
if line_number < 1:
|
|
347
|
+
return ToolError("Error: line_number must be >= 1.", "validation")
|
|
348
|
+
old_content = "".join(lines)
|
|
349
|
+
idx = min(line_number - 1, len(lines))
|
|
350
|
+
ins = content
|
|
351
|
+
if ins and not ins.endswith("\n"):
|
|
352
|
+
ins += "\n"
|
|
353
|
+
new_content = "".join(lines[:idx] + [ins] + lines[idx:])
|
|
354
|
+
return _finalize(
|
|
355
|
+
abs_path, rel_path, old_content, new_content,
|
|
356
|
+
f"Successfully inserted content at line {line_number} in {rel_path}", dry_run,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
elif operation == "delete":
|
|
360
|
+
if line_number is None:
|
|
361
|
+
return ToolError("Error: 'line_number' is required for 'delete' operation.", "validation")
|
|
362
|
+
if not abs_path.exists():
|
|
363
|
+
return ToolError(f"Error: File '{rel_path}' does not exist.")
|
|
364
|
+
with open(abs_path, "r", encoding="utf-8") as f:
|
|
365
|
+
lines = f.readlines()
|
|
366
|
+
if line_number < 1 or line_number > len(lines):
|
|
367
|
+
return ToolError(f"Error: line_number {line_number} is out of range (1-{len(lines)}).", "validation")
|
|
368
|
+
old_content = "".join(lines)
|
|
369
|
+
deleted = lines[line_number - 1]
|
|
370
|
+
new_content = "".join(lines[: line_number - 1] + lines[line_number:])
|
|
371
|
+
preview = deleted.rstrip("\n")[:80]
|
|
372
|
+
return _finalize(
|
|
373
|
+
abs_path, rel_path, old_content, new_content,
|
|
374
|
+
f"Deleted line {line_number} from {rel_path}: '{preview}'", dry_run,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
else:
|
|
378
|
+
return ToolError(f"Error: Unknown operation '{operation}'", "validation")
|
|
379
|
+
|
|
380
|
+
except ValueError as e:
|
|
381
|
+
return ToolError(f"Security Error: {str(e)}", "security")
|
|
382
|
+
except Exception as e:
|
|
383
|
+
return ToolError(f"Error executing file_editor: {str(e)}", "execution")
|