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.
- shotgun/agents/agent_manager.py +497 -30
- shotgun/agents/cancellation.py +103 -0
- shotgun/agents/common.py +90 -77
- shotgun/agents/config/README.md +0 -1
- shotgun/agents/config/manager.py +52 -8
- shotgun/agents/config/models.py +21 -27
- shotgun/agents/config/provider.py +44 -27
- shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
- shotgun/agents/conversation/history/token_counting/base.py +51 -9
- shotgun/agents/export.py +12 -13
- shotgun/agents/file_read.py +176 -0
- shotgun/agents/messages.py +15 -3
- shotgun/agents/models.py +90 -2
- shotgun/agents/plan.py +12 -13
- shotgun/agents/research.py +13 -10
- shotgun/agents/router/__init__.py +47 -0
- shotgun/agents/router/models.py +384 -0
- shotgun/agents/router/router.py +185 -0
- shotgun/agents/router/tools/__init__.py +18 -0
- shotgun/agents/router/tools/delegation_tools.py +557 -0
- shotgun/agents/router/tools/plan_tools.py +403 -0
- shotgun/agents/runner.py +17 -2
- shotgun/agents/specify.py +12 -13
- shotgun/agents/tasks.py +12 -13
- shotgun/agents/tools/__init__.py +8 -0
- shotgun/agents/tools/codebase/directory_lister.py +27 -39
- shotgun/agents/tools/codebase/file_read.py +26 -35
- shotgun/agents/tools/codebase/query_graph.py +9 -0
- shotgun/agents/tools/codebase/retrieve_code.py +9 -0
- shotgun/agents/tools/file_management.py +81 -3
- shotgun/agents/tools/file_read_tools/__init__.py +7 -0
- shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
- shotgun/agents/tools/markdown_tools/__init__.py +62 -0
- shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
- shotgun/agents/tools/markdown_tools/models.py +86 -0
- shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
- shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
- shotgun/agents/tools/markdown_tools/utils.py +453 -0
- shotgun/agents/tools/registry.py +46 -6
- shotgun/agents/tools/web_search/__init__.py +1 -2
- shotgun/agents/tools/web_search/gemini.py +1 -3
- shotgun/agents/tools/web_search/openai.py +42 -23
- shotgun/attachments/__init__.py +41 -0
- shotgun/attachments/errors.py +60 -0
- shotgun/attachments/models.py +107 -0
- shotgun/attachments/parser.py +257 -0
- shotgun/attachments/processor.py +193 -0
- shotgun/build_constants.py +4 -7
- shotgun/cli/clear.py +2 -2
- shotgun/cli/codebase/commands.py +181 -65
- shotgun/cli/compact.py +2 -2
- shotgun/cli/context.py +2 -2
- shotgun/cli/error_handler.py +2 -2
- shotgun/cli/run.py +90 -0
- shotgun/cli/spec/backup.py +2 -1
- shotgun/codebase/__init__.py +2 -0
- shotgun/codebase/benchmarks/__init__.py +35 -0
- shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
- shotgun/codebase/benchmarks/exporters.py +119 -0
- shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
- shotgun/codebase/benchmarks/formatters/base.py +34 -0
- shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
- shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
- shotgun/codebase/benchmarks/models.py +129 -0
- shotgun/codebase/core/__init__.py +4 -0
- shotgun/codebase/core/call_resolution.py +91 -0
- shotgun/codebase/core/change_detector.py +11 -6
- shotgun/codebase/core/errors.py +159 -0
- shotgun/codebase/core/extractors/__init__.py +23 -0
- shotgun/codebase/core/extractors/base.py +138 -0
- shotgun/codebase/core/extractors/factory.py +63 -0
- shotgun/codebase/core/extractors/go/__init__.py +7 -0
- shotgun/codebase/core/extractors/go/extractor.py +122 -0
- shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
- shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
- shotgun/codebase/core/extractors/protocol.py +109 -0
- shotgun/codebase/core/extractors/python/__init__.py +7 -0
- shotgun/codebase/core/extractors/python/extractor.py +141 -0
- shotgun/codebase/core/extractors/rust/__init__.py +7 -0
- shotgun/codebase/core/extractors/rust/extractor.py +139 -0
- shotgun/codebase/core/extractors/types.py +15 -0
- shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
- shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
- shotgun/codebase/core/gitignore.py +252 -0
- shotgun/codebase/core/ingestor.py +644 -354
- shotgun/codebase/core/kuzu_compat.py +119 -0
- shotgun/codebase/core/language_config.py +239 -0
- shotgun/codebase/core/manager.py +256 -46
- shotgun/codebase/core/metrics_collector.py +310 -0
- shotgun/codebase/core/metrics_types.py +347 -0
- shotgun/codebase/core/parallel_executor.py +424 -0
- shotgun/codebase/core/work_distributor.py +254 -0
- shotgun/codebase/core/worker.py +768 -0
- shotgun/codebase/indexing_state.py +86 -0
- shotgun/codebase/models.py +94 -0
- shotgun/codebase/service.py +13 -0
- shotgun/exceptions.py +9 -9
- shotgun/main.py +3 -16
- shotgun/posthog_telemetry.py +165 -24
- shotgun/prompts/agents/export.j2 +2 -0
- shotgun/prompts/agents/file_read.j2 +48 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -52
- shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
- shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
- shotgun/prompts/agents/plan.j2 +38 -12
- shotgun/prompts/agents/research.j2 +70 -31
- shotgun/prompts/agents/router.j2 +713 -0
- shotgun/prompts/agents/specify.j2 +53 -16
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
- shotgun/prompts/agents/state/system_state.j2 +24 -13
- shotgun/prompts/agents/tasks.j2 +72 -34
- shotgun/settings.py +49 -10
- shotgun/tui/app.py +154 -24
- shotgun/tui/commands/__init__.py +9 -1
- shotgun/tui/components/attachment_bar.py +87 -0
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/prompt_input.py +25 -28
- shotgun/tui/components/status_bar.py +14 -7
- shotgun/tui/dependencies.py +58 -8
- shotgun/tui/protocols.py +55 -0
- shotgun/tui/screens/chat/chat.tcss +24 -1
- shotgun/tui/screens/chat/chat_screen.py +1376 -213
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
- shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
- shotgun/tui/screens/chat_screen/command_providers.py +0 -97
- shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
- shotgun/tui/screens/chat_screen/history/chat_history.py +58 -6
- shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
- shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
- shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
- shotgun/tui/screens/chat_screen/messages.py +219 -0
- shotgun/tui/screens/database_locked_dialog.py +219 -0
- shotgun/tui/screens/database_timeout_dialog.py +158 -0
- shotgun/tui/screens/kuzu_error_dialog.py +135 -0
- shotgun/tui/screens/model_picker.py +1 -3
- shotgun/tui/screens/models.py +11 -0
- shotgun/tui/state/processing_state.py +19 -0
- shotgun/tui/utils/mode_progress.py +20 -86
- shotgun/tui/widgets/__init__.py +2 -1
- shotgun/tui/widgets/approval_widget.py +152 -0
- shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
- shotgun/tui/widgets/plan_panel.py +129 -0
- shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
- shotgun/tui/widgets/widget_coordinator.py +18 -0
- shotgun/utils/file_system_utils.py +4 -1
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +88 -35
- shotgun_sh-0.6.2.dist-info/RECORD +291 -0
- shotgun/cli/export.py +0 -81
- shotgun/cli/plan.py +0 -73
- shotgun/cli/research.py +0 -93
- shotgun/cli/specify.py +0 -70
- shotgun/cli/tasks.py +0 -78
- shotgun/sentry_telemetry.py +0 -232
- shotgun/tui/screens/onboarding.py +0 -580
- shotgun_sh-0.3.3.dev1.dist-info/RECORD +0 -229
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
)
|