newcode 0.1.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 (289) hide show
  1. code_puppy/__init__.py +10 -0
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agents/__init__.py +31 -0
  4. code_puppy/agents/agent_c_reviewer.py +155 -0
  5. code_puppy/agents/agent_code_puppy.py +147 -0
  6. code_puppy/agents/agent_code_reviewer.py +90 -0
  7. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  8. code_puppy/agents/agent_creator_agent.py +630 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +122 -0
  11. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  12. code_puppy/agents/agent_manager.py +742 -0
  13. code_puppy/agents/agent_pack_leader.py +380 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +167 -0
  16. code_puppy/agents/agent_python_reviewer.py +90 -0
  17. code_puppy/agents/agent_qa_expert.py +163 -0
  18. code_puppy/agents/agent_qa_kitten.py +208 -0
  19. code_puppy/agents/agent_scheduler.py +121 -0
  20. code_puppy/agents/agent_security_auditor.py +181 -0
  21. code_puppy/agents/agent_terminal_qa.py +323 -0
  22. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  23. code_puppy/agents/base_agent.py +2145 -0
  24. code_puppy/agents/event_stream_handler.py +348 -0
  25. code_puppy/agents/json_agent.py +202 -0
  26. code_puppy/agents/pack/__init__.py +34 -0
  27. code_puppy/agents/pack/bloodhound.py +296 -0
  28. code_puppy/agents/pack/husky.py +307 -0
  29. code_puppy/agents/pack/retriever.py +380 -0
  30. code_puppy/agents/pack/shepherd.py +327 -0
  31. code_puppy/agents/pack/terrier.py +281 -0
  32. code_puppy/agents/pack/watchdog.py +357 -0
  33. code_puppy/agents/prompt_reviewer.py +145 -0
  34. code_puppy/agents/subagent_stream_handler.py +276 -0
  35. code_puppy/api/__init__.py +13 -0
  36. code_puppy/api/app.py +169 -0
  37. code_puppy/api/main.py +21 -0
  38. code_puppy/api/pty_manager.py +453 -0
  39. code_puppy/api/routers/__init__.py +12 -0
  40. code_puppy/api/routers/agents.py +36 -0
  41. code_puppy/api/routers/commands.py +217 -0
  42. code_puppy/api/routers/config.py +75 -0
  43. code_puppy/api/routers/sessions.py +234 -0
  44. code_puppy/api/templates/terminal.html +361 -0
  45. code_puppy/api/websocket.py +154 -0
  46. code_puppy/callbacks.py +674 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +664 -0
  49. code_puppy/cli_runner.py +1038 -0
  50. code_puppy/command_line/__init__.py +1 -0
  51. code_puppy/command_line/add_model_menu.py +1092 -0
  52. code_puppy/command_line/agent_menu.py +662 -0
  53. code_puppy/command_line/attachments.py +395 -0
  54. code_puppy/command_line/autosave_menu.py +704 -0
  55. code_puppy/command_line/clipboard.py +527 -0
  56. code_puppy/command_line/colors_menu.py +526 -0
  57. code_puppy/command_line/command_handler.py +283 -0
  58. code_puppy/command_line/command_registry.py +150 -0
  59. code_puppy/command_line/config_commands.py +719 -0
  60. code_puppy/command_line/core_commands.py +853 -0
  61. code_puppy/command_line/diff_menu.py +865 -0
  62. code_puppy/command_line/file_path_completion.py +73 -0
  63. code_puppy/command_line/load_context_completion.py +52 -0
  64. code_puppy/command_line/mcp/__init__.py +10 -0
  65. code_puppy/command_line/mcp/base.py +32 -0
  66. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  67. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  68. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  69. code_puppy/command_line/mcp/edit_command.py +148 -0
  70. code_puppy/command_line/mcp/handler.py +138 -0
  71. code_puppy/command_line/mcp/help_command.py +147 -0
  72. code_puppy/command_line/mcp/install_command.py +214 -0
  73. code_puppy/command_line/mcp/install_menu.py +705 -0
  74. code_puppy/command_line/mcp/list_command.py +94 -0
  75. code_puppy/command_line/mcp/logs_command.py +235 -0
  76. code_puppy/command_line/mcp/remove_command.py +82 -0
  77. code_puppy/command_line/mcp/restart_command.py +100 -0
  78. code_puppy/command_line/mcp/search_command.py +123 -0
  79. code_puppy/command_line/mcp/start_all_command.py +135 -0
  80. code_puppy/command_line/mcp/start_command.py +117 -0
  81. code_puppy/command_line/mcp/status_command.py +184 -0
  82. code_puppy/command_line/mcp/stop_all_command.py +112 -0
  83. code_puppy/command_line/mcp/stop_command.py +80 -0
  84. code_puppy/command_line/mcp/test_command.py +107 -0
  85. code_puppy/command_line/mcp/utils.py +129 -0
  86. code_puppy/command_line/mcp/wizard_utils.py +334 -0
  87. code_puppy/command_line/mcp_completion.py +174 -0
  88. code_puppy/command_line/model_picker_completion.py +197 -0
  89. code_puppy/command_line/model_settings_menu.py +932 -0
  90. code_puppy/command_line/motd.py +91 -0
  91. code_puppy/command_line/onboarding_slides.py +179 -0
  92. code_puppy/command_line/onboarding_wizard.py +342 -0
  93. code_puppy/command_line/pin_command_completion.py +329 -0
  94. code_puppy/command_line/prompt_toolkit_completion.py +846 -0
  95. code_puppy/command_line/session_commands.py +302 -0
  96. code_puppy/command_line/skills_completion.py +160 -0
  97. code_puppy/command_line/uc_menu.py +893 -0
  98. code_puppy/command_line/utils.py +93 -0
  99. code_puppy/command_line/wiggum_state.py +78 -0
  100. code_puppy/config.py +1787 -0
  101. code_puppy/error_logging.py +133 -0
  102. code_puppy/gemini_code_assist.py +385 -0
  103. code_puppy/gemini_model.py +754 -0
  104. code_puppy/hook_engine/README.md +105 -0
  105. code_puppy/hook_engine/__init__.py +15 -0
  106. code_puppy/hook_engine/aliases.py +155 -0
  107. code_puppy/hook_engine/engine.py +195 -0
  108. code_puppy/hook_engine/executor.py +293 -0
  109. code_puppy/hook_engine/matcher.py +145 -0
  110. code_puppy/hook_engine/models.py +222 -0
  111. code_puppy/hook_engine/registry.py +106 -0
  112. code_puppy/hook_engine/validator.py +141 -0
  113. code_puppy/http_utils.py +361 -0
  114. code_puppy/keymap.py +128 -0
  115. code_puppy/main.py +10 -0
  116. code_puppy/mcp_/__init__.py +66 -0
  117. code_puppy/mcp_/async_lifecycle.py +286 -0
  118. code_puppy/mcp_/blocking_startup.py +469 -0
  119. code_puppy/mcp_/captured_stdio_server.py +275 -0
  120. code_puppy/mcp_/circuit_breaker.py +290 -0
  121. code_puppy/mcp_/config_wizard.py +507 -0
  122. code_puppy/mcp_/dashboard.py +308 -0
  123. code_puppy/mcp_/error_isolation.py +407 -0
  124. code_puppy/mcp_/examples/retry_example.py +226 -0
  125. code_puppy/mcp_/health_monitor.py +589 -0
  126. code_puppy/mcp_/managed_server.py +428 -0
  127. code_puppy/mcp_/manager.py +807 -0
  128. code_puppy/mcp_/mcp_logs.py +224 -0
  129. code_puppy/mcp_/registry.py +451 -0
  130. code_puppy/mcp_/retry_manager.py +337 -0
  131. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  132. code_puppy/mcp_/status_tracker.py +355 -0
  133. code_puppy/mcp_/system_tools.py +209 -0
  134. code_puppy/mcp_prompts/__init__.py +1 -0
  135. code_puppy/mcp_prompts/hook_creator.py +103 -0
  136. code_puppy/messaging/__init__.py +255 -0
  137. code_puppy/messaging/bus.py +613 -0
  138. code_puppy/messaging/commands.py +167 -0
  139. code_puppy/messaging/markdown_patches.py +57 -0
  140. code_puppy/messaging/message_queue.py +361 -0
  141. code_puppy/messaging/messages.py +569 -0
  142. code_puppy/messaging/queue_console.py +271 -0
  143. code_puppy/messaging/renderers.py +311 -0
  144. code_puppy/messaging/rich_renderer.py +1153 -0
  145. code_puppy/messaging/spinner/__init__.py +83 -0
  146. code_puppy/messaging/spinner/console_spinner.py +240 -0
  147. code_puppy/messaging/spinner/spinner_base.py +96 -0
  148. code_puppy/messaging/subagent_console.py +460 -0
  149. code_puppy/model_factory.py +848 -0
  150. code_puppy/model_switching.py +63 -0
  151. code_puppy/model_utils.py +168 -0
  152. code_puppy/models.json +130 -0
  153. code_puppy/models_dev_api.json +1 -0
  154. code_puppy/models_dev_parser.py +592 -0
  155. code_puppy/plugins/__init__.py +186 -0
  156. code_puppy/plugins/agent_skills/__init__.py +22 -0
  157. code_puppy/plugins/agent_skills/config.py +175 -0
  158. code_puppy/plugins/agent_skills/discovery.py +136 -0
  159. code_puppy/plugins/agent_skills/downloader.py +392 -0
  160. code_puppy/plugins/agent_skills/installer.py +22 -0
  161. code_puppy/plugins/agent_skills/metadata.py +219 -0
  162. code_puppy/plugins/agent_skills/prompt_builder.py +100 -0
  163. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  164. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  165. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  166. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  167. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  168. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  169. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  170. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  171. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  172. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  173. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  174. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  175. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  176. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  177. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  178. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  179. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  180. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  181. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  182. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  183. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  184. code_puppy/plugins/chatgpt_oauth/test_plugin.py +295 -0
  185. code_puppy/plugins/chatgpt_oauth/utils.py +499 -0
  186. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  187. code_puppy/plugins/claude_code_hooks/config.py +131 -0
  188. code_puppy/plugins/claude_code_hooks/register_callbacks.py +163 -0
  189. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  190. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  191. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  192. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  193. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  194. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  195. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  196. code_puppy/plugins/claude_code_oauth/utils.py +601 -0
  197. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  198. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  199. code_puppy/plugins/example_custom_command/README.md +280 -0
  200. code_puppy/plugins/example_custom_command/register_callbacks.py +48 -0
  201. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  202. code_puppy/plugins/file_permission_handler/register_callbacks.py +528 -0
  203. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  204. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  205. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  206. code_puppy/plugins/hook_creator/__init__.py +1 -0
  207. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  208. code_puppy/plugins/hook_manager/__init__.py +1 -0
  209. code_puppy/plugins/hook_manager/config.py +277 -0
  210. code_puppy/plugins/hook_manager/hooks_menu.py +551 -0
  211. code_puppy/plugins/hook_manager/register_callbacks.py +205 -0
  212. code_puppy/plugins/oauth_puppy_html.py +224 -0
  213. code_puppy/plugins/scheduler/__init__.py +1 -0
  214. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  215. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  216. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  217. code_puppy/plugins/shell_safety/__init__.py +6 -0
  218. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  219. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  220. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  221. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  222. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  223. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  224. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  225. code_puppy/plugins/universal_constructor/models.py +138 -0
  226. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  227. code_puppy/plugins/universal_constructor/registry.py +302 -0
  228. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  229. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  230. code_puppy/pydantic_patches.py +317 -0
  231. code_puppy/reopenable_async_client.py +232 -0
  232. code_puppy/round_robin_model.py +150 -0
  233. code_puppy/scheduler/__init__.py +41 -0
  234. code_puppy/scheduler/__main__.py +9 -0
  235. code_puppy/scheduler/cli.py +118 -0
  236. code_puppy/scheduler/config.py +126 -0
  237. code_puppy/scheduler/daemon.py +280 -0
  238. code_puppy/scheduler/executor.py +155 -0
  239. code_puppy/scheduler/platform.py +19 -0
  240. code_puppy/scheduler/platform_unix.py +22 -0
  241. code_puppy/scheduler/platform_win.py +32 -0
  242. code_puppy/session_storage.py +338 -0
  243. code_puppy/status_display.py +257 -0
  244. code_puppy/summarization_agent.py +176 -0
  245. code_puppy/terminal_utils.py +418 -0
  246. code_puppy/tools/__init__.py +470 -0
  247. code_puppy/tools/agent_tools.py +616 -0
  248. code_puppy/tools/ask_user_question/__init__.py +26 -0
  249. code_puppy/tools/ask_user_question/constants.py +73 -0
  250. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  251. code_puppy/tools/ask_user_question/handler.py +232 -0
  252. code_puppy/tools/ask_user_question/models.py +304 -0
  253. code_puppy/tools/ask_user_question/registration.py +36 -0
  254. code_puppy/tools/ask_user_question/renderers.py +309 -0
  255. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  256. code_puppy/tools/ask_user_question/theme.py +155 -0
  257. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  258. code_puppy/tools/browser/__init__.py +37 -0
  259. code_puppy/tools/browser/browser_control.py +289 -0
  260. code_puppy/tools/browser/browser_interactions.py +545 -0
  261. code_puppy/tools/browser/browser_locators.py +640 -0
  262. code_puppy/tools/browser/browser_manager.py +378 -0
  263. code_puppy/tools/browser/browser_navigation.py +251 -0
  264. code_puppy/tools/browser/browser_screenshot.py +179 -0
  265. code_puppy/tools/browser/browser_scripts.py +462 -0
  266. code_puppy/tools/browser/browser_workflows.py +221 -0
  267. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  268. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  269. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  270. code_puppy/tools/browser/terminal_tools.py +525 -0
  271. code_puppy/tools/command_runner.py +1346 -0
  272. code_puppy/tools/common.py +1409 -0
  273. code_puppy/tools/display.py +84 -0
  274. code_puppy/tools/file_modifications.py +739 -0
  275. code_puppy/tools/file_operations.py +802 -0
  276. code_puppy/tools/scheduler_tools.py +412 -0
  277. code_puppy/tools/skills_tools.py +251 -0
  278. code_puppy/tools/subagent_context.py +158 -0
  279. code_puppy/tools/tools_content.py +51 -0
  280. code_puppy/tools/universal_constructor.py +889 -0
  281. code_puppy/uvx_detection.py +242 -0
  282. code_puppy/version_checker.py +82 -0
  283. newcode-0.1.1.data/data/code_puppy/models.json +130 -0
  284. newcode-0.1.1.data/data/code_puppy/models_dev_api.json +1 -0
  285. newcode-0.1.1.dist-info/METADATA +154 -0
  286. newcode-0.1.1.dist-info/RECORD +289 -0
  287. newcode-0.1.1.dist-info/WHEEL +4 -0
  288. newcode-0.1.1.dist-info/entry_points.txt +3 -0
  289. newcode-0.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,739 @@
1
+ """Robust, always-diff-logging file-modification helpers + agent tools.
2
+
3
+ Key guarantees
4
+ --------------
5
+ 1. **A diff is printed _inline_ on every path** (success, no-op, or error) – no decorator magic.
6
+ 2. **Full traceback logging** for unexpected errors via `_log_error`.
7
+ 3. Helper functions stay print-free and return a `diff` key, while agent-tool wrappers handle
8
+ all console output.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import difflib
14
+ import json
15
+ import os
16
+ import traceback
17
+ from typing import Any, Dict, List, Union
18
+
19
+ import json_repair
20
+ from pydantic import BaseModel
21
+ from pydantic_ai import RunContext
22
+
23
+ from code_puppy.callbacks import on_delete_file, on_edit_file
24
+ from code_puppy.messaging import ( # Structured messaging types
25
+ DiffLine,
26
+ DiffMessage,
27
+ emit_error,
28
+ emit_warning,
29
+ get_message_bus,
30
+ )
31
+ from code_puppy.tools.common import _find_best_window, generate_group_id
32
+
33
+
34
+ def _create_rejection_response(file_path: str) -> Dict[str, Any]:
35
+ """Create a standardized rejection response with user feedback if available.
36
+
37
+ Args:
38
+ file_path: Path to the file that was rejected
39
+
40
+ Returns:
41
+ Dict containing rejection details and any user feedback
42
+ """
43
+ # Check for user feedback from permission handler
44
+ try:
45
+ from code_puppy.plugins.file_permission_handler.register_callbacks import (
46
+ clear_user_feedback,
47
+ get_last_user_feedback,
48
+ )
49
+
50
+ user_feedback = get_last_user_feedback()
51
+ # Clear feedback after reading it
52
+ clear_user_feedback()
53
+ except ImportError:
54
+ user_feedback = None
55
+
56
+ rejection_message = (
57
+ "USER REJECTED: The user explicitly rejected these file changes."
58
+ )
59
+ if user_feedback:
60
+ rejection_message += f" User feedback: {user_feedback}"
61
+ else:
62
+ rejection_message += " Please do not retry the same changes or any other changes - immediately ask for clarification."
63
+
64
+ return {
65
+ "success": False,
66
+ "path": file_path,
67
+ "message": rejection_message,
68
+ "changed": False,
69
+ "user_rejection": True,
70
+ "rejection_type": "explicit_user_denial",
71
+ "user_feedback": user_feedback,
72
+ }
73
+
74
+
75
+ class DeleteSnippetPayload(BaseModel):
76
+ file_path: str
77
+ delete_snippet: str
78
+
79
+
80
+ class Replacement(BaseModel):
81
+ old_str: str
82
+ new_str: str
83
+
84
+
85
+ class ReplacementsPayload(BaseModel):
86
+ file_path: str
87
+ replacements: List[Replacement]
88
+
89
+
90
+ class ContentPayload(BaseModel):
91
+ file_path: str
92
+ content: str
93
+ overwrite: bool = False
94
+
95
+
96
+ EditFilePayload = Union[DeleteSnippetPayload, ReplacementsPayload, ContentPayload]
97
+
98
+
99
+ def _parse_diff_lines(diff_text: str) -> List[DiffLine]:
100
+ """Parse unified diff text into structured DiffLine objects.
101
+
102
+ Args:
103
+ diff_text: Raw unified diff text
104
+
105
+ Returns:
106
+ List of DiffLine objects with line numbers and types
107
+ """
108
+ if not diff_text or not diff_text.strip():
109
+ return []
110
+
111
+ diff_lines = []
112
+ line_number = 0
113
+
114
+ for line in diff_text.splitlines():
115
+ # Determine line type based on diff markers
116
+ if line.startswith("+") and not line.startswith("+++"):
117
+ line_type = "add"
118
+ line_number += 1
119
+ content = line[1:] # Remove the + prefix
120
+ elif line.startswith("-") and not line.startswith("---"):
121
+ line_type = "remove"
122
+ line_number += 1
123
+ content = line[1:] # Remove the - prefix
124
+ elif line.startswith("@@"):
125
+ # Parse hunk header to get line number
126
+ # Format: @@ -start,count +start,count @@
127
+ import re
128
+
129
+ match = re.search(r"@@ -\d+(?:,\d+)? \+(\d+)", line)
130
+ if match:
131
+ line_number = (
132
+ int(match.group(1)) - 1
133
+ ) # Will be incremented on next line
134
+ line_type = "context"
135
+ content = line
136
+ elif line.startswith("---") or line.startswith("+++"):
137
+ # File headers - treat as context
138
+ line_type = "context"
139
+ content = line
140
+ else:
141
+ line_type = "context"
142
+ line_number += 1
143
+ content = line
144
+
145
+ diff_lines.append(
146
+ DiffLine(
147
+ line_number=max(1, line_number),
148
+ type=line_type,
149
+ content=content,
150
+ )
151
+ )
152
+
153
+ return diff_lines
154
+
155
+
156
+ def _emit_diff_message(
157
+ file_path: str,
158
+ operation: str,
159
+ diff_text: str,
160
+ old_content: str | None = None,
161
+ new_content: str | None = None,
162
+ ) -> None:
163
+ """Emit a structured DiffMessage for UI display.
164
+
165
+ Args:
166
+ file_path: Path to the file being modified
167
+ operation: One of 'create', 'modify', 'delete'
168
+ diff_text: Raw unified diff text
169
+ old_content: Original file content (optional)
170
+ new_content: New file content (optional)
171
+ """
172
+ # Check if diff was already shown during permission prompt
173
+ try:
174
+ from code_puppy.plugins.file_permission_handler.register_callbacks import (
175
+ clear_diff_shown_flag,
176
+ was_diff_already_shown,
177
+ )
178
+
179
+ if was_diff_already_shown():
180
+ # Diff already displayed in permission panel, skip redundant display
181
+ clear_diff_shown_flag()
182
+ return
183
+ except ImportError:
184
+ pass # Permission handler not available, emit anyway
185
+
186
+ if not diff_text or not diff_text.strip():
187
+ return
188
+
189
+ diff_lines = _parse_diff_lines(diff_text)
190
+
191
+ diff_msg = DiffMessage(
192
+ path=file_path,
193
+ operation=operation,
194
+ old_content=old_content,
195
+ new_content=new_content,
196
+ diff_lines=diff_lines,
197
+ )
198
+ get_message_bus().emit(diff_msg)
199
+
200
+
201
+ def _log_error(
202
+ msg: str, exc: Exception | None = None, message_group: str | None = None
203
+ ) -> None:
204
+ emit_error(f"{msg}", message_group=message_group)
205
+ if exc is not None:
206
+ emit_error(traceback.format_exc(), highlight=False, message_group=message_group)
207
+
208
+
209
+ def _delete_snippet_from_file(
210
+ context: RunContext | None,
211
+ file_path: str,
212
+ snippet: str,
213
+ message_group: str | None = None,
214
+ ) -> Dict[str, Any]:
215
+ file_path = os.path.abspath(file_path)
216
+ diff_text = ""
217
+ try:
218
+ if not os.path.exists(file_path) or not os.path.isfile(file_path):
219
+ return {"error": f"File '{file_path}' does not exist.", "diff": diff_text}
220
+ with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
221
+ original = f.read()
222
+ # Sanitize any surrogate characters from reading
223
+ try:
224
+ original = original.encode("utf-8", errors="surrogatepass").decode(
225
+ "utf-8", errors="replace"
226
+ )
227
+ except (UnicodeEncodeError, UnicodeDecodeError):
228
+ pass
229
+ if snippet not in original:
230
+ return {
231
+ "error": f"Snippet not found in file '{file_path}'.",
232
+ "diff": diff_text,
233
+ }
234
+ modified = original.replace(snippet, "", 1)
235
+ from code_puppy.config import get_diff_context_lines
236
+
237
+ diff_text = "".join(
238
+ difflib.unified_diff(
239
+ original.splitlines(keepends=True),
240
+ modified.splitlines(keepends=True),
241
+ fromfile=f"a/{os.path.basename(file_path)}",
242
+ tofile=f"b/{os.path.basename(file_path)}",
243
+ n=get_diff_context_lines(),
244
+ )
245
+ )
246
+ with open(file_path, "w", encoding="utf-8") as f:
247
+ f.write(modified)
248
+ return {
249
+ "success": True,
250
+ "path": file_path,
251
+ "message": "Snippet deleted from file.",
252
+ "changed": True,
253
+ "diff": diff_text,
254
+ }
255
+ except Exception as exc:
256
+ return {"error": str(exc), "diff": diff_text}
257
+
258
+
259
+ def _replace_in_file(
260
+ context: RunContext | None,
261
+ path: str,
262
+ replacements: List[Dict[str, str]],
263
+ message_group: str | None = None,
264
+ ) -> Dict[str, Any]:
265
+ """Robust replacement engine with explicit edge‑case reporting."""
266
+ file_path = os.path.abspath(path)
267
+ diff_text = ""
268
+ try:
269
+ if not os.path.exists(file_path) or not os.path.isfile(file_path):
270
+ return {"error": f"File '{file_path}' does not exist.", "diff": diff_text}
271
+
272
+ with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
273
+ original = f.read()
274
+
275
+ # Sanitize any surrogate characters from reading
276
+ try:
277
+ original = original.encode("utf-8", errors="surrogatepass").decode(
278
+ "utf-8", errors="replace"
279
+ )
280
+ except (UnicodeEncodeError, UnicodeDecodeError):
281
+ pass
282
+
283
+ modified = original
284
+ for rep in replacements:
285
+ old_snippet = rep.get("old_str", "")
286
+ new_snippet = rep.get("new_str", "")
287
+
288
+ if old_snippet and old_snippet in modified:
289
+ modified = modified.replace(old_snippet, new_snippet, 1)
290
+ continue
291
+
292
+ had_trailing_newline = modified.endswith("\n")
293
+ orig_lines = modified.splitlines()
294
+ loc, score = _find_best_window(orig_lines, old_snippet)
295
+
296
+ if score < 0.95 or loc is None:
297
+ return {
298
+ "error": "No suitable match in file (JW < 0.95)",
299
+ "jw_score": score,
300
+ "received": old_snippet,
301
+ "diff": "",
302
+ }
303
+
304
+ start, end = loc
305
+ prefix = "\n".join(orig_lines[:start])
306
+ suffix = "\n".join(orig_lines[end:])
307
+ parts = []
308
+ if prefix:
309
+ parts.append(prefix)
310
+ parts.append(new_snippet.rstrip("\n"))
311
+ if suffix:
312
+ parts.append(suffix)
313
+ modified = "\n".join(parts)
314
+ if had_trailing_newline and not modified.endswith("\n"):
315
+ modified += "\n"
316
+
317
+ if modified == original:
318
+ emit_warning(
319
+ "No changes to apply – proposed content is identical.",
320
+ message_group=message_group,
321
+ )
322
+ return {
323
+ "success": False,
324
+ "path": file_path,
325
+ "message": "No changes to apply.",
326
+ "changed": False,
327
+ "diff": "",
328
+ }
329
+
330
+ from code_puppy.config import get_diff_context_lines
331
+
332
+ diff_text = "".join(
333
+ difflib.unified_diff(
334
+ original.splitlines(keepends=True),
335
+ modified.splitlines(keepends=True),
336
+ fromfile=f"a/{os.path.basename(file_path)}",
337
+ tofile=f"b/{os.path.basename(file_path)}",
338
+ n=get_diff_context_lines(),
339
+ )
340
+ )
341
+ with open(file_path, "w", encoding="utf-8") as f:
342
+ f.write(modified)
343
+ return {
344
+ "success": True,
345
+ "path": file_path,
346
+ "message": "Replacements applied.",
347
+ "changed": True,
348
+ "diff": diff_text,
349
+ }
350
+ except Exception as exc:
351
+ return {"error": str(exc), "diff": diff_text}
352
+
353
+
354
+ def _write_to_file(
355
+ context: RunContext | None,
356
+ path: str,
357
+ content: str,
358
+ overwrite: bool = False,
359
+ message_group: str | None = None,
360
+ ) -> Dict[str, Any]:
361
+ file_path = os.path.abspath(path)
362
+
363
+ try:
364
+ exists = os.path.exists(file_path)
365
+ if exists and not overwrite:
366
+ return {
367
+ "success": False,
368
+ "path": file_path,
369
+ "message": f"Cowardly refusing to overwrite existing file: {file_path}",
370
+ "changed": False,
371
+ "diff": "",
372
+ }
373
+
374
+ from code_puppy.config import get_diff_context_lines
375
+
376
+ if exists:
377
+ with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
378
+ old_content = f.read()
379
+ try:
380
+ old_content = old_content.encode(
381
+ "utf-8", errors="surrogatepass"
382
+ ).decode("utf-8", errors="replace")
383
+ except (UnicodeEncodeError, UnicodeDecodeError):
384
+ pass
385
+ old_lines = old_content.splitlines(keepends=True)
386
+ else:
387
+ old_lines = []
388
+
389
+ diff_lines = difflib.unified_diff(
390
+ old_lines,
391
+ content.splitlines(keepends=True),
392
+ fromfile="/dev/null" if not exists else f"a/{os.path.basename(file_path)}",
393
+ tofile=f"b/{os.path.basename(file_path)}",
394
+ n=get_diff_context_lines(),
395
+ )
396
+ diff_text = "".join(diff_lines)
397
+
398
+ os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
399
+ with open(file_path, "w", encoding="utf-8") as f:
400
+ f.write(content)
401
+
402
+ action = "overwritten" if exists else "created"
403
+ return {
404
+ "success": True,
405
+ "path": file_path,
406
+ "message": f"File '{file_path}' {action} successfully.",
407
+ "changed": True,
408
+ "diff": diff_text,
409
+ }
410
+
411
+ except Exception as exc:
412
+ _log_error("Unhandled exception in write_to_file", exc)
413
+ return {"error": str(exc), "diff": ""}
414
+
415
+
416
+ def delete_snippet_from_file(
417
+ context: RunContext, file_path: str, snippet: str, message_group: str | None = None
418
+ ) -> Dict[str, Any]:
419
+ # Use the plugin system for permission handling with operation data
420
+ from code_puppy.callbacks import on_file_permission
421
+
422
+ operation_data = {"snippet": snippet}
423
+ permission_results = on_file_permission(
424
+ context, file_path, "delete snippet from", None, message_group, operation_data
425
+ )
426
+
427
+ # If any permission handler denies the operation, return cancelled result
428
+ if permission_results and any(
429
+ not result for result in permission_results if result is not None
430
+ ):
431
+ return _create_rejection_response(file_path)
432
+
433
+ res = _delete_snippet_from_file(
434
+ context, file_path, snippet, message_group=message_group
435
+ )
436
+ diff = res.get("diff", "")
437
+ if diff:
438
+ _emit_diff_message(file_path, "modify", diff)
439
+ return res
440
+
441
+
442
+ def write_to_file(
443
+ context: RunContext,
444
+ path: str,
445
+ content: str,
446
+ overwrite: bool,
447
+ message_group: str | None = None,
448
+ ) -> Dict[str, Any]:
449
+ # Use the plugin system for permission handling with operation data
450
+ from code_puppy.callbacks import on_file_permission
451
+
452
+ operation_data = {"content": content, "overwrite": overwrite}
453
+ permission_results = on_file_permission(
454
+ context, path, "write", None, message_group, operation_data
455
+ )
456
+
457
+ # If any permission handler denies the operation, return cancelled result
458
+ if permission_results and any(
459
+ not result for result in permission_results if result is not None
460
+ ):
461
+ return _create_rejection_response(path)
462
+
463
+ res = _write_to_file(
464
+ context, path, content, overwrite=overwrite, message_group=message_group
465
+ )
466
+ diff = res.get("diff", "")
467
+ if diff:
468
+ # Determine operation type based on whether file existed
469
+ operation = "modify" if overwrite else "create"
470
+ _emit_diff_message(path, operation, diff, new_content=content)
471
+ return res
472
+
473
+
474
+ def replace_in_file(
475
+ context: RunContext,
476
+ path: str,
477
+ replacements: List[Dict[str, str]],
478
+ message_group: str | None = None,
479
+ ) -> Dict[str, Any]:
480
+ # Use the plugin system for permission handling with operation data
481
+ from code_puppy.callbacks import on_file_permission
482
+
483
+ operation_data = {"replacements": replacements}
484
+ permission_results = on_file_permission(
485
+ context, path, "replace text in", None, message_group, operation_data
486
+ )
487
+
488
+ # If any permission handler denies the operation, return cancelled result
489
+ if permission_results and any(
490
+ not result for result in permission_results if result is not None
491
+ ):
492
+ return _create_rejection_response(path)
493
+
494
+ res = _replace_in_file(context, path, replacements, message_group=message_group)
495
+ diff = res.get("diff", "")
496
+ if diff:
497
+ _emit_diff_message(path, "modify", diff)
498
+ return res
499
+
500
+
501
+ def _edit_file(
502
+ context: RunContext, payload: EditFilePayload, group_id: str | None = None
503
+ ) -> Dict[str, Any]:
504
+ """
505
+ High-level implementation of the *edit_file* behaviour.
506
+
507
+ This function performs the heavy-lifting after the lightweight agent-exposed wrapper has
508
+ validated / coerced the inbound *payload* to one of the Pydantic models declared at the top
509
+ of this module.
510
+
511
+ Supported payload variants
512
+ --------------------------
513
+ • **ContentPayload** – full file write / overwrite.
514
+ • **ReplacementsPayload** – targeted in-file replacements.
515
+ • **DeleteSnippetPayload** – remove an exact snippet.
516
+
517
+ The helper decides which low-level routine to delegate to and ensures the resulting unified
518
+ diff is always returned so the caller can pretty-print it for the user.
519
+
520
+ Parameters
521
+ ----------
522
+ path : str
523
+ Path to the target file (relative or absolute)
524
+ diff : str
525
+ Either:
526
+ * Raw file content (for file creation)
527
+ * A JSON string with one of the following shapes:
528
+ {"content": "full file contents", "overwrite": true}
529
+ {"replacements": [ {"old_str": "foo", "new_str": "bar"}, ... ] }
530
+ {"delete_snippet": "text to remove"}
531
+
532
+ The function auto-detects the payload type and routes to the appropriate internal helper.
533
+ """
534
+ # Extract file_path from payload
535
+ file_path = os.path.abspath(payload.file_path)
536
+
537
+ # Use provided group_id or generate one if not provided
538
+ if group_id is None:
539
+ group_id = generate_group_id("edit_file", file_path)
540
+
541
+ try:
542
+ if isinstance(payload, DeleteSnippetPayload):
543
+ return delete_snippet_from_file(
544
+ context, file_path, payload.delete_snippet, message_group=group_id
545
+ )
546
+ elif isinstance(payload, ReplacementsPayload):
547
+ # Convert Pydantic Replacement models to dict format for legacy compatibility
548
+ replacements_dict = [
549
+ {"old_str": rep.old_str, "new_str": rep.new_str}
550
+ for rep in payload.replacements
551
+ ]
552
+ return replace_in_file(
553
+ context, file_path, replacements_dict, message_group=group_id
554
+ )
555
+ elif isinstance(payload, ContentPayload):
556
+ file_exists = os.path.exists(file_path)
557
+ if file_exists and not payload.overwrite:
558
+ return {
559
+ "success": False,
560
+ "path": file_path,
561
+ "message": f"File '{file_path}' exists. Set 'overwrite': true to replace.",
562
+ "changed": False,
563
+ }
564
+ return write_to_file(
565
+ context,
566
+ file_path,
567
+ payload.content,
568
+ payload.overwrite,
569
+ message_group=group_id,
570
+ )
571
+ else:
572
+ return {
573
+ "success": False,
574
+ "path": file_path,
575
+ "message": f"Unknown payload type: {type(payload)}",
576
+ "changed": False,
577
+ }
578
+ except Exception as e:
579
+ emit_error(
580
+ "Unable to route file modification tool call to sub-tool",
581
+ message_group=group_id,
582
+ )
583
+ emit_error(str(e), message_group=group_id)
584
+ return {
585
+ "success": False,
586
+ "path": file_path,
587
+ "message": f"Something went wrong in file editing: {str(e)}",
588
+ "changed": False,
589
+ }
590
+
591
+
592
+ def _delete_file(
593
+ context: RunContext, file_path: str, message_group: str | None = None
594
+ ) -> Dict[str, Any]:
595
+ file_path = os.path.abspath(file_path)
596
+
597
+ # Use the plugin system for permission handling with operation data
598
+ from code_puppy.callbacks import on_file_permission
599
+
600
+ operation_data = {} # No additional data needed for delete operations
601
+ permission_results = on_file_permission(
602
+ context, file_path, "delete", None, message_group, operation_data
603
+ )
604
+
605
+ # If any permission handler denies the operation, return cancelled result
606
+ if permission_results and any(
607
+ not result for result in permission_results if result is not None
608
+ ):
609
+ return _create_rejection_response(file_path)
610
+
611
+ try:
612
+ if not os.path.exists(file_path) or not os.path.isfile(file_path):
613
+ res = {"error": f"File '{file_path}' does not exist.", "diff": ""}
614
+ else:
615
+ with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
616
+ original = f.read()
617
+ # Sanitize any surrogate characters from reading
618
+ try:
619
+ original = original.encode("utf-8", errors="surrogatepass").decode(
620
+ "utf-8", errors="replace"
621
+ )
622
+ except (UnicodeEncodeError, UnicodeDecodeError):
623
+ pass
624
+ from code_puppy.config import get_diff_context_lines
625
+
626
+ diff_text = "".join(
627
+ difflib.unified_diff(
628
+ original.splitlines(keepends=True),
629
+ [],
630
+ fromfile=f"a/{os.path.basename(file_path)}",
631
+ tofile=f"b/{os.path.basename(file_path)}",
632
+ n=get_diff_context_lines(),
633
+ )
634
+ )
635
+ os.remove(file_path)
636
+ res = {
637
+ "success": True,
638
+ "path": file_path,
639
+ "message": f"File '{file_path}' deleted successfully.",
640
+ "changed": True,
641
+ "diff": diff_text,
642
+ }
643
+ except Exception as exc:
644
+ _log_error("Unhandled exception in delete_file", exc)
645
+ res = {"error": str(exc), "diff": ""}
646
+
647
+ diff = res.get("diff", "")
648
+ if diff:
649
+ _emit_diff_message(file_path, "delete", diff)
650
+ return res
651
+
652
+
653
+ def register_edit_file(agent):
654
+ """Register only the edit_file tool."""
655
+
656
+ @agent.tool
657
+ def edit_file(
658
+ context: RunContext,
659
+ payload: EditFilePayload | str = "",
660
+ ) -> Dict[str, Any]:
661
+ """Comprehensive file editing tool supporting multiple modification strategies.
662
+
663
+ Supports: ContentPayload (create/overwrite), ReplacementsPayload (targeted edits),
664
+ DeleteSnippetPayload (remove text). Prefer ReplacementsPayload for existing files.
665
+ """
666
+ # Handle string payload parsing (for models that send JSON strings)
667
+
668
+ parse_error_message = "Payload must contain one of: 'content', 'replacements', or 'delete_snippet' with a 'file_path'."
669
+
670
+ if isinstance(payload, str):
671
+ try:
672
+ # Fallback for weird models that just can't help but send json strings...
673
+ payload_dict = json.loads(json_repair.repair_json(payload))
674
+ if "replacements" in payload_dict:
675
+ payload = ReplacementsPayload(**payload_dict)
676
+ elif "delete_snippet" in payload_dict:
677
+ payload = DeleteSnippetPayload(**payload_dict)
678
+ elif "content" in payload_dict:
679
+ payload = ContentPayload(**payload_dict)
680
+ else:
681
+ file_path = "Unknown"
682
+ if "file_path" in payload_dict:
683
+ file_path = payload_dict["file_path"]
684
+ return {
685
+ "success": False,
686
+ "path": file_path,
687
+ "message": parse_error_message,
688
+ "changed": False,
689
+ }
690
+ except Exception as e:
691
+ return {
692
+ "success": False,
693
+ "path": "Not retrievable in Payload",
694
+ "message": f"edit_file call failed: {str(e)} - {parse_error_message}",
695
+ "changed": False,
696
+ }
697
+
698
+ # Call _edit_file which will extract file_path from payload and handle group_id generation
699
+ result = _edit_file(context, payload)
700
+ if "diff" in result:
701
+ del result["diff"]
702
+
703
+ # Trigger edit_file callbacks to enhance the result with rejection details
704
+ enhanced_results = on_edit_file(context, result, payload)
705
+ if enhanced_results:
706
+ # Use the first non-None enhanced result
707
+ for enhanced_result in enhanced_results:
708
+ if enhanced_result is not None:
709
+ result = enhanced_result
710
+ break
711
+
712
+ return result
713
+
714
+
715
+ def register_delete_file(agent):
716
+ """Register only the delete_file tool."""
717
+
718
+ @agent.tool
719
+ def delete_file(context: RunContext, file_path: str = "") -> Dict[str, Any]:
720
+ """Safely delete files with comprehensive logging and diff generation.
721
+
722
+ Shows exactly what content was removed via diff output.
723
+ """
724
+ # Generate group_id for delete_file tool execution
725
+ group_id = generate_group_id("delete_file", file_path)
726
+ result = _delete_file(context, file_path, message_group=group_id)
727
+ if "diff" in result:
728
+ del result["diff"]
729
+
730
+ # Trigger delete_file callbacks to enhance the result with rejection details
731
+ enhanced_results = on_delete_file(context, result, file_path)
732
+ if enhanced_results:
733
+ # Use the first non-None enhanced result
734
+ for enhanced_result in enhanced_results:
735
+ if enhanced_result is not None:
736
+ result = enhanced_result
737
+ break
738
+
739
+ return result