code-muse 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (394) hide show
  1. code_muse/__init__.py +26 -0
  2. code_muse/__main__.py +10 -0
  3. code_muse/agents/__init__.py +31 -0
  4. code_muse/agents/_builder.py +214 -0
  5. code_muse/agents/_compaction.py +506 -0
  6. code_muse/agents/_diagnostics.py +171 -0
  7. code_muse/agents/_history.py +382 -0
  8. code_muse/agents/_key_listeners.py +148 -0
  9. code_muse/agents/_non_streaming_render.py +148 -0
  10. code_muse/agents/_runtime.py +596 -0
  11. code_muse/agents/agent_creator_agent.py +603 -0
  12. code_muse/agents/agent_helios.py +47 -0
  13. code_muse/agents/agent_manager.py +740 -0
  14. code_muse/agents/agent_muse.py +78 -0
  15. code_muse/agents/agent_planning.py +44 -0
  16. code_muse/agents/agent_qa_melpomene.py +207 -0
  17. code_muse/agents/base_agent.py +194 -0
  18. code_muse/agents/event_stream_handler.py +361 -0
  19. code_muse/agents/json_agent.py +201 -0
  20. code_muse/agents/prompt_v3.py +521 -0
  21. code_muse/agents/subagent_stream_handler.py +273 -0
  22. code_muse/callbacks.py +941 -0
  23. code_muse/chatgpt_codex_client.py +333 -0
  24. code_muse/claude_cache_client.py +853 -0
  25. code_muse/cli_runner/__init__.py +319 -0
  26. code_muse/cli_runner/args.py +63 -0
  27. code_muse/cli_runner/loop.py +510 -0
  28. code_muse/cli_runner/resume.py +72 -0
  29. code_muse/cli_runner/runner.py +161 -0
  30. code_muse/command_line/__init__.py +1 -0
  31. code_muse/command_line/add_model_menu.py +1331 -0
  32. code_muse/command_line/agent_menu.py +674 -0
  33. code_muse/command_line/attachments.py +397 -0
  34. code_muse/command_line/autosave_menu.py +709 -0
  35. code_muse/command_line/clipboard.py +528 -0
  36. code_muse/command_line/colors_menu.py +530 -0
  37. code_muse/command_line/command_handler.py +262 -0
  38. code_muse/command_line/command_registry.py +150 -0
  39. code_muse/command_line/config_commands.py +711 -0
  40. code_muse/command_line/core_commands.py +740 -0
  41. code_muse/command_line/diff_menu.py +865 -0
  42. code_muse/command_line/file_path_completion.py +73 -0
  43. code_muse/command_line/load_context_completion.py +57 -0
  44. code_muse/command_line/model_picker_completion.py +512 -0
  45. code_muse/command_line/model_settings_menu.py +983 -0
  46. code_muse/command_line/onboarding_slides.py +162 -0
  47. code_muse/command_line/onboarding_wizard.py +337 -0
  48. code_muse/command_line/pagination.py +41 -0
  49. code_muse/command_line/pin_command_completion.py +329 -0
  50. code_muse/command_line/prompt_toolkit_completion.py +886 -0
  51. code_muse/command_line/session_commands.py +304 -0
  52. code_muse/command_line/shell_passthrough.py +145 -0
  53. code_muse/command_line/skills_completion.py +158 -0
  54. code_muse/command_line/types.py +18 -0
  55. code_muse/command_line/uc_menu.py +908 -0
  56. code_muse/command_line/utils.py +105 -0
  57. code_muse/command_line/wiggum_state.py +77 -0
  58. code_muse/config.py +1138 -0
  59. code_muse/config_agent.py +168 -0
  60. code_muse/config_appearance.py +241 -0
  61. code_muse/config_model.py +357 -0
  62. code_muse/config_security.py +73 -0
  63. code_muse/error_logging.py +132 -0
  64. code_muse/evals/__init__.py +35 -0
  65. code_muse/evals/eval_helpers.py +81 -0
  66. code_muse/evals/eval_runner.py +299 -0
  67. code_muse/evals/sample_evals/__init__.py +1 -0
  68. code_muse/evals/sample_evals/eval_frugal_reads.py +59 -0
  69. code_muse/evals/sample_evals/eval_memory_planning.py +31 -0
  70. code_muse/evals/sample_evals/eval_shell_efficiency.py +39 -0
  71. code_muse/evals/sample_evals/eval_tool_masking.py +33 -0
  72. code_muse/fs_scan_cache/__init__.py +31 -0
  73. code_muse/fs_scan_cache/invalidation_hooks.py +89 -0
  74. code_muse/fs_scan_cache/scan_cache_core.cpython-314-darwin.so +0 -0
  75. code_muse/fs_scan_cache/scan_cache_core.pyx +203 -0
  76. code_muse/fs_scan_cache/tool_integration.py +309 -0
  77. code_muse/fs_scan_cache/ttl_policy.py +44 -0
  78. code_muse/gemini_code_assist.py +383 -0
  79. code_muse/gemini_model.py +838 -0
  80. code_muse/hook_engine/README.md +105 -0
  81. code_muse/hook_engine/__init__.py +21 -0
  82. code_muse/hook_engine/aliases.py +153 -0
  83. code_muse/hook_engine/engine.py +221 -0
  84. code_muse/hook_engine/executor.py +347 -0
  85. code_muse/hook_engine/matcher.py +154 -0
  86. code_muse/hook_engine/models.py +245 -0
  87. code_muse/hook_engine/registry.py +114 -0
  88. code_muse/hook_engine/trust.py +268 -0
  89. code_muse/hook_engine/validator.py +144 -0
  90. code_muse/http_utils.py +360 -0
  91. code_muse/keymap.py +128 -0
  92. code_muse/list_filtering.py +26 -0
  93. code_muse/main.py +10 -0
  94. code_muse/messaging/__init__.py +259 -0
  95. code_muse/messaging/bus.py +621 -0
  96. code_muse/messaging/commands.py +166 -0
  97. code_muse/messaging/markdown_patches.py +57 -0
  98. code_muse/messaging/message_queue.py +397 -0
  99. code_muse/messaging/messages.py +591 -0
  100. code_muse/messaging/queue_console.py +269 -0
  101. code_muse/messaging/renderers.py +308 -0
  102. code_muse/messaging/rich_renderer.py +1158 -0
  103. code_muse/messaging/shimmer.py +154 -0
  104. code_muse/messaging/spinner/__init__.py +87 -0
  105. code_muse/messaging/spinner/console_spinner.py +250 -0
  106. code_muse/messaging/spinner/spinner_base.py +82 -0
  107. code_muse/messaging/subagent_console.py +458 -0
  108. code_muse/model_factory.py +1203 -0
  109. code_muse/model_switching.py +59 -0
  110. code_muse/model_utils.py +156 -0
  111. code_muse/models.json +66 -0
  112. code_muse/models_cache/__init__.py +26 -0
  113. code_muse/models_cache/blocking_lru_cache.py +98 -0
  114. code_muse/models_cache/cache_writer.py +86 -0
  115. code_muse/models_cache/sha256_hash.cpython-314-darwin.so +0 -0
  116. code_muse/models_cache/sha256_hash.pyx +34 -0
  117. code_muse/models_cache/startup_integration.py +75 -0
  118. code_muse/models_dev_api.json +1 -0
  119. code_muse/models_dev_parser.py +590 -0
  120. code_muse/motion.py +126 -0
  121. code_muse/plugins/__init__.py +471 -0
  122. code_muse/plugins/agent_skills/__init__.py +32 -0
  123. code_muse/plugins/agent_skills/config.py +176 -0
  124. code_muse/plugins/agent_skills/discovery.py +309 -0
  125. code_muse/plugins/agent_skills/downloader.py +389 -0
  126. code_muse/plugins/agent_skills/installer.py +19 -0
  127. code_muse/plugins/agent_skills/metadata.py +293 -0
  128. code_muse/plugins/agent_skills/prompt_builder.py +66 -0
  129. code_muse/plugins/agent_skills/register_callbacks.py +298 -0
  130. code_muse/plugins/agent_skills/remote_catalog.py +320 -0
  131. code_muse/plugins/agent_skills/skill_catalog.py +254 -0
  132. code_muse/plugins/agent_skills/skills_install_menu.py +690 -0
  133. code_muse/plugins/agent_skills/skills_menu.py +791 -0
  134. code_muse/plugins/autonomous_memory/__init__.py +39 -0
  135. code_muse/plugins/autonomous_memory/bm25_scorer.cpython-314-darwin.so +0 -0
  136. code_muse/plugins/autonomous_memory/bm25_scorer.cpython-314-x86_64-linux-gnu.so +0 -0
  137. code_muse/plugins/autonomous_memory/bm25_scorer.pyx +291 -0
  138. code_muse/plugins/autonomous_memory/consolidation.py +82 -0
  139. code_muse/plugins/autonomous_memory/extraction.py +382 -0
  140. code_muse/plugins/autonomous_memory/lease_lock.py +105 -0
  141. code_muse/plugins/autonomous_memory/memory_injection.py +59 -0
  142. code_muse/plugins/autonomous_memory/register_callbacks.py +268 -0
  143. code_muse/plugins/autonomous_memory/secret_scanner.py +62 -0
  144. code_muse/plugins/autonomous_memory/session_scanner.py +163 -0
  145. code_muse/plugins/aws_bedrock/__init__.py +14 -0
  146. code_muse/plugins/aws_bedrock/config.py +99 -0
  147. code_muse/plugins/aws_bedrock/register_callbacks.py +241 -0
  148. code_muse/plugins/aws_bedrock/utils.py +153 -0
  149. code_muse/plugins/azure_foundry/README.md +238 -0
  150. code_muse/plugins/azure_foundry/__init__.py +15 -0
  151. code_muse/plugins/azure_foundry/config.py +125 -0
  152. code_muse/plugins/azure_foundry/discovery.py +187 -0
  153. code_muse/plugins/azure_foundry/register_callbacks.py +495 -0
  154. code_muse/plugins/azure_foundry/token.py +180 -0
  155. code_muse/plugins/azure_foundry/utils.py +345 -0
  156. code_muse/plugins/build_filter/__init__.py +1 -0
  157. code_muse/plugins/build_filter/register_callbacks.py +201 -0
  158. code_muse/plugins/build_filter/strategies/__init__.py +1 -0
  159. code_muse/plugins/build_filter/strategies/build.py +397 -0
  160. code_muse/plugins/chatgpt_oauth/__init__.py +6 -0
  161. code_muse/plugins/chatgpt_oauth/config.py +52 -0
  162. code_muse/plugins/chatgpt_oauth/oauth_flow.py +338 -0
  163. code_muse/plugins/chatgpt_oauth/register_callbacks.py +172 -0
  164. code_muse/plugins/chatgpt_oauth/test_plugin.py +301 -0
  165. code_muse/plugins/chatgpt_oauth/utils.py +538 -0
  166. code_muse/plugins/checkpointing/__init__.py +29 -0
  167. code_muse/plugins/checkpointing/checkpoint_hook.py +51 -0
  168. code_muse/plugins/checkpointing/conversation_snapshots.py +117 -0
  169. code_muse/plugins/checkpointing/register_callbacks.py +51 -0
  170. code_muse/plugins/checkpointing/restore_command.py +263 -0
  171. code_muse/plugins/checkpointing/rewind_shortcut.py +88 -0
  172. code_muse/plugins/checkpointing/shadow_git.py +90 -0
  173. code_muse/plugins/claude_code_hooks/__init__.py +1 -0
  174. code_muse/plugins/claude_code_hooks/config.py +188 -0
  175. code_muse/plugins/claude_code_hooks/register_callbacks.py +208 -0
  176. code_muse/plugins/claude_code_oauth/README.md +167 -0
  177. code_muse/plugins/claude_code_oauth/SETUP.md +93 -0
  178. code_muse/plugins/claude_code_oauth/__init__.py +25 -0
  179. code_muse/plugins/claude_code_oauth/config.py +52 -0
  180. code_muse/plugins/claude_code_oauth/fast_mode.py +124 -0
  181. code_muse/plugins/claude_code_oauth/prompt_handler.py +63 -0
  182. code_muse/plugins/claude_code_oauth/register_callbacks.py +547 -0
  183. code_muse/plugins/claude_code_oauth/test_fast_mode.py +165 -0
  184. code_muse/plugins/claude_code_oauth/test_plugin.py +283 -0
  185. code_muse/plugins/claude_code_oauth/token_refresh_heartbeat.py +237 -0
  186. code_muse/plugins/claude_code_oauth/utils.py +664 -0
  187. code_muse/plugins/copilot_auth/__init__.py +11 -0
  188. code_muse/plugins/copilot_auth/config.py +91 -0
  189. code_muse/plugins/copilot_auth/reasoning_client.py +409 -0
  190. code_muse/plugins/copilot_auth/register_callbacks.py +461 -0
  191. code_muse/plugins/copilot_auth/utils.py +584 -0
  192. code_muse/plugins/custom_commands/__init__.py +14 -0
  193. code_muse/plugins/custom_commands/args_injection.py +82 -0
  194. code_muse/plugins/custom_commands/command_discovery.py +89 -0
  195. code_muse/plugins/custom_commands/command_toml_schema.py +71 -0
  196. code_muse/plugins/custom_commands/register_callbacks.py +176 -0
  197. code_muse/plugins/customizable_commands/__init__.py +0 -0
  198. code_muse/plugins/customizable_commands/register_callbacks.py +136 -0
  199. code_muse/plugins/destructive_command_guard/__init__.py +14 -0
  200. code_muse/plugins/destructive_command_guard/detector.py +375 -0
  201. code_muse/plugins/destructive_command_guard/register_callbacks.py +148 -0
  202. code_muse/plugins/example_custom_command/README.md +280 -0
  203. code_muse/plugins/example_custom_command/register_callbacks.py +51 -0
  204. code_muse/plugins/file_permission_handler/__init__.py +4 -0
  205. code_muse/plugins/file_permission_handler/register_callbacks.py +441 -0
  206. code_muse/plugins/filter_engine/__init__.py +30 -0
  207. code_muse/plugins/filter_engine/classifier.py +153 -0
  208. code_muse/plugins/filter_engine/content_detector.py +184 -0
  209. code_muse/plugins/filter_engine/dispatcher.py +244 -0
  210. code_muse/plugins/filter_engine/register_callbacks.py +188 -0
  211. code_muse/plugins/filter_engine/registry.py +279 -0
  212. code_muse/plugins/filter_engine/strategies/__init__.py +8 -0
  213. code_muse/plugins/filter_engine/strategies/ast_compressor.cpython-314-darwin.so +0 -0
  214. code_muse/plugins/filter_engine/strategies/ast_compressor.cpython-314-x86_64-linux-gnu.so +0 -0
  215. code_muse/plugins/filter_engine/strategies/ast_compressor.pyx +348 -0
  216. code_muse/plugins/filter_engine/strategies/ast_parser.py +167 -0
  217. code_muse/plugins/filter_engine/strategies/code.cpython-314-darwin.so +0 -0
  218. code_muse/plugins/filter_engine/strategies/code.cpython-314-x86_64-linux-gnu.so +0 -0
  219. code_muse/plugins/filter_engine/strategies/code.pyx +584 -0
  220. code_muse/plugins/filter_engine/strategies/git.cpython-314-darwin.so +0 -0
  221. code_muse/plugins/filter_engine/strategies/git.cpython-314-x86_64-linux-gnu.so +0 -0
  222. code_muse/plugins/filter_engine/strategies/git.pyx +438 -0
  223. code_muse/plugins/filter_engine/strategies/json_compressor.cpython-314-darwin.so +0 -0
  224. code_muse/plugins/filter_engine/strategies/json_compressor.pyx +253 -0
  225. code_muse/plugins/filter_engine/strategies/json_patterns.cpython-314-darwin.so +0 -0
  226. code_muse/plugins/filter_engine/strategies/json_patterns.pyx +178 -0
  227. code_muse/plugins/filter_engine/strategies/lint.cpython-314-darwin.so +0 -0
  228. code_muse/plugins/filter_engine/strategies/lint.cpython-314-x86_64-linux-gnu.so +0 -0
  229. code_muse/plugins/filter_engine/strategies/lint.pyx +626 -0
  230. code_muse/plugins/filter_engine/strategies/test.cpython-314-darwin.so +0 -0
  231. code_muse/plugins/filter_engine/strategies/test.cpython-314-x86_64-linux-gnu.so +0 -0
  232. code_muse/plugins/filter_engine/strategies/test.pyx +431 -0
  233. code_muse/plugins/filter_engine/verbosity.py +63 -0
  234. code_muse/plugins/force_push_guard/__init__.py +5 -0
  235. code_muse/plugins/force_push_guard/detector.py +96 -0
  236. code_muse/plugins/force_push_guard/register_callbacks.py +144 -0
  237. code_muse/plugins/force_push_guard/test_detector.py +143 -0
  238. code_muse/plugins/frontend_emitter/__init__.py +25 -0
  239. code_muse/plugins/frontend_emitter/emitter.py +121 -0
  240. code_muse/plugins/frontend_emitter/register_callbacks.py +259 -0
  241. code_muse/plugins/gac/__init__.py +4 -0
  242. code_muse/plugins/gac/git_ops.py +136 -0
  243. code_muse/plugins/gac/prompt.py +191 -0
  244. code_muse/plugins/gac/register_callbacks.py +82 -0
  245. code_muse/plugins/hook_creator/__init__.py +1 -0
  246. code_muse/plugins/hook_creator/register_callbacks.py +34 -0
  247. code_muse/plugins/hook_manager/__init__.py +1 -0
  248. code_muse/plugins/hook_manager/config.py +289 -0
  249. code_muse/plugins/hook_manager/hooks_menu.py +563 -0
  250. code_muse/plugins/hook_manager/register_callbacks.py +227 -0
  251. code_muse/plugins/hook_monitor/register_callbacks.py +36 -0
  252. code_muse/plugins/mindpack/__init__.py +0 -0
  253. code_muse/plugins/mindpack/factory.py +930 -0
  254. code_muse/plugins/mindpack/judge.py +573 -0
  255. code_muse/plugins/mindpack/memory.py +100 -0
  256. code_muse/plugins/mindpack/mindpack_menu.py +1552 -0
  257. code_muse/plugins/mindpack/orchestration.py +605 -0
  258. code_muse/plugins/mindpack/register_callbacks.py +175 -0
  259. code_muse/plugins/mindpack/schemas.py +358 -0
  260. code_muse/plugins/mindpack/tools.py +387 -0
  261. code_muse/plugins/oauth_muse_html.py +226 -0
  262. code_muse/plugins/ollama_setup/__init__.py +5 -0
  263. code_muse/plugins/ollama_setup/completer.py +36 -0
  264. code_muse/plugins/ollama_setup/register_callbacks.py +410 -0
  265. code_muse/plugins/plan_command/__init__.py +0 -0
  266. code_muse/plugins/plan_command/register_callbacks.py +206 -0
  267. code_muse/plugins/plan_mode/__init__.py +37 -0
  268. code_muse/plugins/plan_mode/mode_cycling.py +40 -0
  269. code_muse/plugins/plan_mode/plan_generation.py +68 -0
  270. code_muse/plugins/plan_mode/plan_hooks.py +74 -0
  271. code_muse/plugins/plan_mode/plan_mode_tools.py +138 -0
  272. code_muse/plugins/plan_mode/register_callbacks.py +121 -0
  273. code_muse/plugins/plugin_trust/register_callbacks.py +140 -0
  274. code_muse/plugins/policy_engine/__init__.py +46 -0
  275. code_muse/plugins/policy_engine/approval_flow_integration.py +59 -0
  276. code_muse/plugins/policy_engine/policy_evaluator.py +75 -0
  277. code_muse/plugins/policy_engine/policy_file_discovery.py +90 -0
  278. code_muse/plugins/policy_engine/policy_toml_schema.py +115 -0
  279. code_muse/plugins/policy_engine/register_callbacks.py +112 -0
  280. code_muse/plugins/pop_command/__init__.py +1 -0
  281. code_muse/plugins/pop_command/register_callbacks.py +189 -0
  282. code_muse/plugins/prompt_newline/__init__.py +13 -0
  283. code_muse/plugins/prompt_newline/config.py +19 -0
  284. code_muse/plugins/prompt_newline/register_callbacks.py +159 -0
  285. code_muse/plugins/safety_status/__init__.py +0 -0
  286. code_muse/plugins/safety_status/register_callbacks.py +113 -0
  287. code_muse/plugins/semantic_compression/__init__.py +6 -0
  288. code_muse/plugins/semantic_compression/compressor.py +295 -0
  289. code_muse/plugins/semantic_compression/config.py +123 -0
  290. code_muse/plugins/semantic_compression/register_callbacks.py +320 -0
  291. code_muse/plugins/shell_minimizer/__init__.py +50 -0
  292. code_muse/plugins/shell_minimizer/builtin_filters.toml +393 -0
  293. code_muse/plugins/shell_minimizer/pipeline.py +556 -0
  294. code_muse/plugins/shell_minimizer/primitives.py +482 -0
  295. code_muse/plugins/shell_minimizer/register_callbacks.py +276 -0
  296. code_muse/plugins/shell_safety/__init__.py +6 -0
  297. code_muse/plugins/shell_safety/agent_shell_safety.py +69 -0
  298. code_muse/plugins/shell_safety/command_cache.py +149 -0
  299. code_muse/plugins/shell_safety/register_callbacks.py +202 -0
  300. code_muse/plugins/synthetic_status/__init__.py +1 -0
  301. code_muse/plugins/synthetic_status/register_callbacks.py +128 -0
  302. code_muse/plugins/synthetic_status/status_api.py +145 -0
  303. code_muse/plugins/token_caching/__init__.py +21 -0
  304. code_muse/plugins/token_caching/cache_hit_tracking.py +128 -0
  305. code_muse/plugins/token_caching/cacheable_prefix_detection.py +28 -0
  306. code_muse/plugins/token_caching/register_callbacks.py +54 -0
  307. code_muse/plugins/token_caching/stats_display.py +35 -0
  308. code_muse/plugins/token_tracking/__init__.py +26 -0
  309. code_muse/plugins/token_tracking/database.py +381 -0
  310. code_muse/plugins/token_tracking/edit_analyzer.py +97 -0
  311. code_muse/plugins/token_tracking/record.py +55 -0
  312. code_muse/plugins/token_tracking/register_callbacks.py +277 -0
  313. code_muse/plugins/token_tracking/reports.py +329 -0
  314. code_muse/plugins/universal_constructor/__init__.py +13 -0
  315. code_muse/plugins/universal_constructor/models.py +136 -0
  316. code_muse/plugins/universal_constructor/register_callbacks.py +47 -0
  317. code_muse/plugins/universal_constructor/registry.py +390 -0
  318. code_muse/plugins/universal_constructor/runner.py +474 -0
  319. code_muse/plugins/universal_constructor/safety.py +440 -0
  320. code_muse/plugins/universal_constructor/sandbox.py +584 -0
  321. code_muse/provider_identity.py +105 -0
  322. code_muse/pydantic_patches.py +410 -0
  323. code_muse/reopenable_async_client.py +233 -0
  324. code_muse/round_robin_model.py +151 -0
  325. code_muse/secret_storage.py +74 -0
  326. code_muse/security/__init__.py +1 -0
  327. code_muse/security/redaction.cpython-314-darwin.so +0 -0
  328. code_muse/security/redaction.cpython-314-x86_64-linux-gnu.so +0 -0
  329. code_muse/security/redaction.pyx +135 -0
  330. code_muse/session_storage.py +565 -0
  331. code_muse/status_display.py +261 -0
  332. code_muse/stream_parser/__init__.py +76 -0
  333. code_muse/stream_parser/assistant_text_parser.py +90 -0
  334. code_muse/stream_parser/citation_parser.py +76 -0
  335. code_muse/stream_parser/inline_hidden_tag_parser.py +236 -0
  336. code_muse/stream_parser/proposed_plan_parser.py +158 -0
  337. code_muse/stream_parser/stream_text_chunk.py +23 -0
  338. code_muse/stream_parser/stream_text_parser.py +27 -0
  339. code_muse/stream_parser/tagged_line_parser.cpython-314-darwin.so +0 -0
  340. code_muse/stream_parser/tagged_line_parser.pyx +251 -0
  341. code_muse/stream_parser/utf8_stream_parser.cpython-314-darwin.so +0 -0
  342. code_muse/stream_parser/utf8_stream_parser.pyx +206 -0
  343. code_muse/summarization_agent.py +308 -0
  344. code_muse/terminal_utils.cpython-314-darwin.so +0 -0
  345. code_muse/terminal_utils.cpython-314-x86_64-linux-gnu.so +0 -0
  346. code_muse/terminal_utils.pyx +483 -0
  347. code_muse/tools/__init__.py +459 -0
  348. code_muse/tools/agent_tools.py +613 -0
  349. code_muse/tools/ask_user_question/__init__.py +26 -0
  350. code_muse/tools/ask_user_question/constants.py +73 -0
  351. code_muse/tools/ask_user_question/demo_tui.py +55 -0
  352. code_muse/tools/ask_user_question/handler.py +232 -0
  353. code_muse/tools/ask_user_question/models.py +302 -0
  354. code_muse/tools/ask_user_question/registration.py +37 -0
  355. code_muse/tools/ask_user_question/renderers.py +336 -0
  356. code_muse/tools/ask_user_question/terminal_ui.py +327 -0
  357. code_muse/tools/ask_user_question/theme.py +156 -0
  358. code_muse/tools/ask_user_question/tui_loop.py +422 -0
  359. code_muse/tools/background_jobs.py +99 -0
  360. code_muse/tools/browser/__init__.py +37 -0
  361. code_muse/tools/browser/browser_control.py +289 -0
  362. code_muse/tools/browser/browser_interactions.py +545 -0
  363. code_muse/tools/browser/browser_locators.py +640 -0
  364. code_muse/tools/browser/browser_manager.py +376 -0
  365. code_muse/tools/browser/browser_navigation.py +251 -0
  366. code_muse/tools/browser/browser_screenshot.py +180 -0
  367. code_muse/tools/browser/browser_scripts.py +462 -0
  368. code_muse/tools/browser/browser_workflows.py +222 -0
  369. code_muse/tools/chrome_cdp/__init__.py +1070 -0
  370. code_muse/tools/chrome_cdp/register_callbacks.py +61 -0
  371. code_muse/tools/command_runner.py +1401 -0
  372. code_muse/tools/common.py +1407 -0
  373. code_muse/tools/display.py +87 -0
  374. code_muse/tools/file_modifications.py +1099 -0
  375. code_muse/tools/file_operations.py +860 -0
  376. code_muse/tools/image_tools.py +185 -0
  377. code_muse/tools/meetin_proxy/__init__.py +243 -0
  378. code_muse/tools/meetin_proxy/capture_addon.py +82 -0
  379. code_muse/tools/meetin_proxy/proxy_manager.py +326 -0
  380. code_muse/tools/meetin_proxy/register_callbacks.py +45 -0
  381. code_muse/tools/path_policy.py +219 -0
  382. code_muse/tools/skills_tools.py +586 -0
  383. code_muse/tools/subagent_context.py +158 -0
  384. code_muse/tools/tools_content.py +50 -0
  385. code_muse/tools/universal_constructor.py +965 -0
  386. code_muse/uvx_detection.py +241 -0
  387. code_muse/version_checker.py +86 -0
  388. code_muse-0.0.1.data/data/code_muse/models.json +66 -0
  389. code_muse-0.0.1.data/data/code_muse/models_dev_api.json +1 -0
  390. code_muse-0.0.1.dist-info/METADATA +845 -0
  391. code_muse-0.0.1.dist-info/RECORD +394 -0
  392. code_muse-0.0.1.dist-info/WHEEL +4 -0
  393. code_muse-0.0.1.dist-info/entry_points.txt +2 -0
  394. code_muse-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1099 @@
1
+ """Robust file-modification helpers + agent tools.
2
+
3
+ Key guarantees
4
+ --------------
5
+ 1. **Create/edit operations emit diffs** when there are changes to show.
6
+ 2. **Delete-file operations do not print removed content**; they only report deletion.
7
+ 3. **Full traceback logging** for unexpected errors via `_log_error`.
8
+ 4. Helper functions stay print-free while agent-tool wrappers handle console output.
9
+ """
10
+
11
+ import contextlib
12
+ import difflib
13
+ import json
14
+ import os
15
+ import traceback
16
+ import warnings
17
+ from pathlib import Path
18
+ from typing import Annotated, Any
19
+
20
+ import json_repair
21
+ from pydantic import BaseModel, BeforeValidator, WithJsonSchema
22
+ from pydantic_ai import RunContext
23
+
24
+ from code_muse.callbacks import (
25
+ on_create_file,
26
+ on_delete_file,
27
+ on_delete_snippet,
28
+ on_edit_file,
29
+ on_replace_in_file,
30
+ )
31
+ from code_muse.messaging import ( # Structured messaging types
32
+ DiffLine,
33
+ DiffMessage,
34
+ emit_error,
35
+ emit_success,
36
+ emit_warning,
37
+ get_message_bus,
38
+ )
39
+ from code_muse.tools.common import _find_best_window, generate_group_id
40
+ from code_muse.tools.path_policy import Operation, check_path_allowed
41
+
42
+ # Caps for edits/diffs to avoid unbounded memory use
43
+ MAX_EDIT_FILE_BYTES = 1_000_000
44
+ MAX_DIFF_BYTES = 512_000
45
+
46
+ # Fuzzy replacement fallback limits (P2-04)
47
+ MAX_FUZZY_FILE_LINES = 20_000 # Skip fuzzy on files with more lines
48
+ MAX_FUZZY_OLD_SNIPPET_CHARS = 20_000 # Skip fuzzy when old snippet exceeds this
49
+ MAX_FUZZY_REPLACEMENT_COUNT = 20 # Reject replacements list above this
50
+
51
+
52
+ def _create_rejection_response(file_path: str) -> dict[str, Any]:
53
+ """Create a standardized rejection response with user feedback if available.
54
+
55
+ Args:
56
+ file_path: Path to the file that was rejected
57
+
58
+ Returns:
59
+ Dict containing rejection details and any user feedback
60
+ """
61
+ # Check for user feedback from permission handler
62
+ try:
63
+ from code_muse.plugins.file_permission_handler.register_callbacks import (
64
+ clear_user_feedback,
65
+ get_last_user_feedback,
66
+ )
67
+
68
+ user_feedback = get_last_user_feedback()
69
+ # Clear feedback after reading it
70
+ clear_user_feedback()
71
+ except ImportError:
72
+ user_feedback = None
73
+
74
+ rejection_message = (
75
+ "USER REJECTED: The user explicitly rejected these file changes."
76
+ )
77
+ if user_feedback:
78
+ rejection_message += f" User feedback: {user_feedback}"
79
+ else:
80
+ rejection_message += " Please do not retry the same changes or any other changes - immediately ask for clarification."
81
+
82
+ return {
83
+ "success": False,
84
+ "path": file_path,
85
+ "message": rejection_message,
86
+ "changed": False,
87
+ "user_rejection": True,
88
+ "rejection_type": "explicit_user_denial",
89
+ "user_feedback": user_feedback,
90
+ }
91
+
92
+
93
+ class DeleteSnippetPayload(BaseModel):
94
+ file_path: str
95
+ delete_snippet: str
96
+
97
+
98
+ class Replacement(BaseModel):
99
+ old_str: str
100
+ new_str: str
101
+
102
+
103
+ class ReplacementsPayload(BaseModel):
104
+ file_path: str
105
+ replacements: list[Replacement]
106
+
107
+
108
+ class ContentPayload(BaseModel):
109
+ file_path: str
110
+ content: str
111
+ overwrite: bool = False
112
+
113
+
114
+ EditFilePayload = DeleteSnippetPayload, ReplacementsPayload, ContentPayload
115
+
116
+
117
+ def _parse_diff_lines(diff_text: str) -> list[DiffLine]:
118
+ """Parse unified diff text into structured DiffLine objects.
119
+
120
+ Args:
121
+ diff_text: Raw unified diff text
122
+
123
+ Returns:
124
+ List of DiffLine objects with line numbers and types
125
+ """
126
+ if not diff_text or not diff_text.strip():
127
+ return []
128
+
129
+ diff_lines = []
130
+ line_number = 0
131
+
132
+ for line in diff_text.splitlines():
133
+ # Determine line type based on diff markers
134
+ if line.startswith("+") and not line.startswith("+++"):
135
+ line_type = "add"
136
+ line_number += 1
137
+ content = line[1:] # Remove the + prefix
138
+ elif line.startswith("-") and not line.startswith("---"):
139
+ line_type = "remove"
140
+ line_number += 1
141
+ content = line[1:] # Remove the - prefix
142
+ elif line.startswith("@@"):
143
+ # Parse hunk header to get line number
144
+ # Format: @@ -start,count +start,count @@
145
+ import re
146
+
147
+ match = re.search(r"@@ -\d+(?:,\d+)? \+(\d+)", line)
148
+ if match:
149
+ line_number = (
150
+ int(match.group(1)) - 1
151
+ ) # Will be incremented on next line
152
+ line_type = "context"
153
+ content = line
154
+ elif line.startswith("---") or line.startswith("+++"):
155
+ # File headers - treat as context
156
+ line_type = "context"
157
+ content = line
158
+ else:
159
+ line_type = "context"
160
+ line_number += 1
161
+ content = line
162
+
163
+ diff_lines.append(
164
+ DiffLine(
165
+ line_number=max(1, line_number),
166
+ type=line_type,
167
+ content=content,
168
+ )
169
+ )
170
+
171
+ return diff_lines
172
+
173
+
174
+ def _emit_diff_message(
175
+ file_path: str | Path,
176
+ operation: str,
177
+ diff_text: str,
178
+ old_content: str | None = None,
179
+ new_content: str | None = None,
180
+ ) -> None:
181
+ """Emit a structured DiffMessage for UI display.
182
+
183
+ Args:
184
+ file_path: Path to the file being modified
185
+ operation: One of 'create', 'modify', 'delete'
186
+ diff_text: Raw unified diff text
187
+ old_content: Original file content (optional)
188
+ new_content: New file content (optional)
189
+ """
190
+ # Check if diff was already shown during permission prompt
191
+ try:
192
+ from code_muse.plugins.file_permission_handler.register_callbacks import (
193
+ clear_diff_shown_flag,
194
+ was_diff_already_shown,
195
+ )
196
+
197
+ if was_diff_already_shown():
198
+ # Diff already displayed in permission panel, skip redundant display
199
+ clear_diff_shown_flag()
200
+ return
201
+ except ImportError:
202
+ pass # Permission handler not available, emit anyway
203
+
204
+ if not diff_text or not diff_text.strip():
205
+ return
206
+
207
+ diff_lines = _parse_diff_lines(diff_text)
208
+
209
+ diff_msg = DiffMessage(
210
+ path=str(file_path),
211
+ operation=operation,
212
+ old_content=old_content,
213
+ new_content=new_content,
214
+ diff_lines=diff_lines,
215
+ )
216
+ get_message_bus().emit(diff_msg)
217
+
218
+
219
+ def _log_error(
220
+ msg: str, exc: Exception | None = None, message_group: str | None = None
221
+ ) -> None:
222
+ emit_error(f"{msg}", message_group=message_group)
223
+ if exc is not None:
224
+ emit_error(traceback.format_exc(), highlight=False, message_group=message_group)
225
+
226
+
227
+ def _should_enforce_path_policy(context: RunContext | None) -> bool:
228
+ """Return True if path policy should be enforced for this context.
229
+
230
+ Skips enforcement for None/MagicMock contexts used in unit tests,
231
+ but always enforces for real pydantic_ai RunContext objects.
232
+ """
233
+ if context is None:
234
+ return False
235
+ # Only enforce for real RunContext instances, not test mocks
236
+ return isinstance(context, RunContext)
237
+
238
+
239
+ def _delete_snippet_from_file(
240
+ context: RunContext | None,
241
+ file_path: str,
242
+ snippet: str,
243
+ message_group: str | None = None,
244
+ ) -> dict[str, Any]:
245
+ file_path = Path(file_path).resolve()
246
+
247
+ # Enforce path policy for real agent contexts (skip for test helpers)
248
+ if _should_enforce_path_policy(context):
249
+ policy = check_path_allowed(str(file_path), Operation.WRITE)
250
+ if not policy.allowed:
251
+ return {
252
+ "error": policy.reason or "Edit blocked by path policy.",
253
+ "diff": "",
254
+ }
255
+
256
+ diff_text = ""
257
+ try:
258
+ if not file_path.exists() or not file_path.is_file():
259
+ return {"error": f"File '{file_path}' does not exist.", "diff": diff_text}
260
+
261
+ # Huge-file gate: reject edits before full read
262
+ try:
263
+ if file_path.stat().st_size > MAX_EDIT_FILE_BYTES:
264
+ return {
265
+ "error": (
266
+ f"File is too large to edit safely (> {MAX_EDIT_FILE_BYTES} bytes). "
267
+ "Please break the edit into smaller chunks."
268
+ ),
269
+ "diff": diff_text,
270
+ }
271
+ except OSError:
272
+ pass
273
+
274
+ with open(file_path, encoding="utf-8", errors="surrogateescape") as f:
275
+ original = f.read()
276
+ # Sanitize any surrogate characters from reading
277
+ with contextlib.suppress(UnicodeEncodeError, UnicodeDecodeError):
278
+ original = original.encode("utf-8", errors="surrogatepass").decode(
279
+ "utf-8", errors="replace"
280
+ )
281
+ if snippet not in original:
282
+ return {
283
+ "error": f"Snippet not found in file '{file_path}'.",
284
+ "diff": diff_text,
285
+ }
286
+ modified = original.replace(snippet, "", 1)
287
+ from code_muse.config import get_diff_context_lines
288
+
289
+ diff_text = "".join(
290
+ difflib.unified_diff(
291
+ original.splitlines(keepends=True),
292
+ modified.splitlines(keepends=True),
293
+ fromfile=f"a/{file_path.name}",
294
+ tofile=f"b/{file_path.name}",
295
+ n=get_diff_context_lines(),
296
+ )
297
+ )
298
+ with open(file_path, "w", encoding="utf-8") as f:
299
+ f.write(modified)
300
+ return {
301
+ "success": True,
302
+ "path": str(file_path),
303
+ "message": "Snippet deleted from file.",
304
+ "changed": True,
305
+ "diff": diff_text,
306
+ }
307
+ except Exception as exc:
308
+ return {"error": str(exc), "diff": diff_text}
309
+
310
+
311
+ def _replace_in_file(
312
+ context: RunContext | None,
313
+ path: str,
314
+ replacements: list[dict[str, str]],
315
+ message_group: str | None = None,
316
+ ) -> dict[str, Any]:
317
+ """Robust replacement engine with explicit edge‑case reporting."""
318
+ file_path = Path(path).resolve()
319
+
320
+ # Enforce path policy for real agent contexts (skip for test helpers)
321
+ if _should_enforce_path_policy(context):
322
+ policy = check_path_allowed(str(file_path), Operation.WRITE)
323
+ if not policy.allowed:
324
+ return {
325
+ "error": policy.reason or "Edit blocked by path policy.",
326
+ "diff": "",
327
+ }
328
+
329
+ # P2-04: cap replacement count to bound fuzzy-matching cost
330
+ if len(replacements) > MAX_FUZZY_REPLACEMENT_COUNT:
331
+ return {
332
+ "error": (
333
+ f"Too many replacements ({len(replacements)}); "
334
+ f"maximum is {MAX_FUZZY_REPLACEMENT_COUNT}. "
335
+ "Split into smaller batches."
336
+ ),
337
+ "diff": "",
338
+ }
339
+
340
+ diff_text = ""
341
+ try:
342
+ if not file_path.exists() or not file_path.is_file():
343
+ return {"error": f"File '{file_path}' does not exist.", "diff": diff_text}
344
+
345
+ # Huge-file gate: reject edits before full read
346
+ try:
347
+ if file_path.stat().st_size > MAX_EDIT_FILE_BYTES:
348
+ return {
349
+ "error": (
350
+ f"File is too large to edit safely (> {MAX_EDIT_FILE_BYTES} bytes). "
351
+ "Please break the edit into smaller chunks."
352
+ ),
353
+ "diff": diff_text,
354
+ }
355
+ except OSError:
356
+ pass
357
+
358
+ with open(file_path, encoding="utf-8", errors="surrogateescape") as f:
359
+ original = f.read()
360
+
361
+ # Sanitize any surrogate characters from reading
362
+ with contextlib.suppress(UnicodeEncodeError, UnicodeDecodeError):
363
+ original = original.encode("utf-8", errors="surrogatepass").decode(
364
+ "utf-8", errors="replace"
365
+ )
366
+
367
+ # P2-04: pre-compute file line count once for fuzzy-limit check
368
+ original_line_count = original.count("\n") + (
369
+ 1 if original and not original.endswith("\n") else 0
370
+ )
371
+
372
+ modified = original
373
+ for rep in replacements:
374
+ old_snippet = rep.get("old_str", "")
375
+ new_snippet = rep.get("new_str", "")
376
+
377
+ if old_snippet and old_snippet in modified:
378
+ modified = modified.replace(old_snippet, new_snippet, 1)
379
+ continue
380
+
381
+ # --- P2-04: fuzzy fallback limits ---
382
+ # Skip expensive Jaro-Winkler sliding window on large inputs
383
+ fuzzy_blocked_reasons: list[str] = []
384
+ if original_line_count > MAX_FUZZY_FILE_LINES:
385
+ fuzzy_blocked_reasons.append(
386
+ f"file has {original_line_count} lines "
387
+ f"(limit {MAX_FUZZY_FILE_LINES})"
388
+ )
389
+ if len(old_snippet) > MAX_FUZZY_OLD_SNIPPET_CHARS:
390
+ fuzzy_blocked_reasons.append(
391
+ f"old_str is {len(old_snippet)} chars "
392
+ f"(limit {MAX_FUZZY_OLD_SNIPPET_CHARS})"
393
+ )
394
+
395
+ if fuzzy_blocked_reasons:
396
+ return {
397
+ "error": (
398
+ f"Exact match not found and fuzzy fallback skipped: "
399
+ f"{'; '.join(fuzzy_blocked_reasons)}. "
400
+ "Provide the exact text to replace, or break the "
401
+ "edit into smaller chunks targeting a smaller region."
402
+ ),
403
+ "fuzzy_skipped": True,
404
+ "diff": "",
405
+ }
406
+ # --- end P2-04 limits ---
407
+
408
+ had_trailing_newline = modified.endswith("\n")
409
+ # Work on a copy so we never mutate the running buffer
410
+ orig_lines = modified.splitlines()
411
+ loc, score = _find_best_window(orig_lines, old_snippet)
412
+
413
+ if score < 0.95 or loc is None:
414
+ return {
415
+ "error": "No suitable match in file (JW < 0.95)",
416
+ "jw_score": score,
417
+ "received": old_snippet,
418
+ "diff": "",
419
+ }
420
+
421
+ start, end = loc
422
+ prefix = "\n".join(orig_lines[:start])
423
+ suffix = "\n".join(orig_lines[end:])
424
+ parts = []
425
+ if prefix:
426
+ parts.append(prefix)
427
+ parts.append(new_snippet.rstrip("\n"))
428
+ if suffix:
429
+ parts.append(suffix)
430
+ modified = "\n".join(parts)
431
+ if had_trailing_newline and not modified.endswith("\n"):
432
+ modified += "\n"
433
+
434
+ if modified == original:
435
+ emit_warning(
436
+ "No changes to apply – proposed content is identical.",
437
+ message_group=message_group,
438
+ )
439
+ return {
440
+ "success": False,
441
+ "path": str(file_path),
442
+ "message": "No changes to apply.",
443
+ "changed": False,
444
+ "diff": "",
445
+ }
446
+
447
+ from code_muse.config import get_diff_context_lines
448
+
449
+ diff_text = "".join(
450
+ difflib.unified_diff(
451
+ original.splitlines(keepends=True),
452
+ modified.splitlines(keepends=True),
453
+ fromfile=f"a/{file_path.name}",
454
+ tofile=f"b/{file_path.name}",
455
+ n=get_diff_context_lines(),
456
+ )
457
+ )
458
+ if len(diff_text) > MAX_DIFF_BYTES:
459
+ trunc_msg = f"\n\n[Diff truncated: exceeded {MAX_DIFF_BYTES} bytes]\n"
460
+ diff_text = diff_text[: MAX_DIFF_BYTES - len(trunc_msg)] + trunc_msg
461
+ with open(file_path, "w", encoding="utf-8") as f:
462
+ f.write(modified)
463
+ return {
464
+ "success": True,
465
+ "path": str(file_path),
466
+ "message": "Replacements applied.",
467
+ "changed": True,
468
+ "diff": diff_text,
469
+ }
470
+ except Exception as exc:
471
+ return {"error": str(exc), "diff": diff_text}
472
+
473
+
474
+ def _write_to_file(
475
+ context: RunContext | None,
476
+ path: str,
477
+ content: str,
478
+ overwrite: bool = False,
479
+ message_group: str | None = None,
480
+ ) -> dict[str, Any]:
481
+ file_path = Path(path).resolve()
482
+
483
+ # Enforce path policy for real agent contexts (skip for test helpers)
484
+ if _should_enforce_path_policy(context):
485
+ policy = check_path_allowed(str(file_path), Operation.WRITE)
486
+ if not policy.allowed:
487
+ return {
488
+ "error": policy.reason or "Write blocked by path policy.",
489
+ "diff": "",
490
+ }
491
+
492
+ try:
493
+ exists = file_path.exists()
494
+ if exists and not overwrite:
495
+ return {
496
+ "success": False,
497
+ "path": str(file_path),
498
+ "message": f"Cowardly refusing to overwrite existing file: {file_path}",
499
+ "changed": False,
500
+ "diff": "",
501
+ }
502
+
503
+ from code_muse.config import get_diff_context_lines
504
+
505
+ if exists:
506
+ with open(file_path, encoding="utf-8", errors="surrogateescape") as f:
507
+ old_content = f.read()
508
+ with contextlib.suppress(UnicodeEncodeError, UnicodeDecodeError):
509
+ old_content = old_content.encode(
510
+ "utf-8", errors="surrogatepass"
511
+ ).decode("utf-8", errors="replace")
512
+ old_lines = old_content.splitlines(keepends=True)
513
+ else:
514
+ old_lines = []
515
+
516
+ diff_lines = difflib.unified_diff(
517
+ old_lines,
518
+ content.splitlines(keepends=True),
519
+ fromfile="/dev/null" if not exists else f"a/{file_path.name}",
520
+ tofile=f"b/{file_path.name}",
521
+ n=get_diff_context_lines(),
522
+ )
523
+ diff_text = "".join(diff_lines)
524
+ if len(diff_text) > MAX_DIFF_BYTES:
525
+ trunc_msg = f"\n\n[Diff truncated: exceeded {MAX_DIFF_BYTES} bytes]\n"
526
+ diff_text = diff_text[: MAX_DIFF_BYTES - len(trunc_msg)] + trunc_msg
527
+
528
+ file_path.parent.mkdir(parents=True, exist_ok=True)
529
+ with open(file_path, "w", encoding="utf-8") as f:
530
+ f.write(content)
531
+
532
+ action = "overwritten" if exists else "created"
533
+ return {
534
+ "success": True,
535
+ "path": str(file_path),
536
+ "message": f"File '{file_path}' {action} successfully.",
537
+ "changed": True,
538
+ "diff": diff_text,
539
+ }
540
+
541
+ except Exception as exc:
542
+ _log_error("Unhandled exception in write_to_file", exc)
543
+ return {"error": str(exc), "diff": ""}
544
+
545
+
546
+ def delete_snippet_from_file(
547
+ context: RunContext,
548
+ file_path: str | Path,
549
+ snippet: str,
550
+ message_group: str | None = None,
551
+ ) -> dict[str, Any]:
552
+ # Use the plugin system for permission handling with operation data
553
+ from code_muse.callbacks import on_file_permission
554
+
555
+ operation_data = {"snippet": snippet}
556
+ permission_results = on_file_permission(
557
+ context, file_path, "delete snippet from", None, message_group, operation_data
558
+ )
559
+
560
+ # If any permission handler denies the operation, return cancelled result
561
+ if permission_results and any(
562
+ not result for result in permission_results if result is not None
563
+ ):
564
+ return _create_rejection_response(file_path)
565
+
566
+ res = _delete_snippet_from_file(
567
+ context, file_path, snippet, message_group=message_group
568
+ )
569
+ diff = res.get("diff", "")
570
+ if diff:
571
+ _emit_diff_message(file_path, "modify", diff)
572
+ return res
573
+
574
+
575
+ def write_to_file(
576
+ context: RunContext,
577
+ path: str,
578
+ content: str,
579
+ overwrite: bool,
580
+ message_group: str | None = None,
581
+ ) -> dict[str, Any]:
582
+ # Use the plugin system for permission handling with operation data
583
+ from code_muse.callbacks import on_file_permission
584
+
585
+ operation_data = {"content": content, "overwrite": overwrite}
586
+ permission_results = on_file_permission(
587
+ context, path, "write", None, message_group, operation_data
588
+ )
589
+
590
+ # If any permission handler denies the operation, return cancelled result
591
+ if permission_results and any(
592
+ not result for result in permission_results if result is not None
593
+ ):
594
+ return _create_rejection_response(path)
595
+
596
+ res = _write_to_file(
597
+ context, path, content, overwrite=overwrite, message_group=message_group
598
+ )
599
+ diff = res.get("diff", "")
600
+ if diff:
601
+ # Determine operation type based on whether file existed
602
+ operation = "modify" if overwrite else "create"
603
+ _emit_diff_message(path, operation, diff, new_content=content)
604
+ return res
605
+
606
+
607
+ def replace_in_file(
608
+ context: RunContext,
609
+ path: str,
610
+ replacements: list[dict[str, str]],
611
+ message_group: str | None = None,
612
+ ) -> dict[str, Any]:
613
+ # Use the plugin system for permission handling with operation data
614
+ from code_muse.callbacks import on_file_permission
615
+
616
+ operation_data = {"replacements": replacements}
617
+ permission_results = on_file_permission(
618
+ context, path, "replace text in", None, message_group, operation_data
619
+ )
620
+
621
+ # If any permission handler denies the operation, return cancelled result
622
+ if permission_results and any(
623
+ not result for result in permission_results if result is not None
624
+ ):
625
+ return _create_rejection_response(path)
626
+
627
+ res = _replace_in_file(context, path, replacements, message_group=message_group)
628
+ diff = res.get("diff", "")
629
+ if diff:
630
+ _emit_diff_message(path, "modify", diff)
631
+ return res
632
+
633
+
634
+ def _edit_file(
635
+ context: RunContext, payload: EditFilePayload, group_id: str | None = None
636
+ ) -> dict[str, Any]:
637
+ """
638
+ High-level implementation of the *edit_file* behaviour.
639
+
640
+ This function performs the heavy-lifting after the lightweight agent-exposed wrapper has
641
+ validated / coerced the inbound *payload* to one of the Pydantic models declared at the top
642
+ of this module.
643
+
644
+ Supported payload variants
645
+ --------------------------
646
+ • **ContentPayload** – full file write / overwrite.
647
+ • **ReplacementsPayload** – targeted in-file replacements.
648
+ • **DeleteSnippetPayload** – remove an exact snippet.
649
+
650
+ The helper decides which low-level routine to delegate to and ensures the resulting unified
651
+ diff is always returned so the caller can pretty-print it for the user.
652
+
653
+ Parameters
654
+ ----------
655
+ path : str
656
+ Path to the target file (relative or absolute)
657
+ diff : str
658
+ Either:
659
+ * Raw file content (for file creation)
660
+ * A JSON string with one of the following shapes:
661
+ {"content": "full file contents", "overwrite": true}
662
+ {"replacements": [ {"old_str": "foo", "new_str": "bar"}, ... ] }
663
+ {"delete_snippet": "text to remove"}
664
+
665
+ The function auto-detects the payload type and routes to the appropriate internal helper.
666
+ """
667
+ # Extract file_path from payload
668
+ file_path = Path(payload.file_path).resolve()
669
+
670
+ # Use provided group_id or generate one if not provided
671
+ if group_id is None:
672
+ group_id = generate_group_id("edit_file", file_path)
673
+
674
+ try:
675
+ if isinstance(payload, DeleteSnippetPayload):
676
+ return delete_snippet_from_file(
677
+ context, file_path, payload.delete_snippet, message_group=group_id
678
+ )
679
+ elif isinstance(payload, ReplacementsPayload):
680
+ # Convert Pydantic Replacement models to dict format for legacy compatibility
681
+ replacements_dict = [
682
+ {"old_str": rep.old_str, "new_str": rep.new_str}
683
+ for rep in payload.replacements
684
+ ]
685
+ return replace_in_file(
686
+ context, str(file_path), replacements_dict, message_group=group_id
687
+ )
688
+ elif isinstance(payload, ContentPayload):
689
+ file_exists = file_path.exists()
690
+ if file_exists and not payload.overwrite:
691
+ return {
692
+ "success": False,
693
+ "path": str(file_path),
694
+ "message": f"File '{file_path}' exists. Set 'overwrite': true to replace.",
695
+ "changed": False,
696
+ }
697
+ return write_to_file(
698
+ context,
699
+ str(file_path),
700
+ payload.content,
701
+ payload.overwrite,
702
+ message_group=group_id,
703
+ )
704
+ else:
705
+ return {
706
+ "success": False,
707
+ "path": str(file_path),
708
+ "message": f"Unknown payload type: {type(payload)}",
709
+ "changed": False,
710
+ }
711
+ except Exception as e:
712
+ emit_error(
713
+ "Unable to route file modification tool call to sub-tool",
714
+ message_group=group_id,
715
+ )
716
+ emit_error(str(e), message_group=group_id)
717
+ return {
718
+ "success": False,
719
+ "path": file_path,
720
+ "message": f"Something went wrong in file editing: {str(e)}",
721
+ "changed": False,
722
+ }
723
+
724
+
725
+ def _delete_file(
726
+ context: RunContext, file_path: str, message_group: str | None = None
727
+ ) -> dict[str, Any]:
728
+ file_path = Path(file_path).resolve()
729
+
730
+ # Enforce path policy for real agent contexts (skip for test helpers)
731
+ if _should_enforce_path_policy(context):
732
+ policy = check_path_allowed(str(file_path), Operation.DELETE)
733
+ if not policy.allowed:
734
+ return {"error": policy.reason or "Delete blocked by path policy."}
735
+
736
+ # Use the plugin system for permission handling with operation data
737
+ from code_muse.callbacks import on_file_permission
738
+
739
+ operation_data = {} # No additional data needed for delete operations
740
+ permission_results = on_file_permission(
741
+ context, str(file_path), "delete", None, message_group, operation_data
742
+ )
743
+
744
+ # If any permission handler denies the operation, return cancelled result
745
+ if permission_results and any(
746
+ not result for result in permission_results if result is not None
747
+ ):
748
+ return _create_rejection_response(str(file_path))
749
+
750
+ try:
751
+ if not file_path.exists() or not file_path.is_file():
752
+ return {"error": f"File '{file_path}' does not exist."}
753
+
754
+ os.remove(file_path)
755
+ with contextlib.suppress(Exception):
756
+ emit_success(f"Deleted file: {file_path}", message_group=message_group)
757
+ return {
758
+ "success": True,
759
+ "path": str(file_path),
760
+ "message": f"File '{file_path}' deleted successfully.",
761
+ "changed": True,
762
+ }
763
+ except Exception as exc:
764
+ _log_error("Unhandled exception in delete_file", exc)
765
+ return {"error": str(exc)}
766
+
767
+
768
+ def register_edit_file(agent):
769
+ """Register only the edit_file tool.
770
+
771
+ .. deprecated::
772
+ Use register_create_file, register_replace_in_file, and
773
+ register_delete_snippet instead. edit_file is auto-expanded
774
+ to these three tools when listed in an agent's tool config.
775
+ """
776
+ warnings.warn(
777
+ "register_edit_file() is deprecated. Use register_create_file, "
778
+ "register_replace_in_file, and register_delete_snippet instead. "
779
+ "Agents listing 'edit_file' in their tools config will automatically "
780
+ "get the three new tools via TOOL_EXPANSIONS.",
781
+ DeprecationWarning,
782
+ stacklevel=2,
783
+ )
784
+
785
+ @agent.tool
786
+ def edit_file(
787
+ context: RunContext,
788
+ payload: EditFilePayload | str = "",
789
+ ) -> dict[str, Any]:
790
+ """Comprehensive file editing tool supporting multiple modification strategies.
791
+
792
+ Supports: ContentPayload (create/overwrite), ReplacementsPayload (targeted edits),
793
+ DeleteSnippetPayload (remove text). Prefer ReplacementsPayload for existing files.
794
+ """
795
+ # Handle string payload parsing (for models that send JSON strings)
796
+
797
+ parse_error_message = "Payload must contain one of: 'content', 'replacements', or 'delete_snippet' with a 'file_path'."
798
+
799
+ if isinstance(payload, str):
800
+ try:
801
+ # Fallback for weird models that just can't help but send json strings...
802
+ payload_dict = json.loads(json_repair.repair_json(payload))
803
+ if "replacements" in payload_dict:
804
+ payload = ReplacementsPayload(**payload_dict)
805
+ elif "delete_snippet" in payload_dict:
806
+ payload = DeleteSnippetPayload(**payload_dict)
807
+ elif "content" in payload_dict:
808
+ payload = ContentPayload(**payload_dict)
809
+ else:
810
+ file_path = "Unknown"
811
+ if "file_path" in payload_dict:
812
+ file_path = payload_dict["file_path"]
813
+ return {
814
+ "success": False,
815
+ "path": file_path,
816
+ "message": parse_error_message,
817
+ "changed": False,
818
+ }
819
+ except Exception as e:
820
+ return {
821
+ "success": False,
822
+ "path": "Not retrievable in Payload",
823
+ "message": f"edit_file call failed: {str(e)} - {parse_error_message}",
824
+ "changed": False,
825
+ }
826
+
827
+ # Call _edit_file which will extract file_path from payload and handle group_id generation
828
+ result = _edit_file(context, payload)
829
+ if "diff" in result:
830
+ del result["diff"]
831
+
832
+ # Trigger edit_file callbacks to enhance the result with rejection details
833
+ enhanced_results = on_edit_file(context, result, payload)
834
+ if enhanced_results:
835
+ # Use the first non-None enhanced result
836
+ for enhanced_result in enhanced_results:
837
+ if enhanced_result is not None:
838
+ result = enhanced_result
839
+ break
840
+
841
+ return result
842
+
843
+
844
+ def register_delete_file(agent):
845
+ """Register only the delete_file tool."""
846
+
847
+ @agent.tool
848
+ def delete_file(context: RunContext, file_path: str = "") -> dict[str, Any]:
849
+ """Safely delete a file and report the deletion.
850
+
851
+ Delete operations intentionally do not generate or print diffs of removed content.
852
+ """
853
+ # Generate group_id for delete_file tool execution
854
+ group_id = generate_group_id("delete_file", file_path)
855
+ result = _delete_file(context, file_path, message_group=group_id)
856
+ if "diff" in result:
857
+ del result["diff"]
858
+
859
+ # Trigger delete_file callbacks to enhance the result with rejection details
860
+ enhanced_results = on_delete_file(context, result, file_path)
861
+ if enhanced_results:
862
+ # Use the first non-None enhanced result
863
+ for enhanced_result in enhanced_results:
864
+ if enhanced_result is not None:
865
+ result = enhanced_result
866
+ break
867
+
868
+ return result
869
+
870
+
871
+ # Module-level aliases captured before registration functions are defined.
872
+ # Inside register_replace_in_file, the @agent.tool decorator creates a local
873
+ # function named 'replace_in_file' which shadows the module-level helper of the
874
+ # same name for the entire enclosing scope (Python scoping rules). We capture
875
+ # a reference here so the registration function can call the helper.
876
+ _replace_in_file_helper = replace_in_file
877
+
878
+
879
+ def register_create_file(agent):
880
+ """Register the create_file tool for creating or overwriting files."""
881
+ # Local alias to avoid shadowing by the @agent.tool decorated function below
882
+ _write_file = write_to_file
883
+
884
+ @agent.tool
885
+ def create_file(
886
+ context: RunContext,
887
+ file_path: str = "",
888
+ content: str = "",
889
+ overwrite: bool = False,
890
+ ) -> dict[str, Any]:
891
+ """Create a new file or overwrite an existing one with the provided content."""
892
+ group_id = generate_group_id("create_file", file_path)
893
+ result = _write_file(
894
+ context, file_path, content, overwrite, message_group=group_id
895
+ )
896
+ if "diff" in result:
897
+ del result["diff"]
898
+
899
+ # Trigger create_file callbacks
900
+ enhanced_results = on_create_file(context, result, file_path, content)
901
+ if enhanced_results:
902
+ for enhanced_result in enhanced_results:
903
+ if enhanced_result is not None:
904
+ result = enhanced_result
905
+ break
906
+
907
+ # Trigger legacy edit_file callbacks for backward compatibility
908
+ payload = ContentPayload(
909
+ file_path=file_path, content=content, overwrite=overwrite
910
+ )
911
+ enhanced_results = on_edit_file(context, result, payload)
912
+ if enhanced_results:
913
+ for enhanced_result in enhanced_results:
914
+ if enhanced_result is not None:
915
+ result = enhanced_result
916
+ break
917
+
918
+ return result
919
+
920
+
921
+ # Inline JSON schema for Replacement objects — avoids $defs/$ref that many
922
+ # LLM providers misinterpret, causing frequent validation errors and
923
+ # fallback to full-file rewrites.
924
+ _REPLACEMENT_ITEM_SCHEMA = {
925
+ "type": "object",
926
+ "properties": {
927
+ "old_str": {"type": "string"},
928
+ "new_str": {"type": "string"},
929
+ },
930
+ "required": ["old_str", "new_str"],
931
+ }
932
+
933
+ # Type alias used by the tool signature. The Annotated + WithJsonSchema
934
+ # tells Pydantic to emit _REPLACEMENT_ITEM_SCHEMA inline instead of a $ref.
935
+ InlineReplacement = Annotated[dict[str, str], WithJsonSchema(_REPLACEMENT_ITEM_SCHEMA)]
936
+
937
+
938
+ def _try_json_repair(v: Any) -> Any:
939
+ """Best-effort: turn a JSON-ish string into a real Python value.
940
+
941
+ Returns the parsed object on success, or the original ``v`` unchanged on
942
+ failure (or if ``v`` isn't a string in the first place). Used by both the
943
+ outer list coercion and the per-item validation in ``replace_in_file``.
944
+ """
945
+ if not isinstance(v, str):
946
+ return v
947
+ try:
948
+ return json.loads(json_repair.repair_json(v))
949
+ except Exception:
950
+ return v
951
+
952
+
953
+ def _coerce_replacements_arg(v: Any) -> Any:
954
+ """Coerce a stringified JSON array back into an actual list.
955
+
956
+ Some tool-call serializers (looking at you, certain LLM clients) stringify
957
+ list arguments into JSON before shipping them. Pydantic would otherwise
958
+ reject those with ``Input should be a valid array``. We intercept strings
959
+ here, best-effort parse them via ``json_repair``, and hand a real list to
960
+ the normal validator. Non-strings pass through untouched so regular list
961
+ inputs keep their fast path.
962
+ """
963
+ return _try_json_repair(v)
964
+
965
+
966
+ # List type that tolerates JSON-string-encoded arrays coming from the wire.
967
+ # BeforeValidator runs prior to type validation, so the advertised JSON schema
968
+ # (array of InlineReplacement) is unchanged — only inbound coercion is widened.
969
+ RepairableReplacementsList = Annotated[
970
+ list[InlineReplacement],
971
+ BeforeValidator(_coerce_replacements_arg),
972
+ ]
973
+
974
+
975
+ def register_replace_in_file(agent):
976
+ """Register the replace_in_file tool for targeted text replacements."""
977
+
978
+ @agent.tool
979
+ def replace_in_file(
980
+ context: RunContext,
981
+ file_path: str = "",
982
+ replacements: RepairableReplacementsList | None = None,
983
+ ) -> dict[str, Any]:
984
+ """Apply targeted text replacements to an existing file.
985
+
986
+ Each replacement specifies an old_str to find and a new_str to replace it with.
987
+ Replacements are applied sequentially. Prefer this over full file rewrites.
988
+ """
989
+ group_id = generate_group_id("replace_in_file", file_path)
990
+ replacements = replacements or []
991
+ if not replacements:
992
+ return {
993
+ "error": "No replacements provided. 'replacements' is required and must not be empty.",
994
+ }
995
+ try:
996
+ # Validate replacements up front so a malformed payload from the
997
+ # model returns a clean error instead of bubbling a KeyError up
998
+ # through pydantic_ai and tearing down the whole agent run.
999
+ normalized: list[dict[str, str]] = []
1000
+ for idx, raw in enumerate(replacements):
1001
+ # Per-item json_repair: some models stringify each replacement
1002
+ # individually (e.g. ["{\"old_str\": ...}", ...]). Heal those
1003
+ # before strict validation so we don't reject recoverable input.
1004
+ r = _try_json_repair(raw)
1005
+ if not isinstance(r, dict):
1006
+ return {
1007
+ "error": (
1008
+ f"replacements[{idx}] must be an object with "
1009
+ f"'old_str' and 'new_str' keys, got {type(raw).__name__}."
1010
+ )
1011
+ }
1012
+ missing = [k for k in ("old_str", "new_str") if k not in r]
1013
+ if missing:
1014
+ return {
1015
+ "error": (
1016
+ f"replacements[{idx}] is missing required key(s): "
1017
+ f"{', '.join(missing)}. Each replacement must include "
1018
+ f"both 'old_str' and 'new_str'."
1019
+ )
1020
+ }
1021
+ normalized.append({"old_str": r["old_str"], "new_str": r["new_str"]})
1022
+
1023
+ result = _replace_in_file_helper(
1024
+ context, file_path, normalized, message_group=group_id
1025
+ )
1026
+ if "diff" in result:
1027
+ del result["diff"]
1028
+
1029
+ # Trigger replace_in_file callbacks
1030
+ enhanced_results = on_replace_in_file(
1031
+ context, result, file_path, normalized
1032
+ )
1033
+ if enhanced_results:
1034
+ for enhanced_result in enhanced_results:
1035
+ if enhanced_result is not None:
1036
+ result = enhanced_result
1037
+ break
1038
+
1039
+ # Trigger legacy edit_file callbacks for backward compatibility
1040
+ payload = ReplacementsPayload(
1041
+ file_path=file_path,
1042
+ replacements=[
1043
+ Replacement(old_str=r["old_str"], new_str=r["new_str"])
1044
+ for r in normalized
1045
+ ],
1046
+ )
1047
+ enhanced_results = on_edit_file(context, result, payload)
1048
+ if enhanced_results:
1049
+ for enhanced_result in enhanced_results:
1050
+ if enhanced_result is not None:
1051
+ result = enhanced_result
1052
+ break
1053
+
1054
+ return result
1055
+ except Exception as exc:
1056
+ # Last line of defense — never let this tool crash the agent run.
1057
+ _log_error(
1058
+ "Unhandled exception in replace_in_file",
1059
+ exc,
1060
+ message_group=group_id,
1061
+ )
1062
+ return {"error": f"replace_in_file failed: {exc}"}
1063
+
1064
+
1065
+ def register_delete_snippet(agent):
1066
+ """Register the delete_snippet tool for removing text from files."""
1067
+ # Local alias to avoid shadowing by the @agent.tool decorated function below
1068
+ _remove_snippet = delete_snippet_from_file
1069
+
1070
+ @agent.tool
1071
+ def delete_snippet(
1072
+ context: RunContext,
1073
+ file_path: str = "",
1074
+ snippet: str = "",
1075
+ ) -> dict[str, Any]:
1076
+ """Remove the first occurrence of a text snippet from a file."""
1077
+ group_id = generate_group_id("delete_snippet", file_path)
1078
+ result = _remove_snippet(context, file_path, snippet, message_group=group_id)
1079
+ if "diff" in result:
1080
+ del result["diff"]
1081
+
1082
+ # Trigger delete_snippet callbacks
1083
+ enhanced_results = on_delete_snippet(context, result, file_path, snippet)
1084
+ if enhanced_results:
1085
+ for enhanced_result in enhanced_results:
1086
+ if enhanced_result is not None:
1087
+ result = enhanced_result
1088
+ break
1089
+
1090
+ # Trigger legacy edit_file callbacks for backward compatibility
1091
+ payload = DeleteSnippetPayload(file_path=file_path, delete_snippet=snippet)
1092
+ enhanced_results = on_edit_file(context, result, payload)
1093
+ if enhanced_results:
1094
+ for enhanced_result in enhanced_results:
1095
+ if enhanced_result is not None:
1096
+ result = enhanced_result
1097
+ break
1098
+
1099
+ return result