shotgun-sh 0.2.29.dev2__py3-none-any.whl → 0.6.1.dev1__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.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (161) 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 +48 -45
  7. shotgun/agents/config/provider.py +44 -29
  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 +41 -0
  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/cli/clear.py +2 -2
  49. shotgun/cli/codebase/commands.py +181 -65
  50. shotgun/cli/compact.py +2 -2
  51. shotgun/cli/context.py +2 -2
  52. shotgun/cli/run.py +90 -0
  53. shotgun/cli/spec/backup.py +2 -1
  54. shotgun/cli/spec/commands.py +2 -0
  55. shotgun/cli/spec/models.py +18 -0
  56. shotgun/cli/spec/pull_service.py +122 -68
  57. shotgun/codebase/__init__.py +2 -0
  58. shotgun/codebase/benchmarks/__init__.py +35 -0
  59. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  60. shotgun/codebase/benchmarks/exporters.py +119 -0
  61. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  62. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  63. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  64. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  65. shotgun/codebase/benchmarks/models.py +129 -0
  66. shotgun/codebase/core/__init__.py +4 -0
  67. shotgun/codebase/core/call_resolution.py +91 -0
  68. shotgun/codebase/core/change_detector.py +11 -6
  69. shotgun/codebase/core/errors.py +159 -0
  70. shotgun/codebase/core/extractors/__init__.py +23 -0
  71. shotgun/codebase/core/extractors/base.py +138 -0
  72. shotgun/codebase/core/extractors/factory.py +63 -0
  73. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  74. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  75. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  76. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  77. shotgun/codebase/core/extractors/protocol.py +109 -0
  78. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  79. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  80. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  81. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  82. shotgun/codebase/core/extractors/types.py +15 -0
  83. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  84. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  85. shotgun/codebase/core/gitignore.py +252 -0
  86. shotgun/codebase/core/ingestor.py +644 -354
  87. shotgun/codebase/core/kuzu_compat.py +119 -0
  88. shotgun/codebase/core/language_config.py +239 -0
  89. shotgun/codebase/core/manager.py +256 -46
  90. shotgun/codebase/core/metrics_collector.py +310 -0
  91. shotgun/codebase/core/metrics_types.py +347 -0
  92. shotgun/codebase/core/parallel_executor.py +424 -0
  93. shotgun/codebase/core/work_distributor.py +254 -0
  94. shotgun/codebase/core/worker.py +768 -0
  95. shotgun/codebase/indexing_state.py +86 -0
  96. shotgun/codebase/models.py +94 -0
  97. shotgun/codebase/service.py +13 -0
  98. shotgun/exceptions.py +1 -1
  99. shotgun/main.py +2 -10
  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 +20 -28
  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 +43 -1
  107. shotgun/prompts/agents/research.j2 +75 -20
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +94 -4
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -15
  112. shotgun/prompts/agents/tasks.j2 +77 -23
  113. shotgun/settings.py +44 -0
  114. shotgun/shotgun_web/shared_specs/upload_pipeline.py +38 -0
  115. shotgun/tui/app.py +90 -23
  116. shotgun/tui/commands/__init__.py +9 -1
  117. shotgun/tui/components/attachment_bar.py +87 -0
  118. shotgun/tui/components/mode_indicator.py +120 -25
  119. shotgun/tui/components/prompt_input.py +23 -28
  120. shotgun/tui/components/status_bar.py +5 -4
  121. shotgun/tui/dependencies.py +58 -8
  122. shotgun/tui/protocols.py +37 -0
  123. shotgun/tui/screens/chat/chat.tcss +24 -1
  124. shotgun/tui/screens/chat/chat_screen.py +1374 -211
  125. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  126. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  127. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  128. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  129. shotgun/tui/screens/chat_screen/history/chat_history.py +49 -6
  130. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  131. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  132. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  133. shotgun/tui/screens/chat_screen/messages.py +219 -0
  134. shotgun/tui/screens/database_locked_dialog.py +219 -0
  135. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  136. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  137. shotgun/tui/screens/model_picker.py +14 -9
  138. shotgun/tui/screens/models.py +11 -0
  139. shotgun/tui/screens/shotgun_auth.py +50 -0
  140. shotgun/tui/screens/spec_pull.py +2 -0
  141. shotgun/tui/state/processing_state.py +19 -0
  142. shotgun/tui/utils/mode_progress.py +20 -86
  143. shotgun/tui/widgets/__init__.py +2 -1
  144. shotgun/tui/widgets/approval_widget.py +152 -0
  145. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  146. shotgun/tui/widgets/plan_panel.py +129 -0
  147. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  148. shotgun/tui/widgets/widget_coordinator.py +18 -0
  149. shotgun/utils/file_system_utils.py +4 -1
  150. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/METADATA +88 -34
  151. shotgun_sh-0.6.1.dev1.dist-info/RECORD +292 -0
  152. shotgun/cli/export.py +0 -81
  153. shotgun/cli/plan.py +0 -73
  154. shotgun/cli/research.py +0 -93
  155. shotgun/cli/specify.py +0 -70
  156. shotgun/cli/tasks.py +0 -78
  157. shotgun/tui/screens/onboarding.py +0 -580
  158. shotgun_sh-0.2.29.dev2.dist-info/RECORD +0 -229
  159. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/WHEEL +0 -0
  160. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/entry_points.txt +0 -0
  161. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -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
+ )
@@ -0,0 +1,193 @@
1
+ """Attachment processor for file validation and encoding.
2
+
3
+ Provides functions to validate file sizes against provider limits
4
+ and encode file contents to base64 for API submission.
5
+ """
6
+
7
+ import base64
8
+ import logging
9
+ from pathlib import Path
10
+
11
+ import aiofiles
12
+
13
+ from shotgun.agents.config.models import ProviderType
14
+ from shotgun.attachments.errors import (
15
+ cannot_read_file,
16
+ file_not_found,
17
+ file_too_large,
18
+ )
19
+ from shotgun.attachments.models import AttachmentType, FileAttachment
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Provider file size limits in bytes
24
+ PROVIDER_SIZE_LIMITS: dict[ProviderType, int] = {
25
+ ProviderType.OPENAI: 20 * 1024 * 1024, # 20MB
26
+ ProviderType.ANTHROPIC: 32 * 1024 * 1024, # 32MB
27
+ ProviderType.GOOGLE: 4 * 1024 * 1024, # 4MB
28
+ }
29
+
30
+ # Default limit for unknown providers (most restrictive)
31
+ DEFAULT_SIZE_LIMIT: int = 4 * 1024 * 1024 # 4MB
32
+
33
+
34
+ def get_provider_size_limit(provider: ProviderType) -> int:
35
+ """Get the maximum file size limit for a provider.
36
+
37
+ Args:
38
+ provider: The LLM provider type.
39
+
40
+ Returns:
41
+ Maximum file size in bytes.
42
+ """
43
+ return PROVIDER_SIZE_LIMITS.get(provider, DEFAULT_SIZE_LIMIT)
44
+
45
+
46
+ def format_file_size(size_bytes: int) -> str:
47
+ """Format file size in human-readable format.
48
+
49
+ Args:
50
+ size_bytes: File size in bytes.
51
+
52
+ Returns:
53
+ Human-readable string (e.g., "2.5 MB", "512 KB", "128 B").
54
+ """
55
+ if size_bytes < 1024:
56
+ return f"{size_bytes} B"
57
+ elif size_bytes < 1024 * 1024:
58
+ return f"{size_bytes / 1024:.1f} KB"
59
+ else:
60
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
61
+
62
+
63
+ def validate_file_size(
64
+ attachment: FileAttachment,
65
+ provider: ProviderType,
66
+ ) -> tuple[bool, str | None]:
67
+ """Validate file size against provider limit.
68
+
69
+ Args:
70
+ attachment: The file attachment to validate.
71
+ provider: The target LLM provider.
72
+
73
+ Returns:
74
+ Tuple of (is_valid, error_message).
75
+ If valid: (True, None)
76
+ If invalid: (False, "File size {size} exceeds {provider} limit of {limit}")
77
+ """
78
+ limit = get_provider_size_limit(provider)
79
+
80
+ if attachment.file_size_bytes > limit:
81
+ size_str = format_file_size(attachment.file_size_bytes)
82
+ limit_str = format_file_size(limit)
83
+ provider_name = provider.value.capitalize()
84
+ return (
85
+ False,
86
+ file_too_large(size_str, limit_str, provider_name),
87
+ )
88
+
89
+ return (True, None)
90
+
91
+
92
+ async def encode_file_to_base64(file_path: Path) -> str:
93
+ """Asynchronously read and encode file contents to base64.
94
+
95
+ Args:
96
+ file_path: Path to the file to encode.
97
+
98
+ Returns:
99
+ Base64-encoded string of file contents.
100
+
101
+ Raises:
102
+ FileNotFoundError: If file does not exist.
103
+ PermissionError: If file cannot be read.
104
+ OSError: If file read fails.
105
+ """
106
+ async with aiofiles.open(file_path, "rb") as f:
107
+ content = await f.read()
108
+
109
+ if not content:
110
+ return ""
111
+
112
+ return base64.b64encode(content).decode("utf-8")
113
+
114
+
115
+ async def process_attachment(
116
+ attachment: FileAttachment,
117
+ provider: ProviderType,
118
+ ) -> tuple[FileAttachment, str | None]:
119
+ """Validate and process an attachment for submission.
120
+
121
+ Validates file size against provider limits and encodes content to base64.
122
+ All supported attachment types (PDF, PNG, JPG, JPEG, GIF, WEBP) work with
123
+ all providers (OpenAI, Anthropic, Google) via BinaryContent.
124
+
125
+ Args:
126
+ attachment: The file attachment to process.
127
+ provider: The target LLM provider.
128
+
129
+ Returns:
130
+ Tuple of (processed_attachment, error_message).
131
+ If successful: (attachment_with_base64_content, None)
132
+ If failed: (original_attachment, error_message)
133
+ """
134
+ # Validate file size
135
+ is_valid, error = validate_file_size(attachment, provider)
136
+ if not is_valid:
137
+ return (attachment, error)
138
+
139
+ # Encode file to base64
140
+ try:
141
+ content_base64 = await encode_file_to_base64(attachment.file_path)
142
+ except FileNotFoundError:
143
+ return (attachment, file_not_found(attachment.file_path))
144
+ except PermissionError:
145
+ return (attachment, cannot_read_file(attachment.file_path, "permission denied"))
146
+ except OSError as e:
147
+ logger.warning(f"Failed to read file '{attachment.file_path}': {e}")
148
+ return (attachment, cannot_read_file(attachment.file_path))
149
+
150
+ # Create new attachment with base64 content
151
+ processed = FileAttachment(
152
+ file_path=attachment.file_path,
153
+ file_name=attachment.file_name,
154
+ file_type=attachment.file_type,
155
+ file_size_bytes=attachment.file_size_bytes,
156
+ content_base64=content_base64,
157
+ mime_type=attachment.mime_type,
158
+ )
159
+
160
+ logger.debug(
161
+ f"Processed attachment: {processed.file_name} "
162
+ f"({len(content_base64)} base64 chars)"
163
+ )
164
+
165
+ return (processed, None)
166
+
167
+
168
+ def create_attachment_hint_display(attachment: FileAttachment) -> str:
169
+ """Create display string for attachment in chat history.
170
+
171
+ Args:
172
+ attachment: The file attachment.
173
+
174
+ Returns:
175
+ Formatted display string (e.g., "document.pdf (2.5 MB)").
176
+ """
177
+ size_str = format_file_size(attachment.file_size_bytes)
178
+ return f"{attachment.file_name} ({size_str})"
179
+
180
+
181
+ def get_attachment_icon(attachment_type: AttachmentType) -> str:
182
+ """Get the appropriate icon for an attachment type.
183
+
184
+ Args:
185
+ attachment_type: The type of attachment.
186
+
187
+ Returns:
188
+ Icon string for display.
189
+ """
190
+ if attachment_type == AttachmentType.PDF:
191
+ return "\U0001f4c4" # document emoji
192
+ else:
193
+ return "\U0001f5bc\ufe0f" # framed picture emoji
shotgun/cli/clear.py CHANGED
@@ -1,13 +1,13 @@
1
1
  """Clear command for shotgun CLI."""
2
2
 
3
3
  import asyncio
4
- from pathlib import Path
5
4
 
6
5
  import typer
7
6
  from rich.console import Console
8
7
 
9
8
  from shotgun.agents.conversation import ConversationManager
10
9
  from shotgun.logging_config import get_logger
10
+ from shotgun.utils import get_shotgun_home
11
11
 
12
12
  app = typer.Typer(
13
13
  name="clear", help="Clear the conversation history", no_args_is_help=False
@@ -26,7 +26,7 @@ def clear() -> None:
26
26
  """
27
27
  try:
28
28
  # Get conversation file path
29
- conversation_file = Path.home() / ".shotgun-sh" / "conversation.json"
29
+ conversation_file = get_shotgun_home() / "conversation.json"
30
30
 
31
31
  # Check if file exists
32
32
  if not conversation_file.exists():