code-puppy 0.0.169__py3-none-any.whl → 0.0.366__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 (243) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +8 -8
  3. code_puppy/agents/agent_c_reviewer.py +155 -0
  4. code_puppy/agents/agent_code_puppy.py +9 -2
  5. code_puppy/agents/agent_code_reviewer.py +90 -0
  6. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  7. code_puppy/agents/agent_creator_agent.py +48 -9
  8. code_puppy/agents/agent_golang_reviewer.py +151 -0
  9. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  10. code_puppy/agents/agent_manager.py +146 -199
  11. code_puppy/agents/agent_pack_leader.py +383 -0
  12. code_puppy/agents/agent_planning.py +163 -0
  13. code_puppy/agents/agent_python_programmer.py +165 -0
  14. code_puppy/agents/agent_python_reviewer.py +90 -0
  15. code_puppy/agents/agent_qa_expert.py +163 -0
  16. code_puppy/agents/agent_qa_kitten.py +208 -0
  17. code_puppy/agents/agent_security_auditor.py +181 -0
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  20. code_puppy/agents/base_agent.py +1713 -1
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/json_agent.py +12 -1
  23. code_puppy/agents/pack/__init__.py +34 -0
  24. code_puppy/agents/pack/bloodhound.py +304 -0
  25. code_puppy/agents/pack/husky.py +321 -0
  26. code_puppy/agents/pack/retriever.py +393 -0
  27. code_puppy/agents/pack/shepherd.py +348 -0
  28. code_puppy/agents/pack/terrier.py +287 -0
  29. code_puppy/agents/pack/watchdog.py +367 -0
  30. code_puppy/agents/prompt_reviewer.py +145 -0
  31. code_puppy/agents/subagent_stream_handler.py +276 -0
  32. code_puppy/api/__init__.py +13 -0
  33. code_puppy/api/app.py +169 -0
  34. code_puppy/api/main.py +21 -0
  35. code_puppy/api/pty_manager.py +446 -0
  36. code_puppy/api/routers/__init__.py +12 -0
  37. code_puppy/api/routers/agents.py +36 -0
  38. code_puppy/api/routers/commands.py +217 -0
  39. code_puppy/api/routers/config.py +74 -0
  40. code_puppy/api/routers/sessions.py +232 -0
  41. code_puppy/api/templates/terminal.html +361 -0
  42. code_puppy/api/websocket.py +154 -0
  43. code_puppy/callbacks.py +174 -4
  44. code_puppy/chatgpt_codex_client.py +283 -0
  45. code_puppy/claude_cache_client.py +586 -0
  46. code_puppy/cli_runner.py +916 -0
  47. code_puppy/command_line/add_model_menu.py +1079 -0
  48. code_puppy/command_line/agent_menu.py +395 -0
  49. code_puppy/command_line/attachments.py +395 -0
  50. code_puppy/command_line/autosave_menu.py +605 -0
  51. code_puppy/command_line/clipboard.py +527 -0
  52. code_puppy/command_line/colors_menu.py +520 -0
  53. code_puppy/command_line/command_handler.py +233 -627
  54. code_puppy/command_line/command_registry.py +150 -0
  55. code_puppy/command_line/config_commands.py +715 -0
  56. code_puppy/command_line/core_commands.py +792 -0
  57. code_puppy/command_line/diff_menu.py +863 -0
  58. code_puppy/command_line/load_context_completion.py +15 -22
  59. code_puppy/command_line/mcp/base.py +1 -4
  60. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  61. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  62. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  63. code_puppy/command_line/mcp/edit_command.py +148 -0
  64. code_puppy/command_line/mcp/handler.py +9 -4
  65. code_puppy/command_line/mcp/help_command.py +6 -5
  66. code_puppy/command_line/mcp/install_command.py +16 -27
  67. code_puppy/command_line/mcp/install_menu.py +685 -0
  68. code_puppy/command_line/mcp/list_command.py +3 -3
  69. code_puppy/command_line/mcp/logs_command.py +174 -65
  70. code_puppy/command_line/mcp/remove_command.py +2 -2
  71. code_puppy/command_line/mcp/restart_command.py +12 -4
  72. code_puppy/command_line/mcp/search_command.py +17 -11
  73. code_puppy/command_line/mcp/start_all_command.py +22 -13
  74. code_puppy/command_line/mcp/start_command.py +50 -31
  75. code_puppy/command_line/mcp/status_command.py +6 -7
  76. code_puppy/command_line/mcp/stop_all_command.py +11 -8
  77. code_puppy/command_line/mcp/stop_command.py +11 -10
  78. code_puppy/command_line/mcp/test_command.py +2 -2
  79. code_puppy/command_line/mcp/utils.py +1 -1
  80. code_puppy/command_line/mcp/wizard_utils.py +22 -18
  81. code_puppy/command_line/mcp_completion.py +174 -0
  82. code_puppy/command_line/model_picker_completion.py +89 -30
  83. code_puppy/command_line/model_settings_menu.py +884 -0
  84. code_puppy/command_line/motd.py +14 -8
  85. code_puppy/command_line/onboarding_slides.py +179 -0
  86. code_puppy/command_line/onboarding_wizard.py +340 -0
  87. code_puppy/command_line/pin_command_completion.py +329 -0
  88. code_puppy/command_line/prompt_toolkit_completion.py +626 -75
  89. code_puppy/command_line/session_commands.py +296 -0
  90. code_puppy/command_line/utils.py +54 -0
  91. code_puppy/config.py +1181 -51
  92. code_puppy/error_logging.py +118 -0
  93. code_puppy/gemini_code_assist.py +385 -0
  94. code_puppy/gemini_model.py +602 -0
  95. code_puppy/http_utils.py +220 -104
  96. code_puppy/keymap.py +128 -0
  97. code_puppy/main.py +5 -594
  98. code_puppy/{mcp → mcp_}/__init__.py +17 -0
  99. code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
  100. code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
  101. code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
  102. code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
  103. code_puppy/{mcp → mcp_}/dashboard.py +15 -6
  104. code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
  105. code_puppy/{mcp → mcp_}/managed_server.py +66 -39
  106. code_puppy/{mcp → mcp_}/manager.py +146 -52
  107. code_puppy/mcp_/mcp_logs.py +224 -0
  108. code_puppy/{mcp → mcp_}/registry.py +6 -6
  109. code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
  110. code_puppy/messaging/__init__.py +199 -2
  111. code_puppy/messaging/bus.py +610 -0
  112. code_puppy/messaging/commands.py +167 -0
  113. code_puppy/messaging/markdown_patches.py +57 -0
  114. code_puppy/messaging/message_queue.py +17 -48
  115. code_puppy/messaging/messages.py +500 -0
  116. code_puppy/messaging/queue_console.py +1 -24
  117. code_puppy/messaging/renderers.py +43 -146
  118. code_puppy/messaging/rich_renderer.py +1027 -0
  119. code_puppy/messaging/spinner/__init__.py +33 -5
  120. code_puppy/messaging/spinner/console_spinner.py +92 -52
  121. code_puppy/messaging/spinner/spinner_base.py +29 -0
  122. code_puppy/messaging/subagent_console.py +461 -0
  123. code_puppy/model_factory.py +686 -80
  124. code_puppy/model_utils.py +167 -0
  125. code_puppy/models.json +86 -104
  126. code_puppy/models_dev_api.json +1 -0
  127. code_puppy/models_dev_parser.py +592 -0
  128. code_puppy/plugins/__init__.py +164 -10
  129. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  130. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  131. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  132. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  133. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  134. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  135. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  136. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  137. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  138. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  139. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  140. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  141. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  142. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  143. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  144. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  145. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  146. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  147. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  148. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  149. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  150. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  151. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  152. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  153. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  154. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  155. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  156. code_puppy/plugins/example_custom_command/README.md +280 -0
  157. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  158. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  159. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  160. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  161. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  162. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  163. code_puppy/plugins/oauth_puppy_html.py +228 -0
  164. code_puppy/plugins/shell_safety/__init__.py +6 -0
  165. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  166. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  167. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  168. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  169. code_puppy/prompts/codex_system_prompt.md +310 -0
  170. code_puppy/pydantic_patches.py +131 -0
  171. code_puppy/reopenable_async_client.py +8 -8
  172. code_puppy/round_robin_model.py +10 -15
  173. code_puppy/session_storage.py +294 -0
  174. code_puppy/status_display.py +21 -4
  175. code_puppy/summarization_agent.py +52 -14
  176. code_puppy/terminal_utils.py +418 -0
  177. code_puppy/tools/__init__.py +139 -6
  178. code_puppy/tools/agent_tools.py +548 -49
  179. code_puppy/tools/browser/__init__.py +37 -0
  180. code_puppy/tools/browser/browser_control.py +289 -0
  181. code_puppy/tools/browser/browser_interactions.py +545 -0
  182. code_puppy/tools/browser/browser_locators.py +640 -0
  183. code_puppy/tools/browser/browser_manager.py +316 -0
  184. code_puppy/tools/browser/browser_navigation.py +251 -0
  185. code_puppy/tools/browser/browser_screenshot.py +179 -0
  186. code_puppy/tools/browser/browser_scripts.py +462 -0
  187. code_puppy/tools/browser/browser_workflows.py +221 -0
  188. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  189. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  190. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  191. code_puppy/tools/browser/terminal_tools.py +525 -0
  192. code_puppy/tools/command_runner.py +941 -153
  193. code_puppy/tools/common.py +1146 -6
  194. code_puppy/tools/display.py +84 -0
  195. code_puppy/tools/file_modifications.py +288 -89
  196. code_puppy/tools/file_operations.py +352 -266
  197. code_puppy/tools/subagent_context.py +158 -0
  198. code_puppy/uvx_detection.py +242 -0
  199. code_puppy/version_checker.py +30 -11
  200. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  201. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  202. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
  203. code_puppy-0.0.366.dist-info/RECORD +217 -0
  204. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  205. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
  206. code_puppy/agent.py +0 -231
  207. code_puppy/agents/agent_orchestrator.json +0 -26
  208. code_puppy/agents/runtime_manager.py +0 -272
  209. code_puppy/command_line/mcp/add_command.py +0 -183
  210. code_puppy/command_line/meta_command_handler.py +0 -153
  211. code_puppy/message_history_processor.py +0 -490
  212. code_puppy/messaging/spinner/textual_spinner.py +0 -101
  213. code_puppy/state_management.py +0 -200
  214. code_puppy/tui/__init__.py +0 -10
  215. code_puppy/tui/app.py +0 -986
  216. code_puppy/tui/components/__init__.py +0 -21
  217. code_puppy/tui/components/chat_view.py +0 -550
  218. code_puppy/tui/components/command_history_modal.py +0 -218
  219. code_puppy/tui/components/copy_button.py +0 -139
  220. code_puppy/tui/components/custom_widgets.py +0 -63
  221. code_puppy/tui/components/human_input_modal.py +0 -175
  222. code_puppy/tui/components/input_area.py +0 -167
  223. code_puppy/tui/components/sidebar.py +0 -309
  224. code_puppy/tui/components/status_bar.py +0 -182
  225. code_puppy/tui/messages.py +0 -27
  226. code_puppy/tui/models/__init__.py +0 -8
  227. code_puppy/tui/models/chat_message.py +0 -25
  228. code_puppy/tui/models/command_history.py +0 -89
  229. code_puppy/tui/models/enums.py +0 -24
  230. code_puppy/tui/screens/__init__.py +0 -15
  231. code_puppy/tui/screens/help.py +0 -130
  232. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  233. code_puppy/tui/screens/settings.py +0 -290
  234. code_puppy/tui/screens/tools.py +0 -74
  235. code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
  236. code_puppy-0.0.169.dist-info/RECORD +0 -112
  237. /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
  238. /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
  239. /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
  240. /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
  241. /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
  242. /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
  243. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,395 @@
1
+ """Helpers for parsing file attachments from interactive prompts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mimetypes
6
+ import os
7
+ import shlex
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Iterable, List, Sequence
11
+
12
+ from pydantic_ai import BinaryContent, DocumentUrl, ImageUrl
13
+
14
+ SUPPORTED_INLINE_SCHEMES = {"http", "https"}
15
+
16
+ # Maximum path length to consider - conservative limit to avoid OS errors
17
+ # Most OS have limits around 4096, but we set lower to catch garbage early
18
+ MAX_PATH_LENGTH = 1024
19
+
20
+ # Allow common extensions people drag in the terminal.
21
+ DEFAULT_ACCEPTED_IMAGE_EXTENSIONS = {
22
+ ".png",
23
+ ".jpg",
24
+ ".jpeg",
25
+ ".gif",
26
+ ".bmp",
27
+ ".webp",
28
+ ".tiff",
29
+ }
30
+ DEFAULT_ACCEPTED_DOCUMENT_EXTENSIONS = set()
31
+
32
+
33
+ @dataclass
34
+ class PromptAttachment:
35
+ """Represents a binary attachment parsed from the input prompt."""
36
+
37
+ placeholder: str
38
+ content: BinaryContent
39
+
40
+
41
+ @dataclass
42
+ class PromptLinkAttachment:
43
+ """Represents a URL attachment supported by pydantic-ai."""
44
+
45
+ placeholder: str
46
+ url_part: ImageUrl | DocumentUrl
47
+
48
+
49
+ @dataclass
50
+ class ProcessedPrompt:
51
+ """Container for parsed input prompt and attachments."""
52
+
53
+ prompt: str
54
+ attachments: List[PromptAttachment]
55
+ link_attachments: List[PromptLinkAttachment]
56
+ warnings: List[str]
57
+
58
+
59
+ class AttachmentParsingError(RuntimeError):
60
+ """Raised when we fail to load a user-provided attachment."""
61
+
62
+
63
+ def _is_probable_path(token: str) -> bool:
64
+ """Heuristically determine whether a token is a local filesystem path."""
65
+
66
+ if not token:
67
+ return False
68
+ # Reject absurdly long tokens before any processing to avoid OS errors
69
+ if len(token) > MAX_PATH_LENGTH:
70
+ return False
71
+ if token.startswith("#"):
72
+ return False
73
+ # Windows drive letters or Unix absolute/relative paths
74
+ if token.startswith(("/", "~", "./", "../")):
75
+ return True
76
+ if len(token) >= 2 and token[1] == ":":
77
+ return True
78
+ # Things like `path/to/file.png`
79
+ return os.sep in token or '"' in token
80
+
81
+
82
+ def _unescape_dragged_path(token: str) -> str:
83
+ """Convert backslash-escaped spaces used by drag-and-drop to literal spaces."""
84
+ # Shell/terminal escaping typically produces '\ ' sequences
85
+ return token.replace(r"\ ", " ")
86
+
87
+
88
+ def _normalise_path(token: str) -> Path:
89
+ """Expand user shortcuts and resolve relative components without touching fs."""
90
+ # First unescape any drag-and-drop backslash spaces before other expansions
91
+ unescaped = _unescape_dragged_path(token)
92
+ expanded = os.path.expanduser(unescaped)
93
+ try:
94
+ # This will not resolve against symlinks because we do not call resolve()
95
+ return Path(expanded).absolute()
96
+ except Exception as exc:
97
+ raise AttachmentParsingError(f"Invalid path '{token}': {exc}") from exc
98
+
99
+
100
+ def _determine_media_type(path: Path) -> str:
101
+ """Best-effort media type detection for images only."""
102
+
103
+ mime, _ = mimetypes.guess_type(path.name)
104
+ if mime:
105
+ return mime
106
+ if path.suffix.lower() in DEFAULT_ACCEPTED_IMAGE_EXTENSIONS:
107
+ return "image/png"
108
+ return "application/octet-stream"
109
+
110
+
111
+ def _load_binary(path: Path) -> bytes:
112
+ try:
113
+ return path.read_bytes()
114
+ except FileNotFoundError as exc:
115
+ raise AttachmentParsingError(f"Attachment not found: {path}") from exc
116
+ except PermissionError as exc:
117
+ raise AttachmentParsingError(
118
+ f"Cannot read attachment (permission denied): {path}"
119
+ ) from exc
120
+ except OSError as exc:
121
+ raise AttachmentParsingError(
122
+ f"Failed to read attachment {path}: {exc}"
123
+ ) from exc
124
+
125
+
126
+ def _tokenise(prompt: str) -> Iterable[str]:
127
+ """Split the prompt preserving quoted segments using shell-like semantics."""
128
+
129
+ if not prompt:
130
+ return []
131
+ try:
132
+ # On Windows, avoid POSIX escaping so backslashes are preserved
133
+ posix_mode = os.name != "nt"
134
+ return shlex.split(prompt, posix=posix_mode)
135
+ except ValueError:
136
+ # Fallback naive split when shlex fails (e.g. unmatched quotes)
137
+ return prompt.split()
138
+
139
+
140
+ def _strip_attachment_token(token: str) -> str:
141
+ """Trim surrounding whitespace/punctuation terminals tack onto paths."""
142
+
143
+ return token.strip().strip(",;:()[]{}")
144
+
145
+
146
+ def _candidate_paths(
147
+ tokens: Sequence[str],
148
+ start: int,
149
+ max_span: int = 5,
150
+ ) -> Iterable[tuple[str, int]]:
151
+ """Yield space-joined token slices to reconstruct paths with spaces."""
152
+
153
+ collected: list[str] = []
154
+ for offset, raw in enumerate(tokens[start : start + max_span]):
155
+ collected.append(raw)
156
+ yield " ".join(collected), start + offset + 1
157
+
158
+
159
+ def _is_supported_extension(path: Path) -> bool:
160
+ suffix = path.suffix.lower()
161
+ return (
162
+ suffix
163
+ in DEFAULT_ACCEPTED_IMAGE_EXTENSIONS | DEFAULT_ACCEPTED_DOCUMENT_EXTENSIONS
164
+ )
165
+
166
+
167
+ def _parse_link(token: str) -> PromptLinkAttachment | None:
168
+ """URL parsing disabled: no URLs are treated as attachments."""
169
+ return None
170
+
171
+
172
+ @dataclass
173
+ class _DetectedPath:
174
+ placeholder: str
175
+ path: Path | None
176
+ start_index: int
177
+ consumed_until: int
178
+ unsupported: bool = False
179
+ link: PromptLinkAttachment | None = None
180
+
181
+ def has_path(self) -> bool:
182
+ return self.path is not None and not self.unsupported
183
+
184
+
185
+ def _detect_path_tokens(prompt: str) -> tuple[list[_DetectedPath], list[str]]:
186
+ # Preserve backslash-spaces from drag-and-drop before shlex tokenization
187
+ # Replace '\ ' with a marker that shlex won't split, then restore later
188
+ ESCAPE_MARKER = "\u0000ESCAPED_SPACE\u0000"
189
+ masked_prompt = prompt.replace(r"\ ", ESCAPE_MARKER)
190
+ tokens = list(_tokenise(masked_prompt))
191
+ # Restore escaped spaces in individual tokens
192
+ tokens = [t.replace(ESCAPE_MARKER, " ") for t in tokens]
193
+
194
+ detections: list[_DetectedPath] = []
195
+ warnings: list[str] = []
196
+
197
+ index = 0
198
+ while index < len(tokens):
199
+ token = tokens[index]
200
+
201
+ link_attachment = _parse_link(token)
202
+ if link_attachment:
203
+ detections.append(
204
+ _DetectedPath(
205
+ placeholder=token,
206
+ path=None,
207
+ start_index=index,
208
+ consumed_until=index + 1,
209
+ link=link_attachment,
210
+ )
211
+ )
212
+ index += 1
213
+ continue
214
+
215
+ stripped_token = _strip_attachment_token(token)
216
+ if not _is_probable_path(stripped_token):
217
+ index += 1
218
+ continue
219
+
220
+ # Additional guard: skip if stripped token exceeds reasonable path length
221
+ if len(stripped_token) > MAX_PATH_LENGTH:
222
+ index += 1
223
+ continue
224
+
225
+ start_index = index
226
+ consumed_until = index + 1
227
+ candidate_path_token = stripped_token
228
+ # For placeholder: try to reconstruct escaped representation; if none, use raw token
229
+ original_tokens_for_slice = list(_tokenise(masked_prompt))[index:consumed_until]
230
+ candidate_placeholder = "".join(
231
+ ot.replace(ESCAPE_MARKER, r"\ ") if ESCAPE_MARKER in ot else ot
232
+ for ot in original_tokens_for_slice
233
+ )
234
+ # If placeholder seems identical to raw token, just use the raw token
235
+ if candidate_placeholder == token.replace(" ", r"\ "):
236
+ candidate_placeholder = token
237
+
238
+ try:
239
+ path = _normalise_path(candidate_path_token)
240
+ except AttachmentParsingError as exc:
241
+ warnings.append(str(exc))
242
+ index = consumed_until
243
+ continue
244
+
245
+ # Guard filesystem operations against OS errors (ENAMETOOLONG, etc.)
246
+ try:
247
+ path_exists = path.exists()
248
+ path_is_file = path.is_file() if path_exists else False
249
+ except OSError:
250
+ # Skip this token if filesystem check fails (path too long, etc.)
251
+ index = consumed_until
252
+ continue
253
+
254
+ if not path_exists or not path_is_file:
255
+ found_span = False
256
+ last_path = path
257
+ for joined, end_index in _candidate_paths(tokens, index):
258
+ stripped_joined = _strip_attachment_token(joined)
259
+ if not _is_probable_path(stripped_joined):
260
+ continue
261
+ candidate_path_token = stripped_joined
262
+ candidate_placeholder = joined
263
+ consumed_until = end_index
264
+ if len(candidate_path_token) > MAX_PATH_LENGTH:
265
+ continue
266
+ try:
267
+ last_path = _normalise_path(candidate_path_token)
268
+ except AttachmentParsingError:
269
+ # Suppress warnings for non-file spans; just skip quietly
270
+ found_span = False
271
+ break
272
+ try:
273
+ if last_path.exists() and last_path.is_file():
274
+ path = last_path
275
+ found_span = True
276
+ # We'll rebuild escaped placeholder after this block
277
+ break
278
+ except OSError:
279
+ continue
280
+ if not found_span:
281
+ # Quietly skip tokens that are not files
282
+ index += 1
283
+ continue
284
+ # Reconstruct escaped placeholder for multi-token paths
285
+ original_tokens_for_path = tokens[index:consumed_until]
286
+ escaped_placeholder = " ".join(original_tokens_for_path).replace(" ", r"\ ")
287
+ candidate_placeholder = escaped_placeholder
288
+ if not _is_supported_extension(path):
289
+ detections.append(
290
+ _DetectedPath(
291
+ placeholder=candidate_placeholder,
292
+ path=path,
293
+ start_index=start_index,
294
+ consumed_until=consumed_until,
295
+ unsupported=True,
296
+ )
297
+ )
298
+ index = consumed_until
299
+ continue
300
+
301
+ # Reconstruct escaped placeholder for exact replacement later
302
+ # For unquoted spaces, keep the original literal token from the prompt
303
+ # so replacement matches precisely
304
+ escaped_placeholder = candidate_placeholder
305
+
306
+ detections.append(
307
+ _DetectedPath(
308
+ placeholder=candidate_placeholder,
309
+ path=path,
310
+ start_index=start_index,
311
+ consumed_until=consumed_until,
312
+ )
313
+ )
314
+ index = consumed_until
315
+
316
+ return detections, warnings
317
+
318
+
319
+ def parse_prompt_attachments(prompt: str) -> ProcessedPrompt:
320
+ """Extract attachments from the prompt returning cleaned text and metadata."""
321
+
322
+ attachments: List[PromptAttachment] = []
323
+
324
+ detections, detection_warnings = _detect_path_tokens(prompt)
325
+ warnings: List[str] = list(detection_warnings)
326
+
327
+ link_attachments = [d.link for d in detections if d.link is not None]
328
+
329
+ for detection in detections:
330
+ if detection.link is not None and detection.path is None:
331
+ continue
332
+ if detection.path is None:
333
+ continue
334
+ if detection.unsupported:
335
+ # Skip unsupported attachments without warning noise
336
+ continue
337
+
338
+ try:
339
+ media_type = _determine_media_type(detection.path)
340
+ data = _load_binary(detection.path)
341
+ except AttachmentParsingError:
342
+ # Silently ignore unreadable attachments to reduce prompt noise
343
+ continue
344
+ attachments.append(
345
+ PromptAttachment(
346
+ placeholder=detection.placeholder,
347
+ content=BinaryContent(data=data, media_type=media_type),
348
+ )
349
+ )
350
+
351
+ # Rebuild cleaned_prompt by skipping tokens consumed as file paths.
352
+ # This preserves original punctuation and spacing for non-attachment tokens.
353
+ ESCAPE_MARKER = "\u0000ESCAPED_SPACE\u0000"
354
+ masked = prompt.replace(r"\ ", ESCAPE_MARKER)
355
+ tokens = list(_tokenise(masked))
356
+
357
+ # Build exact token spans for file attachments (supported or unsupported)
358
+ # Skip spans for: supported files (path present and not unsupported) and links.
359
+ spans = [
360
+ (d.start_index, d.consumed_until)
361
+ for d in detections
362
+ if (d.path is not None and not d.unsupported)
363
+ or (d.link is not None and d.path is None)
364
+ ]
365
+ cleaned_parts: list[str] = []
366
+ i = 0
367
+ while i < len(tokens):
368
+ span = next((s for s in spans if s[0] <= i < s[1]), None)
369
+ if span is not None:
370
+ i = span[1]
371
+ continue
372
+ cleaned_parts.append(tokens[i].replace(ESCAPE_MARKER, " "))
373
+ i += 1
374
+
375
+ cleaned_prompt = " ".join(cleaned_parts).strip()
376
+ cleaned_prompt = " ".join(cleaned_prompt.split())
377
+
378
+ if cleaned_prompt == "" and attachments:
379
+ cleaned_prompt = "Describe the attached files in detail."
380
+
381
+ return ProcessedPrompt(
382
+ prompt=cleaned_prompt,
383
+ attachments=attachments,
384
+ link_attachments=link_attachments,
385
+ warnings=warnings,
386
+ )
387
+
388
+
389
+ __all__ = [
390
+ "ProcessedPrompt",
391
+ "PromptAttachment",
392
+ "PromptLinkAttachment",
393
+ "AttachmentParsingError",
394
+ "parse_prompt_attachments",
395
+ ]