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,860 @@
1
+ # file_operations.py
2
+
3
+ import contextlib
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ from collections import deque
9
+ from pathlib import Path
10
+
11
+ from pydantic import BaseModel, conint
12
+ from pydantic_ai import RunContext
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Module-level helper functions (exposed for unit tests _and_ used as tools)
16
+ # ---------------------------------------------------------------------------
17
+ from code_muse.messaging import ( # New structured messaging types
18
+ FileContentMessage,
19
+ FileEntry,
20
+ FileListingMessage,
21
+ GrepMatch,
22
+ GrepResultMessage,
23
+ get_message_bus,
24
+ )
25
+ from code_muse.tools.path_policy import Operation, check_path_allowed
26
+
27
+ # Caps for listing / reading to avoid unbounded memory and model-context blowup
28
+ MAX_LIST_FILES_UI_ENTRIES = 5_000
29
+ MAX_LIST_FILES_LLM_ENTRIES = 1_000
30
+ MAX_READ_FILE_BYTES = 128_000
31
+ MAX_GREP_MATCHES = 50
32
+ MAX_GREP_LINE_LENGTH = 512
33
+
34
+
35
+ # Pydantic models for tool return types
36
+ class ListedFile(BaseModel):
37
+ path: str | None
38
+ type: str | None
39
+ size: int = 0
40
+ full_path: str | None
41
+ depth: int | None
42
+
43
+
44
+ class ListFileOutput(BaseModel):
45
+ content: str
46
+ error: str | None = None
47
+
48
+
49
+ class ReadFileOutput(BaseModel):
50
+ content: str | None
51
+ num_tokens: conint(lt=10000)
52
+ error: str | None = None
53
+
54
+
55
+ class MatchInfo(BaseModel):
56
+ file_path: str | None
57
+ line_number: int | None
58
+ line_content: str | None
59
+
60
+
61
+ class GrepOutput(BaseModel):
62
+ matches: list[MatchInfo]
63
+ error: str | None = None
64
+
65
+
66
+ def is_likely_home_directory(directory):
67
+ """Detect if directory is likely a user's home directory or common home subdirectory"""
68
+ abs_dir = Path(directory).resolve()
69
+ home_dir = Path.home()
70
+
71
+ # Exact home directory match
72
+ if abs_dir == home_dir:
73
+ return True
74
+
75
+ # Check for common home directory subdirectories
76
+ common_home_subdirs = {
77
+ "Documents",
78
+ "Desktop",
79
+ "Downloads",
80
+ "Pictures",
81
+ "Music",
82
+ "Videos",
83
+ "Movies",
84
+ "Public",
85
+ "Library",
86
+ "Applications", # Cover macOS/Linux
87
+ }
88
+ return bool(abs_dir.name in common_home_subdirs and abs_dir.parent == home_dir)
89
+
90
+
91
+ def is_project_directory(directory):
92
+ """Quick heuristic to detect if this looks like a project directory"""
93
+ project_indicators = {
94
+ "package.json",
95
+ "pyproject.toml",
96
+ "Cargo.toml",
97
+ "pom.xml",
98
+ "build.gradle",
99
+ "CMakeLists.txt",
100
+ ".git",
101
+ "requirements.txt",
102
+ "composer.json",
103
+ "Gemfile",
104
+ "go.mod",
105
+ "Makefile",
106
+ "setup.py",
107
+ }
108
+
109
+ try:
110
+ contents = os.listdir(directory)
111
+ return any(indicator in contents for indicator in project_indicators)
112
+ except OSError:
113
+ return False
114
+
115
+
116
+ def would_match_directory(pattern: str, directory: str) -> bool:
117
+ """Check if a glob pattern would match the given directory path.
118
+
119
+ This is used to avoid adding ignore patterns that would inadvertently
120
+ exclude the directory we're actually trying to search in.
121
+
122
+ Args:
123
+ pattern: A glob pattern like '**/tmp/**' or 'node_modules'
124
+ directory: The directory path to check against
125
+
126
+ Returns:
127
+ True if the pattern would match the directory, False otherwise
128
+ """
129
+ import fnmatch
130
+
131
+ # Normalize the directory path
132
+ abs_dir = Path(directory).resolve()
133
+ dir_name = abs_dir.name
134
+
135
+ # Strip leading/trailing wildcards and slashes for simpler matching
136
+ clean_pattern = pattern.strip("*").strip("/")
137
+
138
+ # Check if the directory name matches the pattern
139
+ if fnmatch.fnmatch(dir_name, clean_pattern):
140
+ return True
141
+
142
+ # Check if the full path contains the pattern
143
+ if fnmatch.fnmatch(abs_dir, pattern):
144
+ return True
145
+
146
+ # Check if any part of the path matches
147
+ path_parts = str(abs_dir).split(os.sep)
148
+ return any(fnmatch.fnmatch(part, clean_pattern) for part in path_parts)
149
+
150
+
151
+ def _list_files(
152
+ context: RunContext, directory: str = ".", recursive: bool = True
153
+ ) -> ListFileOutput:
154
+ import sys
155
+
156
+ directory = Path(directory).expanduser().resolve()
157
+
158
+ # Enforce workspace / sensitive directory policy before listing
159
+ policy = check_path_allowed(str(directory), Operation.LIST)
160
+ if not policy.allowed:
161
+ error_msg = policy.reason or "Directory listing blocked by path policy."
162
+ return ListFileOutput(content=error_msg, error=error_msg)
163
+
164
+ # Plain text output for LLM consumption
165
+ output_lines = []
166
+ output_lines.append(f"DIRECTORY LISTING: {directory} (recursive={recursive})")
167
+
168
+ if not directory.exists():
169
+ error_msg = f"Error: Directory '{directory}' does not exist"
170
+ return ListFileOutput(content=error_msg, error=error_msg)
171
+ if not directory.is_dir():
172
+ error_msg = f"Error: '{directory}' is not a directory"
173
+ return ListFileOutput(content=error_msg, error=error_msg)
174
+
175
+ results = []
176
+
177
+ # Smart home directory detection - auto-limit recursion for performance
178
+ # But allow recursion in tests (when context=None) or when explicitly requested
179
+ if context is not None and is_likely_home_directory(str(directory)) and recursive:
180
+ if not is_project_directory(str(directory)):
181
+ output_lines.append(
182
+ "Warning: Detected home directory - limiting to non-recursive listing for performance"
183
+ )
184
+ recursive = False
185
+
186
+ # Create a temporary ignore file with our ignore patterns
187
+ ignore_file = None
188
+ try:
189
+ # Find ripgrep executable - first check system PATH, then virtual environment
190
+ rg_path = shutil.which("rg")
191
+ if not rg_path:
192
+ # Try to find it in the virtual environment
193
+ # Use sys.executable to determine the Python environment path
194
+ python_dir = Path(sys.executable).parent
195
+ # python_dir is already bin/ (Unix) or Scripts/ (Windows)
196
+ for name in ["rg", "rg.exe"]:
197
+ candidate = python_dir / name
198
+ if candidate.exists():
199
+ rg_path = str(candidate)
200
+ break
201
+
202
+ if not rg_path and recursive:
203
+ # Only need ripgrep for recursive listings
204
+ error_msg = "Error: ripgrep (rg) not found. Please install ripgrep to use this tool."
205
+ return ListFileOutput(content=error_msg, error=error_msg)
206
+
207
+ # Only use ripgrep for recursive listings
208
+ if recursive:
209
+ # Build command for ripgrep --files
210
+ cmd = [rg_path, "--files"]
211
+
212
+ # Add ignore patterns to the command via a temporary file
213
+ from code_muse.tools.common import (
214
+ DIR_IGNORE_PATTERNS,
215
+ )
216
+
217
+ with tempfile.NamedTemporaryFile(
218
+ mode="w", delete=False, suffix=".ignore"
219
+ ) as f:
220
+ ignore_file = f.name
221
+ for pattern in DIR_IGNORE_PATTERNS:
222
+ # Skip patterns that would match the search directory itself
223
+ # For example, if searching in /tmp/test-dir, skip **/tmp/**
224
+ if would_match_directory(pattern, directory):
225
+ continue
226
+ f.write(f"{pattern}\n")
227
+
228
+ cmd.extend(["--ignore-file", ignore_file])
229
+ cmd.append(str(directory))
230
+
231
+ # Run ripgrep to get file listing
232
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
233
+
234
+ # Process the output lines
235
+ files = result.stdout.strip().split("\n") if result.stdout.strip() else []
236
+
237
+ # Create ListedFile objects with metadata
238
+ for full_path in files:
239
+ if not full_path: # Skip empty lines
240
+ continue
241
+
242
+ fp = Path(full_path)
243
+ # Skip if file doesn't exist (though it should)
244
+ if not fp.exists():
245
+ continue
246
+
247
+ # Extract relative path from the full path
248
+ if full_path.startswith(str(directory)):
249
+ file_path = full_path[len(str(directory)) :].lstrip(os.sep)
250
+ else:
251
+ file_path = full_path
252
+
253
+ # Check if path is a file or directory
254
+ if fp.is_file():
255
+ entry_type = "file"
256
+ size = fp.stat().st_size
257
+ elif fp.is_dir():
258
+ entry_type = "directory"
259
+ size = 0
260
+ else:
261
+ # Skip if it's neither a file nor directory
262
+ continue
263
+
264
+ try:
265
+ # Calculate depth based on the relative path
266
+ depth = file_path.count(os.sep)
267
+
268
+ # Add directory entries if needed for files
269
+ if entry_type == "file":
270
+ p = Path(file_path).parent
271
+ dir_path = str(p) if p != Path(".") else ""
272
+ if dir_path:
273
+ # Add directory path components if they don't exist
274
+ path_parts = dir_path.split(os.sep)
275
+ for i in range(len(path_parts)):
276
+ partial_path = os.sep.join(path_parts[: i + 1])
277
+ # Check if we already added this directory
278
+ if not any(
279
+ f.path == partial_path and f.type == "directory"
280
+ for f in results
281
+ ):
282
+ results.append(
283
+ ListedFile(
284
+ path=partial_path,
285
+ type="directory",
286
+ size=0,
287
+ full_path=str(directory / partial_path),
288
+ depth=partial_path.count(os.sep),
289
+ )
290
+ )
291
+
292
+ # Add the entry (file or directory)
293
+ results.append(
294
+ ListedFile(
295
+ path=file_path,
296
+ type=entry_type,
297
+ size=size,
298
+ full_path=full_path,
299
+ depth=depth,
300
+ )
301
+ )
302
+ except OSError:
303
+ # Skip files we can't access
304
+ continue
305
+
306
+ # In non-recursive mode, we also need to explicitly list immediate entries
307
+ # ripgrep's --files option only returns files; we add directories and files ourselves
308
+ if not recursive:
309
+ try:
310
+ entries = os.listdir(directory)
311
+ for entry in sorted(entries):
312
+ full_entry_path = directory / entry
313
+ if not full_entry_path.exists():
314
+ continue
315
+
316
+ if full_entry_path.is_dir():
317
+ # In non-recursive mode, only skip obviously system/hidden directories
318
+ # Don't use the full should_ignore_dir_path which is too aggressive
319
+ if entry.startswith("."):
320
+ continue
321
+ results.append(
322
+ ListedFile(
323
+ path=entry,
324
+ type="directory",
325
+ size=0,
326
+ full_path=str(full_entry_path),
327
+ depth=0,
328
+ )
329
+ )
330
+ elif full_entry_path.is_file():
331
+ # Include top-level files (including binaries)
332
+ try:
333
+ size = full_entry_path.stat().st_size
334
+ except OSError:
335
+ size = 0
336
+ results.append(
337
+ ListedFile(
338
+ path=entry,
339
+ type="file",
340
+ size=size,
341
+ full_path=str(full_entry_path),
342
+ depth=0,
343
+ )
344
+ )
345
+ except OSError:
346
+ # Skip entries we can't access
347
+ pass
348
+ except subprocess.TimeoutExpired:
349
+ error_msg = "Error: List files command timed out after 30 seconds"
350
+ return ListFileOutput(content=error_msg, error=error_msg)
351
+ except Exception as e:
352
+ error_msg = f"Error: Error during list files operation: {e}"
353
+ return ListFileOutput(content=error_msg, error=error_msg)
354
+ finally:
355
+ # Clean up the temporary ignore file
356
+ if ignore_file and Path(ignore_file).exists():
357
+ os.unlink(ignore_file)
358
+
359
+ def format_size(size_bytes):
360
+ if size_bytes < 1024:
361
+ return f"{size_bytes} B"
362
+ elif size_bytes < 1024 * 1024:
363
+ return f"{size_bytes / 1024:.1f} KB"
364
+ elif size_bytes < 1024 * 1024 * 1024:
365
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
366
+ else:
367
+ return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
368
+
369
+ def get_file_icon(file_path):
370
+ ext = Path(file_path).suffix.lower()
371
+ if ext in [".py", ".pyw"]:
372
+ return "\U0001f40d"
373
+ elif ext in [".js", ".jsx", ".ts", ".tsx"]:
374
+ return "\U0001f4dc"
375
+ elif ext in [".html", ".htm", ".xml"]:
376
+ return "\U0001f310"
377
+ elif ext in [".css", ".scss", ".sass"]:
378
+ return "\U0001f3a8"
379
+ elif ext in [".md", ".markdown", ".rst"]:
380
+ return "\U0001f4dd"
381
+ elif ext in [".json", ".yaml", ".yml", ".toml"]:
382
+ return "\u2699\ufe0f"
383
+ elif ext in [".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp"]:
384
+ return "\U0001f5bc\ufe0f"
385
+ elif ext in [".mp3", ".wav", ".ogg", ".flac"]:
386
+ return "\U0001f3b5"
387
+ elif ext in [".mp4", ".avi", ".mov", ".webm"]:
388
+ return "\U0001f3ac"
389
+ elif ext in [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"]:
390
+ return "\U0001f4c4"
391
+ elif ext in [".zip", ".tar", ".gz", ".rar", ".7z"]:
392
+ return "\U0001f4e6"
393
+ elif ext in [".exe", ".dll", ".so", ".dylib"]:
394
+ return "\u26a1"
395
+ else:
396
+ return "\U0001f4c4"
397
+
398
+ # Count items in results
399
+ dir_count = sum(1 for item in results if item.type == "directory")
400
+ file_count = sum(1 for item in results if item.type == "file")
401
+ total_size = sum(item.size for item in results if item.type == "file")
402
+
403
+ # Build structured FileEntry objects for the UI
404
+ file_entries = []
405
+
406
+ def _sort_key(item):
407
+ """Sort by path components to keep children grouped under parents.
408
+
409
+ Splitting on os.sep ensures 'src/foo' always sorts right after 'src'
410
+ rather than letting 'src-tauri' (with '-' < '/') slip in between.
411
+ Directories sort before files at the same level.
412
+ """
413
+ parts = item.path.split(os.sep)
414
+ return (parts, item.type != "directory")
415
+
416
+ sorted_results = sorted(results, key=_sort_key)
417
+
418
+ for item in sorted_results:
419
+ if item.type == "directory" and not item.path:
420
+ continue
421
+ file_entries.append(
422
+ FileEntry(
423
+ path=item.path,
424
+ type="dir" if item.type == "directory" else "file",
425
+ size=item.size,
426
+ depth=item.depth or 0,
427
+ )
428
+ )
429
+
430
+ # Cap UI structured entries
431
+ ui_truncated = False
432
+ if len(file_entries) > MAX_LIST_FILES_UI_ENTRIES:
433
+ file_entries = file_entries[:MAX_LIST_FILES_UI_ENTRIES]
434
+ ui_truncated = True
435
+
436
+ # Emit structured message for the UI
437
+ file_listing_msg = FileListingMessage(
438
+ directory=str(directory),
439
+ files=file_entries,
440
+ recursive=recursive,
441
+ total_size=total_size,
442
+ dir_count=dir_count,
443
+ file_count=file_count,
444
+ )
445
+ get_message_bus().emit(file_listing_msg)
446
+
447
+ # Build plain text output for LLM consumption
448
+ llm_lines: list[str] = []
449
+ for item in sorted_results:
450
+ if item.type == "directory" and not item.path:
451
+ continue
452
+ name = Path(item.path).name or item.path
453
+ indent = " " * (item.depth or 0)
454
+ if item.type == "directory":
455
+ llm_lines.append(f"{indent}{name}/")
456
+ else:
457
+ size_str = format_size(item.size)
458
+ llm_lines.append(f"{indent}{name} ({size_str})")
459
+
460
+ llm_truncated = False
461
+ if len(llm_lines) > MAX_LIST_FILES_LLM_ENTRIES:
462
+ llm_lines = llm_lines[:MAX_LIST_FILES_LLM_ENTRIES]
463
+ llm_truncated = True
464
+
465
+ output_lines.extend(llm_lines)
466
+
467
+ # Add summary
468
+ output_lines.append(
469
+ f"\nSummary: {dir_count} directories, {file_count} files ({format_size(total_size)} total)"
470
+ )
471
+ if ui_truncated or llm_truncated:
472
+ output_lines.append(
473
+ f"\n[Truncated: shown {MAX_LIST_FILES_LLM_ENTRIES} of {len(sorted_results)} entries]"
474
+ )
475
+
476
+ return ListFileOutput(content="\n".join(output_lines))
477
+
478
+
479
+ def _read_file(
480
+ context: RunContext,
481
+ file_path: str,
482
+ start_line: int | None = None,
483
+ num_lines: int | None = None,
484
+ ) -> ReadFileOutput:
485
+ file_path = Path(file_path).expanduser().resolve()
486
+
487
+ # Enforce path policy before reading
488
+ policy = check_path_allowed(str(file_path), Operation.READ)
489
+ if not policy.allowed:
490
+ error_msg = policy.reason or "File read blocked by path policy."
491
+ return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
492
+
493
+ if not file_path.exists():
494
+ error_msg = f"File {file_path} does not exist"
495
+ return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
496
+ if not file_path.is_file():
497
+ error_msg = f"{file_path} is not a file"
498
+ return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
499
+
500
+ # Huge-file gate: reject full reads of files larger than cap unless chunked
501
+ try:
502
+ file_size = file_path.stat().st_size
503
+ except OSError:
504
+ file_size = 0
505
+ if start_line is None and num_lines is None and file_size > MAX_READ_FILE_BYTES:
506
+ return ReadFileOutput(
507
+ content=None,
508
+ error=(
509
+ f"File is too large ({file_size} bytes > {MAX_READ_FILE_BYTES} bytes). "
510
+ "Please read this file in chunks using start_line and num_lines."
511
+ ),
512
+ num_tokens=0,
513
+ )
514
+
515
+ try:
516
+ # Use errors="surrogateescape" to handle files with invalid UTF-8 sequences
517
+ # This is common on Windows when files contain emojis or were created by
518
+ # applications that don't properly encode Unicode
519
+ with open(file_path, encoding="utf-8", errors="surrogateescape") as f:
520
+ if start_line is not None and start_line < 1:
521
+ error_msg = "start_line must be >= 1 (1-based indexing)"
522
+ return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
523
+ if num_lines is not None and num_lines < 1:
524
+ error_msg = "num_lines must be >= 1"
525
+ return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
526
+ if start_line is not None and num_lines is not None:
527
+ # Read only the specified lines efficiently using itertools.islice
528
+ # to avoid loading the entire file into memory
529
+ import itertools
530
+
531
+ start_idx = start_line - 1
532
+ selected_lines = list(
533
+ itertools.islice(f, start_idx, start_idx + num_lines)
534
+ )
535
+ content = "".join(selected_lines)
536
+ else:
537
+ # Read the entire file
538
+ content = f.read()
539
+
540
+ # Sanitize the content to remove any surrogate characters that could
541
+ # cause issues when the content is later serialized or displayed
542
+ # This re-encodes with surrogatepass then decodes with replace to
543
+ # convert lone surrogates to replacement characters
544
+ try:
545
+ content = content.encode("utf-8", errors="surrogatepass").decode(
546
+ "utf-8", errors="replace"
547
+ )
548
+ except UnicodeEncodeError, UnicodeDecodeError:
549
+ # If that fails, do a more aggressive cleanup
550
+ content = "".join(
551
+ char if ord(char) < 0xD800 or ord(char) > 0xDFFF else "\ufffd"
552
+ for char in content
553
+ )
554
+
555
+ # Simple approximation: ~4 characters per token
556
+ num_tokens = len(content) // 4
557
+ if num_tokens > 10000:
558
+ return ReadFileOutput(
559
+ content=None,
560
+ error="The file is massive, greater than 10,000 tokens which is dangerous to read entirely. Please read this file in chunks.",
561
+ num_tokens=0,
562
+ )
563
+
564
+ # Count total lines for the message
565
+ total_lines = content.count("\n") + (
566
+ 1 if content and not content.endswith("\n") else 0
567
+ )
568
+
569
+ # Emit structured message for the UI
570
+ # Only include start_line/num_lines if they are valid positive integers
571
+ emit_start_line = (
572
+ start_line if start_line is not None and start_line >= 1 else None
573
+ )
574
+ emit_num_lines = (
575
+ num_lines if num_lines is not None and num_lines >= 1 else None
576
+ )
577
+ file_content_msg = FileContentMessage(
578
+ path=str(file_path),
579
+ content=content,
580
+ start_line=emit_start_line,
581
+ num_lines=emit_num_lines,
582
+ total_lines=total_lines,
583
+ num_tokens=num_tokens,
584
+ )
585
+ get_message_bus().emit(file_content_msg)
586
+
587
+ return ReadFileOutput(content=content, num_tokens=num_tokens)
588
+ except FileNotFoundError:
589
+ error_msg = "FILE NOT FOUND"
590
+ return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
591
+ except PermissionError:
592
+ error_msg = "PERMISSION DENIED"
593
+ return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
594
+ except Exception as e:
595
+ message = f"An error occurred trying to read the file: {e}"
596
+ return ReadFileOutput(content=message, num_tokens=0, error=message)
597
+
598
+
599
+ def _sanitize_string(text: str) -> str:
600
+ """Sanitize a string to remove invalid Unicode surrogates.
601
+
602
+ This handles encoding issues common on Windows with copy-paste operations.
603
+ """
604
+ if not text:
605
+ return text
606
+ try:
607
+ # Try encoding - if it works, string is clean
608
+ text.encode("utf-8")
609
+ return text
610
+ except UnicodeEncodeError:
611
+ pass
612
+
613
+ try:
614
+ # Encode allowing surrogates, then decode replacing them
615
+ return text.encode("utf-8", errors="surrogatepass").decode(
616
+ "utf-8", errors="replace"
617
+ )
618
+ except UnicodeEncodeError, UnicodeDecodeError:
619
+ # Last resort: filter out surrogate characters
620
+ return "".join(
621
+ char if ord(char) < 0xD800 or ord(char) > 0xDFFF else "\ufffd"
622
+ for char in text
623
+ )
624
+
625
+
626
+ def _grep(context: RunContext, search_string: str, directory: str = ".") -> GrepOutput:
627
+ import json
628
+ import os
629
+ import shutil
630
+ import subprocess
631
+ import sys
632
+
633
+ # Sanitize search string to handle any surrogates from copy-paste
634
+ search_string = _sanitize_string(search_string)
635
+
636
+ directory = Path(directory).expanduser().resolve()
637
+
638
+ # Enforce workspace / sensitive directory policy before searching
639
+ policy = check_path_allowed(str(directory), Operation.SEARCH)
640
+ if not policy.allowed:
641
+ error_message = policy.reason or "Search blocked by path policy."
642
+ return GrepOutput(matches=[], error=error_message)
643
+
644
+ matches: deque[MatchInfo] = deque(maxlen=MAX_GREP_MATCHES)
645
+ error_message: str | None = None
646
+
647
+ # Create a temporary ignore file with our ignore patterns
648
+ ignore_file = None
649
+ try:
650
+ # Find ripgrep executable - first check system PATH, then virtual environment
651
+ rg_path = shutil.which("rg")
652
+ if not rg_path:
653
+ python_dir = Path(sys.executable).parent
654
+ for name in ["rg", "rg.exe"]:
655
+ candidate = python_dir / name
656
+ if candidate.exists():
657
+ rg_path = str(candidate)
658
+ break
659
+
660
+ if not rg_path:
661
+ error_message = (
662
+ "ripgrep (rg) not found. Please install ripgrep to use this tool."
663
+ )
664
+ return GrepOutput(matches=[], error=error_message)
665
+
666
+ # Prevent option injection: treat search_string as data/regex only.
667
+ # Use '--' before the pattern so ripgrep stops parsing flags.
668
+ cmd = [
669
+ rg_path,
670
+ "--json",
671
+ "--max-count",
672
+ str(MAX_GREP_MATCHES),
673
+ "--max-filesize",
674
+ "5M",
675
+ "--type=all",
676
+ "--",
677
+ search_string,
678
+ directory,
679
+ ]
680
+
681
+ # Add ignore patterns to the command via a temporary file
682
+ from code_muse.tools.common import DIR_IGNORE_PATTERNS
683
+
684
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".ignore") as f:
685
+ ignore_file = f.name
686
+ for pattern in DIR_IGNORE_PATTERNS:
687
+ # Skip patterns that would match the search directory itself
688
+ if would_match_directory(pattern, directory):
689
+ continue
690
+ f.write(f"{pattern}\n")
691
+
692
+ # Insert ignore-file arg after the base flags and before '--'
693
+ cmd.insert(1, "--ignore-file")
694
+ cmd.insert(2, ignore_file)
695
+
696
+ # Stream JSON output via Popen to avoid buffering huge results
697
+ process = subprocess.Popen(
698
+ cmd,
699
+ stdout=subprocess.PIPE,
700
+ stderr=subprocess.PIPE,
701
+ text=True,
702
+ encoding="utf-8",
703
+ errors="replace",
704
+ )
705
+
706
+ timed_out = False
707
+ import threading
708
+
709
+ def _kill_on_timeout():
710
+ nonlocal timed_out
711
+ timed_out = True
712
+ with contextlib.suppress(Exception):
713
+ process.kill()
714
+
715
+ timer = threading.Timer(30.0, _kill_on_timeout)
716
+ timer.start()
717
+
718
+ try:
719
+ for raw_line in process.stdout:
720
+ if timed_out:
721
+ raise subprocess.TimeoutExpired("rg", 30)
722
+ line = raw_line.rstrip("\n")
723
+ if not line:
724
+ continue
725
+ try:
726
+ match_data = json.loads(line)
727
+ except json.JSONDecodeError:
728
+ continue
729
+ if match_data.get("type") != "match":
730
+ continue
731
+ data = match_data.get("data", {})
732
+ path_data = data.get("path", {})
733
+ file_path = path_data.get("text", "") if path_data.get("text") else ""
734
+ line_number = data.get("line_number", None)
735
+ line_content = (
736
+ data.get("lines", {}).get("text", "")
737
+ if data.get("lines", {}).get("text")
738
+ else ""
739
+ )
740
+ if len(line_content) > MAX_GREP_LINE_LENGTH:
741
+ line_content = line_content[:MAX_GREP_LINE_LENGTH]
742
+ if file_path and line_number:
743
+ matches.append(
744
+ MatchInfo(
745
+ file_path=_sanitize_string(file_path),
746
+ line_number=line_number,
747
+ line_content=_sanitize_string(line_content.strip()),
748
+ )
749
+ )
750
+ if len(matches) >= MAX_GREP_MATCHES:
751
+ break
752
+ finally:
753
+ timer.cancel()
754
+ # Ensure ripgrep is killed even if we stopped early
755
+ try:
756
+ if process.poll() is None:
757
+ process.kill()
758
+ except Exception:
759
+ pass
760
+ with contextlib.suppress(Exception):
761
+ process.wait(timeout=2)
762
+
763
+ except subprocess.TimeoutExpired:
764
+ error_message = "Grep command timed out after 30 seconds"
765
+ except FileNotFoundError:
766
+ error_message = (
767
+ "ripgrep (rg) not found. Please install ripgrep to use this tool."
768
+ )
769
+ except Exception as e:
770
+ error_message = f"Error during grep operation: {e}"
771
+ finally:
772
+ if ignore_file and Path(ignore_file).exists():
773
+ with contextlib.suppress(OSError):
774
+ os.unlink(ignore_file)
775
+
776
+ match_list = list(matches)
777
+
778
+ # Build structured GrepMatch objects for the UI
779
+ grep_matches = [
780
+ GrepMatch(
781
+ file_path=m.file_path or "",
782
+ line_number=m.line_number or 1,
783
+ line_content=m.line_content or "",
784
+ )
785
+ for m in match_list
786
+ ]
787
+
788
+ unique_files = len(set(m.file_path for m in match_list)) if match_list else 0
789
+
790
+ grep_result_msg = GrepResultMessage(
791
+ search_term=search_string,
792
+ directory=str(directory),
793
+ matches=grep_matches,
794
+ total_matches=len(match_list),
795
+ files_searched=unique_files,
796
+ )
797
+ get_message_bus().emit(grep_result_msg)
798
+
799
+ return GrepOutput(matches=match_list, error=error_message)
800
+
801
+
802
+ def register_list_files(agent):
803
+ """Register only the list_files tool."""
804
+ from code_muse.config import get_allow_recursion
805
+
806
+ @agent.tool
807
+ def list_files(
808
+ context: RunContext, directory: str = ".", recursive: bool = True
809
+ ) -> ListFileOutput:
810
+ """List files and directories with intelligent filtering and safety features.
811
+
812
+ Automatically ignores build artifacts, caches, and common noise.
813
+ """
814
+ warning = None
815
+ if recursive and not get_allow_recursion():
816
+ warning = "Recursion disabled globally for list_files - returning non-recursive results"
817
+ recursive = False
818
+ result = _list_files(context, directory, recursive)
819
+
820
+ # The structured FileListingMessage is already emitted by _list_files
821
+ # No need to emit again here
822
+ if warning:
823
+ result.error = warning
824
+ if (len(result.content)) > 200000:
825
+ result.content = result.content[0:200000]
826
+ result.error = "Results truncated. This is a massive directory tree, recommend non-recursive calls to list_files"
827
+ return result
828
+
829
+
830
+ def register_read_file(agent):
831
+ """Register only the read_file tool."""
832
+
833
+ @agent.tool
834
+ def read_file(
835
+ context: RunContext,
836
+ file_path: str = "",
837
+ start_line: int | None = None,
838
+ num_lines: int | None = None,
839
+ ) -> ReadFileOutput:
840
+ """Read file contents with optional line-range selection and token safety.
841
+
842
+ Use start_line/num_lines for large files to avoid overwhelming context.
843
+ """
844
+ return _read_file(context, file_path, start_line, num_lines)
845
+
846
+
847
+ def register_grep(agent):
848
+ """Register only the grep tool."""
849
+
850
+ @agent.tool
851
+ def grep(
852
+ context: RunContext, search_string: str = "", directory: str = "."
853
+ ) -> GrepOutput:
854
+ """Recursively search for text patterns across files using ripgrep (rg).
855
+
856
+ search_string is treated as a regex pattern, not CLI flags.
857
+ Use plain text or regex syntax; option injection is blocked.
858
+ Output is capped to 50 matches and 512 characters per line.
859
+ """
860
+ return _grep(context, search_string, directory)