shotgun-sh 0.3.3.dev1__py3-none-any.whl → 0.6.2__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 (159) hide show
  1. shotgun/agents/agent_manager.py +497 -30
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +90 -77
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +52 -8
  6. shotgun/agents/config/models.py +21 -27
  7. shotgun/agents/config/provider.py +44 -27
  8. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  9. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  10. shotgun/agents/export.py +12 -13
  11. shotgun/agents/file_read.py +176 -0
  12. shotgun/agents/messages.py +15 -3
  13. shotgun/agents/models.py +90 -2
  14. shotgun/agents/plan.py +12 -13
  15. shotgun/agents/research.py +13 -10
  16. shotgun/agents/router/__init__.py +47 -0
  17. shotgun/agents/router/models.py +384 -0
  18. shotgun/agents/router/router.py +185 -0
  19. shotgun/agents/router/tools/__init__.py +18 -0
  20. shotgun/agents/router/tools/delegation_tools.py +557 -0
  21. shotgun/agents/router/tools/plan_tools.py +403 -0
  22. shotgun/agents/runner.py +17 -2
  23. shotgun/agents/specify.py +12 -13
  24. shotgun/agents/tasks.py +12 -13
  25. shotgun/agents/tools/__init__.py +8 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  27. shotgun/agents/tools/codebase/file_read.py +26 -35
  28. shotgun/agents/tools/codebase/query_graph.py +9 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  30. shotgun/agents/tools/file_management.py +81 -3
  31. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  32. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  33. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  34. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  35. shotgun/agents/tools/markdown_tools/models.py +86 -0
  36. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  37. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  38. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  39. shotgun/agents/tools/registry.py +46 -6
  40. shotgun/agents/tools/web_search/__init__.py +1 -2
  41. shotgun/agents/tools/web_search/gemini.py +1 -3
  42. shotgun/agents/tools/web_search/openai.py +42 -23
  43. shotgun/attachments/__init__.py +41 -0
  44. shotgun/attachments/errors.py +60 -0
  45. shotgun/attachments/models.py +107 -0
  46. shotgun/attachments/parser.py +257 -0
  47. shotgun/attachments/processor.py +193 -0
  48. shotgun/build_constants.py +4 -7
  49. shotgun/cli/clear.py +2 -2
  50. shotgun/cli/codebase/commands.py +181 -65
  51. shotgun/cli/compact.py +2 -2
  52. shotgun/cli/context.py +2 -2
  53. shotgun/cli/error_handler.py +2 -2
  54. shotgun/cli/run.py +90 -0
  55. shotgun/cli/spec/backup.py +2 -1
  56. shotgun/codebase/__init__.py +2 -0
  57. shotgun/codebase/benchmarks/__init__.py +35 -0
  58. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  59. shotgun/codebase/benchmarks/exporters.py +119 -0
  60. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  61. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  62. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  63. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  64. shotgun/codebase/benchmarks/models.py +129 -0
  65. shotgun/codebase/core/__init__.py +4 -0
  66. shotgun/codebase/core/call_resolution.py +91 -0
  67. shotgun/codebase/core/change_detector.py +11 -6
  68. shotgun/codebase/core/errors.py +159 -0
  69. shotgun/codebase/core/extractors/__init__.py +23 -0
  70. shotgun/codebase/core/extractors/base.py +138 -0
  71. shotgun/codebase/core/extractors/factory.py +63 -0
  72. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  73. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  74. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  75. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  76. shotgun/codebase/core/extractors/protocol.py +109 -0
  77. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  78. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  79. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  80. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  81. shotgun/codebase/core/extractors/types.py +15 -0
  82. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  83. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  84. shotgun/codebase/core/gitignore.py +252 -0
  85. shotgun/codebase/core/ingestor.py +644 -354
  86. shotgun/codebase/core/kuzu_compat.py +119 -0
  87. shotgun/codebase/core/language_config.py +239 -0
  88. shotgun/codebase/core/manager.py +256 -46
  89. shotgun/codebase/core/metrics_collector.py +310 -0
  90. shotgun/codebase/core/metrics_types.py +347 -0
  91. shotgun/codebase/core/parallel_executor.py +424 -0
  92. shotgun/codebase/core/work_distributor.py +254 -0
  93. shotgun/codebase/core/worker.py +768 -0
  94. shotgun/codebase/indexing_state.py +86 -0
  95. shotgun/codebase/models.py +94 -0
  96. shotgun/codebase/service.py +13 -0
  97. shotgun/exceptions.py +9 -9
  98. shotgun/main.py +3 -16
  99. shotgun/posthog_telemetry.py +165 -24
  100. shotgun/prompts/agents/export.j2 +2 -0
  101. shotgun/prompts/agents/file_read.j2 +48 -0
  102. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -52
  103. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  104. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  105. shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
  106. shotgun/prompts/agents/plan.j2 +38 -12
  107. shotgun/prompts/agents/research.j2 +70 -31
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +53 -16
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -13
  112. shotgun/prompts/agents/tasks.j2 +72 -34
  113. shotgun/settings.py +49 -10
  114. shotgun/tui/app.py +154 -24
  115. shotgun/tui/commands/__init__.py +9 -1
  116. shotgun/tui/components/attachment_bar.py +87 -0
  117. shotgun/tui/components/mode_indicator.py +120 -25
  118. shotgun/tui/components/prompt_input.py +25 -28
  119. shotgun/tui/components/status_bar.py +14 -7
  120. shotgun/tui/dependencies.py +58 -8
  121. shotgun/tui/protocols.py +55 -0
  122. shotgun/tui/screens/chat/chat.tcss +24 -1
  123. shotgun/tui/screens/chat/chat_screen.py +1376 -213
  124. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  125. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  126. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  127. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  128. shotgun/tui/screens/chat_screen/history/chat_history.py +58 -6
  129. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  130. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  131. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  132. shotgun/tui/screens/chat_screen/messages.py +219 -0
  133. shotgun/tui/screens/database_locked_dialog.py +219 -0
  134. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  135. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  136. shotgun/tui/screens/model_picker.py +1 -3
  137. shotgun/tui/screens/models.py +11 -0
  138. shotgun/tui/state/processing_state.py +19 -0
  139. shotgun/tui/utils/mode_progress.py +20 -86
  140. shotgun/tui/widgets/__init__.py +2 -1
  141. shotgun/tui/widgets/approval_widget.py +152 -0
  142. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  143. shotgun/tui/widgets/plan_panel.py +129 -0
  144. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  145. shotgun/tui/widgets/widget_coordinator.py +18 -0
  146. shotgun/utils/file_system_utils.py +4 -1
  147. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +88 -35
  148. shotgun_sh-0.6.2.dist-info/RECORD +291 -0
  149. shotgun/cli/export.py +0 -81
  150. shotgun/cli/plan.py +0 -73
  151. shotgun/cli/research.py +0 -93
  152. shotgun/cli/specify.py +0 -70
  153. shotgun/cli/tasks.py +0 -78
  154. shotgun/sentry_telemetry.py +0 -232
  155. shotgun/tui/screens/onboarding.py +0 -580
  156. shotgun_sh-0.3.3.dev1.dist-info/RECORD +0 -229
  157. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
  158. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
  159. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,7 @@
1
1
  """OpenAI web search tool implementation."""
2
2
 
3
+ import asyncio
4
+
3
5
  from openai import AsyncOpenAI
4
6
  from opentelemetry import trace
5
7
 
@@ -15,6 +17,9 @@ logger = get_logger(__name__)
15
17
  # Global prompt loader instance
16
18
  prompt_loader = PromptLoader()
17
19
 
20
+ # Timeout for web search API call (in seconds)
21
+ WEB_SEARCH_TIMEOUT = 120 # 2 minutes
22
+
18
23
 
19
24
  @register_tool(
20
25
  category=ToolCategory.WEB_RESEARCH,
@@ -64,29 +69,43 @@ async def openai_web_search_tool(query: str) -> str:
64
69
  )
65
70
 
66
71
  client = AsyncOpenAI(api_key=api_key)
67
- response = await client.responses.create(
68
- model="gpt-5-mini",
69
- input=[
70
- {"role": "user", "content": [{"type": "input_text", "text": prompt}]}
71
- ],
72
- text={
73
- "format": {"type": "text"},
74
- "verbosity": "high",
75
- }, # Increased from medium
76
- reasoning={"effort": "medium", "summary": "auto"},
77
- tools=[
78
- {
79
- "type": "web_search",
80
- "user_location": {"type": "approximate"},
81
- "search_context_size": "high", # Increased from low for more context
82
- }
83
- ],
84
- store=False,
85
- include=[
86
- "reasoning.encrypted_content",
87
- "web_search_call.action.sources", # pyright: ignore[reportArgumentType]
88
- ],
89
- )
72
+
73
+ # Wrap API call with timeout to prevent indefinite hangs
74
+ try:
75
+ response = await asyncio.wait_for(
76
+ client.responses.create(
77
+ model="gpt-5-mini",
78
+ input=[
79
+ {
80
+ "role": "user",
81
+ "content": [{"type": "input_text", "text": prompt}],
82
+ }
83
+ ],
84
+ text={
85
+ "format": {"type": "text"},
86
+ "verbosity": "high",
87
+ },
88
+ reasoning={"effort": "medium", "summary": "auto"},
89
+ tools=[
90
+ {
91
+ "type": "web_search",
92
+ "user_location": {"type": "approximate"},
93
+ "search_context_size": "high",
94
+ }
95
+ ],
96
+ store=False,
97
+ include=[
98
+ "reasoning.encrypted_content",
99
+ "web_search_call.action.sources", # pyright: ignore[reportArgumentType]
100
+ ],
101
+ ),
102
+ timeout=WEB_SEARCH_TIMEOUT,
103
+ )
104
+ except asyncio.TimeoutError:
105
+ error_msg = f"Web search timed out after {WEB_SEARCH_TIMEOUT} seconds"
106
+ logger.warning("⏱️ %s", error_msg)
107
+ span.set_attribute("output.value", f"**Error:**\n {error_msg}\n")
108
+ return error_msg
90
109
 
91
110
  result_text = response.output_text or "No content returned"
92
111
 
@@ -0,0 +1,41 @@
1
+ """File attachment support for @path syntax.
2
+
3
+ This module provides functionality for parsing and processing file attachments
4
+ in user input using the @path syntax (e.g., @/path/to/file.pdf).
5
+ """
6
+
7
+ from shotgun.attachments.models import (
8
+ AttachmentBarState,
9
+ AttachmentHint,
10
+ AttachmentParseResult,
11
+ AttachmentType,
12
+ FileAttachment,
13
+ )
14
+ from shotgun.attachments.parser import is_image_type, parse_attachment_reference
15
+ from shotgun.attachments.processor import (
16
+ create_attachment_hint_display,
17
+ format_file_size,
18
+ get_attachment_icon,
19
+ get_provider_size_limit,
20
+ process_attachment,
21
+ validate_file_size,
22
+ )
23
+
24
+ __all__ = [
25
+ # Models
26
+ "AttachmentBarState",
27
+ "AttachmentHint",
28
+ "AttachmentParseResult",
29
+ "AttachmentType",
30
+ "FileAttachment",
31
+ # Parser
32
+ "is_image_type",
33
+ "parse_attachment_reference",
34
+ # Processor
35
+ "create_attachment_hint_display",
36
+ "format_file_size",
37
+ "get_attachment_icon",
38
+ "get_provider_size_limit",
39
+ "process_attachment",
40
+ "validate_file_size",
41
+ ]
@@ -0,0 +1,60 @@
1
+ """Attachment error message formatting utilities.
2
+
3
+ Centralizes error message formatting for consistent user feedback.
4
+ All attachment-related error messages should use these functions.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ # Warning emoji prefix for all error messages
10
+ WARNING_PREFIX = "\u26a0\ufe0f" # ⚠️
11
+
12
+
13
+ def file_not_found(path: Path | str) -> str:
14
+ """Format file not found error message."""
15
+ return f"{WARNING_PREFIX} File not found: {path}"
16
+
17
+
18
+ def not_a_file(path: Path | str) -> str:
19
+ """Format not a file (e.g., directory) error message."""
20
+ return f"{WARNING_PREFIX} Not a file: {path}"
21
+
22
+
23
+ def unsupported_file_type(extension: str, supported: str) -> str:
24
+ """Format unsupported file type error message.
25
+
26
+ Args:
27
+ extension: The file extension (e.g., ".doc" or "(no extension)")
28
+ supported: Comma-separated list of supported extensions
29
+ """
30
+ return (
31
+ f"{WARNING_PREFIX} Unsupported file type: {extension} (supported: {supported})"
32
+ )
33
+
34
+
35
+ def cannot_read_file(path: Path | str, reason: str | None = None) -> str:
36
+ """Format cannot read file error message.
37
+
38
+ Args:
39
+ path: Path to the file
40
+ reason: Optional reason (e.g., "permission denied")
41
+ """
42
+ if reason:
43
+ return f"{WARNING_PREFIX} Cannot read file: {path} ({reason})"
44
+ return f"{WARNING_PREFIX} Cannot read file: {path}"
45
+
46
+
47
+ def could_not_resolve_path(path: str) -> str:
48
+ """Format path resolution error message."""
49
+ return f"{WARNING_PREFIX} Could not resolve path: {path}"
50
+
51
+
52
+ def file_too_large(size: str, limit: str, provider: str) -> str:
53
+ """Format file too large error message.
54
+
55
+ Args:
56
+ size: Human-readable file size (e.g., "45.0 MB")
57
+ limit: Human-readable size limit (e.g., "32.0 MB")
58
+ provider: Provider name (e.g., "Anthropic")
59
+ """
60
+ return f"{WARNING_PREFIX} File too large: {size} (max: {limit} for {provider})"
@@ -0,0 +1,107 @@
1
+ """Type contracts for file attachment support.
2
+
3
+ These models define the shape of file attachment data throughout the system.
4
+ """
5
+
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+ from typing import Literal, Protocol
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class AttachmentType(StrEnum):
14
+ """Supported attachment file types."""
15
+
16
+ PDF = "pdf"
17
+ PNG = "png"
18
+ JPG = "jpg"
19
+ JPEG = "jpeg"
20
+ GIF = "gif"
21
+ WEBP = "webp"
22
+
23
+
24
+ class FileAttachment(BaseModel):
25
+ """Represents a file attachment pending submission.
26
+
27
+ This model tracks attachment state from parsing through submission.
28
+ """
29
+
30
+ file_path: Path = Field(..., description="Absolute path to the attached file")
31
+ file_name: str = Field(..., description="Display name (basename)")
32
+ file_type: AttachmentType = Field(..., description="File type enum")
33
+ file_size_bytes: int = Field(..., description="File size in bytes")
34
+ content_base64: str | None = Field(
35
+ default=None, description="Base64-encoded content (populated on submission)"
36
+ )
37
+ mime_type: str = Field(..., description="MIME type for API submission")
38
+
39
+
40
+ class AttachmentHint(BaseModel):
41
+ """Hint message variant for displaying attachments in chat history.
42
+
43
+ Used by UIHint system to render attachment indicators in the conversation.
44
+ """
45
+
46
+ filename: str = Field(..., description="Display filename")
47
+ file_type: AttachmentType = Field(..., description="File type for icon selection")
48
+ file_size_display: str = Field(
49
+ ..., description="Human-readable size (e.g., '2.5 MB')"
50
+ )
51
+ kind: Literal["attachment"] = "attachment"
52
+
53
+
54
+ class AttachmentBarState(BaseModel):
55
+ """State model for the AttachmentBar widget.
56
+
57
+ Tracks current attachment for display above the input.
58
+ """
59
+
60
+ attachment: FileAttachment | None = Field(
61
+ default=None, description="Currently attached file, or None if no attachment"
62
+ )
63
+
64
+
65
+ class AttachmentParseResult(BaseModel):
66
+ """Result of parsing @path references from user input.
67
+
68
+ Contains the original text (with @path preserved) and any successfully
69
+ parsed attachment. The @path reference is kept in the text so the LLM
70
+ knows which file is being referenced.
71
+ """
72
+
73
+ original_text: str = Field(
74
+ ...,
75
+ description="Original input text with @path reference preserved for LLM context",
76
+ )
77
+ attachment: FileAttachment | None = Field(
78
+ default=None, description="Parsed attachment, or None if no valid @path found"
79
+ )
80
+ error_message: str | None = Field(
81
+ default=None,
82
+ description="Error message if parsing failed (file not found, unsupported type, etc.)",
83
+ )
84
+
85
+
86
+ class AttachmentCapability(Protocol):
87
+ """Protocol for checking provider attachment capabilities.
88
+
89
+ Note: All three providers (OpenAI, Anthropic, Gemini) support both images
90
+ and PDFs. OpenAI requires vision-capable models (GPT-4o, GPT-4o mini,
91
+ GPT-5.2) for PDF support.
92
+ """
93
+
94
+ @property
95
+ def supports_images(self) -> bool:
96
+ """Whether provider supports image attachments."""
97
+ ...
98
+
99
+ @property
100
+ def supports_pdf(self) -> bool:
101
+ """Whether provider supports native PDF attachments."""
102
+ ...
103
+
104
+ @property
105
+ def max_file_size_bytes(self) -> int:
106
+ """Maximum file size in bytes for this provider."""
107
+ ...
@@ -0,0 +1,257 @@
1
+ """Attachment path parser for @path syntax in user input.
2
+
3
+ Parses file references from user input text. Supported formats:
4
+ - Absolute paths: @/absolute/path.pdf
5
+ - Home directory: @~/Documents/file.png
6
+ - Explicit relative: @./relative.jpg, @../parent/file.gif
7
+ - Bare relative: @tmp/file.pdf, @path/to/file.png
8
+ - Filename only: @document.pdf, @image.png
9
+
10
+ Without the @ prefix, file paths are passed through to the LLM which
11
+ can use its own file tools to handle them.
12
+ """
13
+
14
+ import logging
15
+ import re
16
+ from pathlib import Path
17
+
18
+ from shotgun.attachments.errors import (
19
+ cannot_read_file,
20
+ could_not_resolve_path,
21
+ file_not_found,
22
+ not_a_file,
23
+ unsupported_file_type,
24
+ )
25
+ from shotgun.attachments.models import (
26
+ AttachmentParseResult,
27
+ AttachmentType,
28
+ FileAttachment,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Regex pattern for @path syntax
34
+ # Matches:
35
+ # - Explicit prefixes: @/absolute, @~/, @./, @../
36
+ # - Bare relative paths: @tmp/file, @path/to/file
37
+ # - Filenames with supported extensions: @file.pdf, @image.png
38
+ # Excludes trailing punctuation that commonly follows paths in sentences
39
+ ATTACHMENT_PATH_PATTERN = re.compile(
40
+ r"@("
41
+ r"(?:/|~|\.\.?/)[^\s?!,;:\"')\]]+" # /path, ~/path, ./path, ../path
42
+ r"|"
43
+ r"\w[^\s?!,;:\"')\]@]*/[^\s?!,;:\"')\]]+" # path/to/file (bare relative)
44
+ r"|"
45
+ r"\w[\w.-]*\.(?:pdf|png|jpe?g|gif|webp)" # file.pdf (filename with extension)
46
+ r")",
47
+ re.IGNORECASE,
48
+ )
49
+
50
+ # Supported file extensions mapped to AttachmentType
51
+ SUPPORTED_EXTENSIONS: dict[str, AttachmentType] = {
52
+ ".pdf": AttachmentType.PDF,
53
+ ".png": AttachmentType.PNG,
54
+ ".jpg": AttachmentType.JPG,
55
+ ".jpeg": AttachmentType.JPEG,
56
+ ".gif": AttachmentType.GIF,
57
+ ".webp": AttachmentType.WEBP,
58
+ }
59
+
60
+ # MIME types for each attachment type
61
+ MIME_TYPES: dict[AttachmentType, str] = {
62
+ AttachmentType.PDF: "application/pdf",
63
+ AttachmentType.PNG: "image/png",
64
+ AttachmentType.JPG: "image/jpeg",
65
+ AttachmentType.JPEG: "image/jpeg",
66
+ AttachmentType.GIF: "image/gif",
67
+ AttachmentType.WEBP: "image/webp",
68
+ }
69
+
70
+
71
+ def _extract_path_reference(text: str) -> str | None:
72
+ """Extract the first @path reference from text.
73
+
74
+ Args:
75
+ text: Input text to search.
76
+
77
+ Returns:
78
+ The path string (without @) if found, None otherwise.
79
+ """
80
+ match = ATTACHMENT_PATH_PATTERN.search(text)
81
+ if match:
82
+ return match.group(1)
83
+ return None
84
+
85
+
86
+ def _resolve_path(path_str: str) -> Path:
87
+ """Resolve a path string to an absolute Path.
88
+
89
+ Handles:
90
+ - Absolute paths: /path/to/file
91
+ - Home directory: ~/path/to/file
92
+ - Relative paths: ./file or ../file
93
+
94
+ Args:
95
+ path_str: Path string from user input.
96
+
97
+ Returns:
98
+ Resolved absolute Path object.
99
+ """
100
+ path = Path(path_str)
101
+
102
+ # Expand ~ to user home directory
103
+ if path_str.startswith("~"):
104
+ path = path.expanduser()
105
+
106
+ # Resolve to absolute path (handles ./ and ../)
107
+ return path.resolve()
108
+
109
+
110
+ def _validate_file_extension(path: Path) -> AttachmentType | None:
111
+ """Validate file has a supported extension.
112
+
113
+ Args:
114
+ path: Path to validate.
115
+
116
+ Returns:
117
+ AttachmentType if extension is supported, None otherwise.
118
+ """
119
+ extension = path.suffix.lower()
120
+ return SUPPORTED_EXTENSIONS.get(extension)
121
+
122
+
123
+ def _get_mime_type(attachment_type: AttachmentType) -> str:
124
+ """Get MIME type for an attachment type.
125
+
126
+ Args:
127
+ attachment_type: The attachment type enum.
128
+
129
+ Returns:
130
+ MIME type string (e.g., "application/pdf").
131
+ """
132
+ return MIME_TYPES[attachment_type]
133
+
134
+
135
+ def is_image_type(attachment_type: AttachmentType) -> bool:
136
+ """Check if attachment type is an image format.
137
+
138
+ Args:
139
+ attachment_type: The attachment type to check.
140
+
141
+ Returns:
142
+ True if PNG, JPG, JPEG, GIF, or WEBP; False for PDF.
143
+ """
144
+ return attachment_type != AttachmentType.PDF
145
+
146
+
147
+ def parse_attachment_reference(text: str) -> AttachmentParseResult:
148
+ """Parse @path reference from user input text.
149
+
150
+ Extracts the first @path reference from the input text, validates the file
151
+ exists and has a supported extension, and returns parsing result.
152
+
153
+ The original text is preserved with the @path reference intact so the LLM
154
+ knows which file is being referenced.
155
+
156
+ Args:
157
+ text: User input text that may contain an @path reference.
158
+
159
+ Returns:
160
+ AttachmentParseResult with:
161
+ - original_text: The input text unchanged (with @path preserved)
162
+ - attachment: FileAttachment if valid file found, None otherwise
163
+ - error_message: Error description if parsing failed, None if successful
164
+
165
+ Examples:
166
+ >>> result = parse_attachment_reference("Analyze @/path/to/doc.pdf")
167
+ >>> result.original_text
168
+ 'Analyze @/path/to/doc.pdf'
169
+ >>> result.attachment.file_name
170
+ 'doc.pdf'
171
+ """
172
+ # Extract path reference from text
173
+ path_str = _extract_path_reference(text)
174
+
175
+ # No @path reference found - not an error, just no attachment
176
+ if path_str is None:
177
+ return AttachmentParseResult(
178
+ original_text=text,
179
+ attachment=None,
180
+ error_message=None,
181
+ )
182
+
183
+ # Resolve to absolute path
184
+ try:
185
+ resolved_path = _resolve_path(path_str)
186
+ except Exception as e:
187
+ logger.warning(f"Failed to resolve path '{path_str}': {e}")
188
+ return AttachmentParseResult(
189
+ original_text=text,
190
+ attachment=None,
191
+ error_message=could_not_resolve_path(path_str),
192
+ )
193
+
194
+ # Check if file exists
195
+ if not resolved_path.exists():
196
+ return AttachmentParseResult(
197
+ original_text=text,
198
+ attachment=None,
199
+ error_message=file_not_found(resolved_path),
200
+ )
201
+
202
+ # Check if it's a file (not a directory)
203
+ if not resolved_path.is_file():
204
+ return AttachmentParseResult(
205
+ original_text=text,
206
+ attachment=None,
207
+ error_message=not_a_file(resolved_path),
208
+ )
209
+
210
+ # Validate file extension
211
+ attachment_type = _validate_file_extension(resolved_path)
212
+ if attachment_type is None:
213
+ extension = resolved_path.suffix or "(no extension)"
214
+ supported = ", ".join(ext.lstrip(".") for ext in SUPPORTED_EXTENSIONS.keys())
215
+ return AttachmentParseResult(
216
+ original_text=text,
217
+ attachment=None,
218
+ error_message=unsupported_file_type(extension, supported),
219
+ )
220
+
221
+ # Check file is readable
222
+ try:
223
+ file_size = resolved_path.stat().st_size
224
+ except PermissionError:
225
+ return AttachmentParseResult(
226
+ original_text=text,
227
+ attachment=None,
228
+ error_message=cannot_read_file(resolved_path, "permission denied"),
229
+ )
230
+ except OSError as e:
231
+ logger.warning(f"Failed to stat file '{resolved_path}': {e}")
232
+ return AttachmentParseResult(
233
+ original_text=text,
234
+ attachment=None,
235
+ error_message=cannot_read_file(resolved_path),
236
+ )
237
+
238
+ # Create successful attachment (content_base64 will be populated by processor)
239
+ attachment = FileAttachment(
240
+ file_path=resolved_path,
241
+ file_name=resolved_path.name,
242
+ file_type=attachment_type,
243
+ file_size_bytes=file_size,
244
+ content_base64=None,
245
+ mime_type=_get_mime_type(attachment_type),
246
+ )
247
+
248
+ logger.debug(
249
+ f"Parsed attachment: {attachment.file_name} "
250
+ f"({attachment.file_type.value}, {file_size} bytes)"
251
+ )
252
+
253
+ return AttachmentParseResult(
254
+ original_text=text,
255
+ attachment=attachment,
256
+ error_message=None,
257
+ )