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.
Files changed (271) hide show
  1. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/PKG-INFO +1 -1
  2. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/config.toml.example +14 -12
  3. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/pyproject.toml +1 -1
  4. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/config.py +24 -1
  5. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama.py +106 -3
  6. ayder_cli-2.2.9/src/ayder_cli/tools/builtins/filesystem.py +383 -0
  7. ayder_cli-2.2.9/src/ayder_cli/tools/builtins/search.py +533 -0
  8. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/search_definitions.py +20 -8
  9. ayder_cli-2.2.9/src/ayder_cli/tui/rendering.py +12 -0
  10. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/widgets.py +13 -11
  11. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_config_v2.py +33 -0
  12. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_provider_integration.py +109 -0
  13. ayder_cli-2.2.9/tests/tools/test_file_editor_hardening.py +155 -0
  14. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_impl.py +15 -7
  15. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_impl_coverage.py +4 -2
  16. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_search_codebase.py +3 -2
  17. ayder_cli-2.2.9/tests/tools/test_search_hardening.py +381 -0
  18. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_status_bar_badges.py +8 -0
  19. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_widgets.py +52 -1
  20. ayder_cli-2.2.7/src/ayder_cli/tools/builtins/filesystem.py +0 -306
  21. ayder_cli-2.2.7/src/ayder_cli/tools/builtins/search.py +0 -335
  22. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/.github/workflows/ci.yml +0 -0
  23. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/.github/workflows/python-package.yml +0 -0
  24. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/.gitignore +0 -0
  25. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/.python-version +0 -0
  26. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/AGENTS.md +0 -0
  27. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/LICENSE +0 -0
  28. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/README.md +0 -0
  29. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/PROJECT_STRUCTURE.md +0 -0
  30. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/README_AGENT_WORKFLOW.md +0 -0
  31. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/README_OLLAMA.md +0 -0
  32. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/cc.png +0 -0
  33. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/superpowers/plans/2026-06-26-file-editor-improvements.md +0 -0
  34. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/superpowers/plans/2026-06-26-session-save-resume.md +0 -0
  35. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/superpowers/specs/2026-06-26-file-editor-improvements-design.md +0 -0
  36. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/docs/superpowers/specs/2026-06-26-session-save-resume-design.md +0 -0
  37. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/__init__.py +0 -0
  38. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/__main__.py +0 -0
  39. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/__init__.py +0 -0
  40. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/callbacks.py +0 -0
  41. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/config.py +0 -0
  42. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/registry.py +0 -0
  43. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/run.py +0 -0
  44. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/runner.py +0 -0
  45. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/tool.py +0 -0
  46. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/agents/worktree.py +0 -0
  47. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/application/README.md +0 -0
  48. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/application/__init__.py +0 -0
  49. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/application/execution_policy.py +0 -0
  50. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/application/message_contract.py +0 -0
  51. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/application/runtime_factory.py +0 -0
  52. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/application/validation.py +0 -0
  53. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/cli.py +0 -0
  54. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/cli_callbacks.py +0 -0
  55. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/cli_runner.py +0 -0
  56. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/console.py +0 -0
  57. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/cache_monitor.py +0 -0
  58. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/config_migration.py +0 -0
  59. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/context.py +0 -0
  60. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/context_manager.py +0 -0
  61. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/context_manager_factory.py +0 -0
  62. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/default_context_manager.py +0 -0
  63. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/ollama_context_manager.py +0 -0
  64. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/result.py +0 -0
  65. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/core/session.py +0 -0
  66. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/logging_config.py +0 -0
  67. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/loops/__init__.py +0 -0
  68. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/loops/chat_loop.py +0 -0
  69. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/parser.py +0 -0
  70. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/process_manager.py +0 -0
  71. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/prompts.py +0 -0
  72. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/__init__.py +0 -0
  73. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/base.py +0 -0
  74. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/claude.py +0 -0
  75. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/deepseek.py +0 -0
  76. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/gemini.py +0 -0
  77. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/glm.py +0 -0
  78. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/__init__.py +0 -0
  79. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/_errors.py +0 -0
  80. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/base.py +0 -0
  81. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/deepseek.py +0 -0
  82. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/generic_native.py +0 -0
  83. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/generic_xml.py +0 -0
  84. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/matrix.py +0 -0
  85. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/minimax.py +0 -0
  86. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/qwen3.py +0 -0
  87. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_drivers/registry.py +0 -0
  88. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/ollama_inspector.py +0 -0
  89. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/openai.py +0 -0
  90. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/impl/qwen.py +0 -0
  91. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/orchestrator.py +0 -0
  92. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/providers/retry.py +0 -0
  93. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/services/__init__.py +0 -0
  94. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/services/interactions.py +0 -0
  95. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/themes/__init__.py +0 -0
  96. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/themes/ayder.py +0 -0
  97. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/themes/claude.py +0 -0
  98. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/__init__.py +0 -0
  99. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/__init__.py +0 -0
  100. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/background_process_tool.py +0 -0
  101. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/context.py +0 -0
  102. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/context_definitions.py +0 -0
  103. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/filesystem_definitions.py +0 -0
  104. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/note_tool.py +0 -0
  105. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/notes.py +0 -0
  106. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/notes_definitions.py +0 -0
  107. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/process_manager_definitions.py +0 -0
  108. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/shell.py +0 -0
  109. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/shell_definitions.py +0 -0
  110. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/skill.py +0 -0
  111. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/skill_definitions.py +0 -0
  112. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/task_definitions.py +0 -0
  113. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/task_tool.py +0 -0
  114. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/tasks.py +0 -0
  115. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/utils_tools.py +0 -0
  116. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/utils_tools_definitions.py +0 -0
  117. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/web.py +0 -0
  118. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/builtins/web_definitions.py +0 -0
  119. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/definition.py +0 -0
  120. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/execution.py +0 -0
  121. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/hooks.py +0 -0
  122. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/normalization.py +0 -0
  123. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/plugin_api.py +0 -0
  124. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/plugin_github.py +0 -0
  125. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/plugin_manager.py +0 -0
  126. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/plugin_status.py +0 -0
  127. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/registry.py +0 -0
  128. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/schemas.py +0 -0
  129. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tools/utils.py +0 -0
  130. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/__init__.py +0 -0
  131. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/adapter.py +0 -0
  132. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/app.py +0 -0
  133. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/commands.py +0 -0
  134. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/helpers.py +0 -0
  135. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/keybindings.py +0 -0
  136. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/parser.py +0 -0
  137. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/screens.py +0 -0
  138. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/theme_manager.py +0 -0
  139. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/tui/types.py +0 -0
  140. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/ui.py +0 -0
  141. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/src/ayder_cli/version.py +0 -0
  142. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/COVERAGE.md +0 -0
  143. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/__init__.py +0 -0
  144. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/__init__.py +0 -0
  145. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_agent_consolidated.py +0 -0
  146. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_agent_tools.py +0 -0
  147. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_callbacks.py +0 -0
  148. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_config.py +0 -0
  149. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_integration.py +0 -0
  150. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_queue.py +0 -0
  151. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_registry.py +0 -0
  152. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_run.py +0 -0
  153. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_runner.py +0 -0
  154. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_runner_provider_unavailable.py +0 -0
  155. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_tool.py +0 -0
  156. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_worktree_helpers.py +0 -0
  157. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/agents/test_worktree_isolation.py +0 -0
  158. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/application/test_message_contract.py +0 -0
  159. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/application/test_runtime_factory.py +0 -0
  160. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/client/test_main.py +0 -0
  161. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/convergence/__init__.py +0 -0
  162. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/convergence/test_execution_policy_parity.py +0 -0
  163. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/convergence/test_runtime_wiring.py +0 -0
  164. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/convergence/test_validation_path.py +0 -0
  165. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_cache_monitor.py +0 -0
  166. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_config.py +0 -0
  167. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_config_coverage.py +0 -0
  168. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_config_migration.py +0 -0
  169. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_config_provider_logging.py +0 -0
  170. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_config_retry.py +0 -0
  171. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_context_manager_factory.py +0 -0
  172. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_default_context_manager.py +0 -0
  173. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_default_context_manager_should_compact.py +0 -0
  174. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_ollama_context_manager.py +0 -0
  175. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_ollama_context_manager_cache_monitor.py +0 -0
  176. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_ollama_context_manager_max_history.py +0 -0
  177. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_parameter_aliasing.py +0 -0
  178. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_parser.py +0 -0
  179. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/core/test_session.py +0 -0
  180. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/loops/__init__.py +0 -0
  181. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/loops/test_base.py +0 -0
  182. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/loops/test_chat_loop_hook.py +0 -0
  183. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/loops/test_chat_loop_tool_truncation.py +0 -0
  184. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/__init__.py +0 -0
  185. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/__init__.py +0 -0
  186. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_base.py +0 -0
  187. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_deepseek.py +0 -0
  188. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_errors.py +0 -0
  189. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_fallback.py +0 -0
  190. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_generic_native.py +0 -0
  191. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_generic_xml.py +0 -0
  192. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_matrix.py +0 -0
  193. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_minimax.py +0 -0
  194. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_qwen3.py +0 -0
  195. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/ollama_drivers/test_registry.py +0 -0
  196. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_glm_async.py +0 -0
  197. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_ollama_inspector.py +0 -0
  198. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_ollama_native.py +0 -0
  199. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_ollama_xml.py +0 -0
  200. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_ollama_xml_autoroute.py +0 -0
  201. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_ollama_xml_usage.py +0 -0
  202. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_orchestrator_capabilities.py +0 -0
  203. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_qwen_async.py +0 -0
  204. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_retry_classifier.py +0 -0
  205. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_retry_integration.py +0 -0
  206. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/providers/test_retrying_provider.py +0 -0
  207. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/services/test_boundary.py +0 -0
  208. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/services/test_interaction_sink.py +0 -0
  209. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_cli.py +0 -0
  210. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_cli_callbacks.py +0 -0
  211. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_context.py +0 -0
  212. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_env_manager.py +0 -0
  213. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_logging_config.py +0 -0
  214. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_notes.py +0 -0
  215. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_process_manager.py +0 -0
  216. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/test_tasks.py +0 -0
  217. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/__init__.py +0 -0
  218. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_agent_notes.py +0 -0
  219. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_background_process_tool.py +0 -0
  220. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_bash_tool.py +0 -0
  221. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_definition_discovery.py +0 -0
  222. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_di_injection.py +0 -0
  223. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_note_tool.py +0 -0
  224. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_notes_api.py +0 -0
  225. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_path_security.py +0 -0
  226. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_plugin_api.py +0 -0
  227. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_plugin_github.py +0 -0
  228. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_plugin_loading.py +0 -0
  229. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_plugin_manager.py +0 -0
  230. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_plugin_status.py +0 -0
  231. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_registry.py +0 -0
  232. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_registry_coverage.py +0 -0
  233. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_result.py +0 -0
  234. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_schemas.py +0 -0
  235. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_skill_tool.py +0 -0
  236. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_task_tool.py +0 -0
  237. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_utils.py +0 -0
  238. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tools/test_web.py +0 -0
  239. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/__init__.py +0 -0
  240. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_agent_list_screen.py +0 -0
  241. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_do_clear_coordination.py +0 -0
  242. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_follow_mode.py +0 -0
  243. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_help_screen.py +0 -0
  244. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_input_box.py +0 -0
  245. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_keybindings.py +0 -0
  246. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_multi_select_screen.py +0 -0
  247. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_multiline_input.py +0 -0
  248. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_paste.py +0 -0
  249. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_pending_compact_consumer.py +0 -0
  250. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_pull_delivery.py +0 -0
  251. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_resume.py +0 -0
  252. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_themes.py +0 -0
  253. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/tui/test_turn_consumer.py +0 -0
  254. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_confirm_screen.py +0 -0
  255. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_diff_preview.py +0 -0
  256. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_plugin_autoenable.py +0 -0
  257. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_provider_switch.py +0 -0
  258. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_run_tui_startup.py +0 -0
  259. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_assistant_sanitization.py +0 -0
  260. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_chat_loop.py +0 -0
  261. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_commands_agent.py +0 -0
  262. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_commands_logging.py +0 -0
  263. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_commands_plugin.py +0 -0
  264. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_commands_provider.py +0 -0
  265. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_commands_skill.py +0 -0
  266. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_helpers.py +0 -0
  267. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_temporal_command.py +0 -0
  268. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_tui_widgets.py +0 -0
  269. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_ui.py +0 -0
  270. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_ui_coverage.py +0 -0
  271. {ayder_cli-2.2.7 → ayder_cli-2.2.9}/tests/ui/test_ui_verbose.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ayder-cli
3
- Version: 2.2.7
3
+ Version: 2.2.9
4
4
  Summary: AI agent for any LLMs
5
5
  Project-URL: Homepage, https://github.com/ayder/ayder-cli
6
6
  Project-URL: Repository, https://github.com/ayder/ayder-cli.git
@@ -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 hardcodes thinking ON
52
- # (think=True) and does NOT forward the configured level — so a
53
- # `think = "high"` below documents intent, but the "low/medium/high"
54
- # granularity is not applied. The `openai` driver sends no reasoning-effort
55
- # parameter at all.
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 — hardcoded ON for ollama (level ignored); openai
64
- # sends none.
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 / think values below are
70
- # therefore largely INERT on this Ollama pipeline — they are kept to
71
- # document intent and to carry over if you switch an agent to an `openai`
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ayder-cli"
7
- version = "2.2.7"
7
+ version = "2.2.9"
8
8
  description = "AI agent for any LLMs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -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=True,
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=True,
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")