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.
- code_puppy/__init__.py +7 -1
- code_puppy/agents/__init__.py +8 -8
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +9 -2
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +48 -9
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +146 -199
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_planning.py +163 -0
- code_puppy/agents/agent_python_programmer.py +165 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +1713 -1
- code_puppy/agents/event_stream_handler.py +350 -0
- code_puppy/agents/json_agent.py +12 -1
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +174 -4
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/claude_cache_client.py +586 -0
- code_puppy/cli_runner.py +916 -0
- code_puppy/command_line/add_model_menu.py +1079 -0
- code_puppy/command_line/agent_menu.py +395 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +605 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +520 -0
- code_puppy/command_line/command_handler.py +233 -627
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +715 -0
- code_puppy/command_line/core_commands.py +792 -0
- code_puppy/command_line/diff_menu.py +863 -0
- code_puppy/command_line/load_context_completion.py +15 -22
- code_puppy/command_line/mcp/base.py +1 -4
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +9 -4
- code_puppy/command_line/mcp/help_command.py +6 -5
- code_puppy/command_line/mcp/install_command.py +16 -27
- code_puppy/command_line/mcp/install_menu.py +685 -0
- code_puppy/command_line/mcp/list_command.py +3 -3
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +12 -4
- code_puppy/command_line/mcp/search_command.py +17 -11
- code_puppy/command_line/mcp/start_all_command.py +22 -13
- code_puppy/command_line/mcp/start_command.py +50 -31
- code_puppy/command_line/mcp/status_command.py +6 -7
- code_puppy/command_line/mcp/stop_all_command.py +11 -8
- code_puppy/command_line/mcp/stop_command.py +11 -10
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/utils.py +1 -1
- code_puppy/command_line/mcp/wizard_utils.py +22 -18
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +89 -30
- code_puppy/command_line/model_settings_menu.py +884 -0
- code_puppy/command_line/motd.py +14 -8
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +626 -75
- code_puppy/command_line/session_commands.py +296 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +1181 -51
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +602 -0
- code_puppy/http_utils.py +220 -104
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +5 -594
- code_puppy/{mcp → mcp_}/__init__.py +17 -0
- code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
- code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
- code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
- code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
- code_puppy/{mcp → mcp_}/dashboard.py +15 -6
- code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
- code_puppy/{mcp → mcp_}/managed_server.py +66 -39
- code_puppy/{mcp → mcp_}/manager.py +146 -52
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/{mcp → mcp_}/registry.py +6 -6
- code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
- code_puppy/messaging/__init__.py +199 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +17 -48
- code_puppy/messaging/messages.py +500 -0
- code_puppy/messaging/queue_console.py +1 -24
- code_puppy/messaging/renderers.py +43 -146
- code_puppy/messaging/rich_renderer.py +1027 -0
- code_puppy/messaging/spinner/__init__.py +33 -5
- code_puppy/messaging/spinner/console_spinner.py +92 -52
- code_puppy/messaging/spinner/spinner_base.py +29 -0
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +686 -80
- code_puppy/model_utils.py +167 -0
- code_puppy/models.json +86 -104
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +164 -10
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +767 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
- code_puppy/plugins/claude_code_oauth/config.py +50 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/utils.py +518 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/round_robin_model.py +10 -15
- code_puppy/session_storage.py +294 -0
- code_puppy/status_display.py +21 -4
- code_puppy/summarization_agent.py +52 -14
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +139 -6
- code_puppy/tools/agent_tools.py +548 -49
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +941 -153
- code_puppy/tools/common.py +1146 -6
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +288 -89
- code_puppy/tools/file_operations.py +352 -266
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +30 -11
- code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
- code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
- code_puppy-0.0.366.dist-info/RECORD +217 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
- code_puppy/agent.py +0 -231
- code_puppy/agents/agent_orchestrator.json +0 -26
- code_puppy/agents/runtime_manager.py +0 -272
- code_puppy/command_line/mcp/add_command.py +0 -183
- code_puppy/command_line/meta_command_handler.py +0 -153
- code_puppy/message_history_processor.py +0 -490
- code_puppy/messaging/spinner/textual_spinner.py +0 -101
- code_puppy/state_management.py +0 -200
- code_puppy/tui/__init__.py +0 -10
- code_puppy/tui/app.py +0 -986
- code_puppy/tui/components/__init__.py +0 -21
- code_puppy/tui/components/chat_view.py +0 -550
- code_puppy/tui/components/command_history_modal.py +0 -218
- code_puppy/tui/components/copy_button.py +0 -139
- code_puppy/tui/components/custom_widgets.py +0 -63
- code_puppy/tui/components/human_input_modal.py +0 -175
- code_puppy/tui/components/input_area.py +0 -167
- code_puppy/tui/components/sidebar.py +0 -309
- code_puppy/tui/components/status_bar.py +0 -182
- code_puppy/tui/messages.py +0 -27
- code_puppy/tui/models/__init__.py +0 -8
- code_puppy/tui/models/chat_message.py +0 -25
- code_puppy/tui/models/command_history.py +0 -89
- code_puppy/tui/models/enums.py +0 -24
- code_puppy/tui/screens/__init__.py +0 -15
- code_puppy/tui/screens/help.py +0 -130
- code_puppy/tui/screens/mcp_install_wizard.py +0 -803
- code_puppy/tui/screens/settings.py +0 -290
- code_puppy/tui/screens/tools.py +0 -74
- code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
- code_puppy-0.0.169.dist-info/RECORD +0 -112
- /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
- /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
- /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
- /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
- /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
- /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
- {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
|
+
]
|