shotgun-sh 0.4.0.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 +307 -8
- shotgun/agents/cancellation.py +103 -0
- shotgun/agents/common.py +12 -0
- shotgun/agents/config/README.md +0 -1
- shotgun/agents/config/manager.py +10 -7
- shotgun/agents/config/models.py +5 -27
- shotgun/agents/config/provider.py +44 -27
- shotgun/agents/conversation/history/token_counting/base.py +51 -9
- shotgun/agents/file_read.py +176 -0
- shotgun/agents/messages.py +15 -3
- shotgun/agents/models.py +24 -1
- shotgun/agents/router/models.py +8 -0
- shotgun/agents/router/tools/delegation_tools.py +55 -1
- shotgun/agents/router/tools/plan_tools.py +88 -7
- shotgun/agents/runner.py +17 -2
- 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 +32 -2
- 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 +44 -6
- 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/file_read.j2 +48 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -47
- 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 +21 -22
- shotgun/prompts/agents/plan.j2 +14 -0
- shotgun/prompts/agents/router.j2 +531 -258
- shotgun/prompts/agents/specify.j2 +14 -0
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
- shotgun/prompts/agents/state/system_state.j2 +13 -11
- shotgun/prompts/agents/tasks.j2 +14 -0
- shotgun/settings.py +49 -10
- shotgun/tui/app.py +149 -18
- shotgun/tui/commands/__init__.py +9 -1
- shotgun/tui/components/attachment_bar.py +87 -0
- shotgun/tui/components/prompt_input.py +25 -28
- shotgun/tui/components/status_bar.py +14 -7
- shotgun/tui/dependencies.py +3 -8
- shotgun/tui/protocols.py +18 -0
- shotgun/tui/screens/chat/chat.tcss +15 -0
- shotgun/tui/screens/chat/chat_screen.py +766 -235
- 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 -10
- shotgun/tui/screens/chat_screen/history/chat_history.py +54 -14
- shotgun/tui/screens/chat_screen/history/formatters.py +22 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
- 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/widgets/widget_coordinator.py +18 -0
- shotgun/utils/file_system_utils.py +4 -1
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +87 -34
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/RECORD +128 -79
- 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 -584
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Multimodal file reading tool that verifies files exist and returns paths.
|
|
2
|
+
|
|
3
|
+
This tool verifies PDFs/images exist and returns their paths for the agent
|
|
4
|
+
to include in `files_found`. The Router then loads these via `file_requests`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
from pydantic_ai import RunContext, ToolReturn
|
|
11
|
+
|
|
12
|
+
from shotgun.agents.models import AgentDeps
|
|
13
|
+
from shotgun.agents.tools.registry import ToolCategory, register_tool
|
|
14
|
+
from shotgun.logging_config import get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
# MIME type mapping for supported file types
|
|
19
|
+
MIME_TYPES: dict[str, str] = {
|
|
20
|
+
".pdf": "application/pdf",
|
|
21
|
+
".png": "image/png",
|
|
22
|
+
".jpg": "image/jpeg",
|
|
23
|
+
".jpeg": "image/jpeg",
|
|
24
|
+
".gif": "image/gif",
|
|
25
|
+
".webp": "image/webp",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Maximum file size for multimodal reading (32MB - Anthropic's limit)
|
|
29
|
+
MAX_FILE_SIZE_BYTES = 32 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MultimodalFileReadResult(BaseModel):
|
|
33
|
+
"""Result from multimodal file read."""
|
|
34
|
+
|
|
35
|
+
success: bool = Field(description="Whether the file was successfully found")
|
|
36
|
+
file_path: str = Field(description="The absolute path to the file")
|
|
37
|
+
file_name: str = Field(default="", description="The file name")
|
|
38
|
+
file_size_bytes: int = Field(default=0, description="File size in bytes")
|
|
39
|
+
mime_type: str = Field(default="", description="MIME type of the file")
|
|
40
|
+
error: str | None = Field(default=None, description="Error message if failed")
|
|
41
|
+
|
|
42
|
+
def __str__(self) -> str:
|
|
43
|
+
if not self.success:
|
|
44
|
+
return f"Error: {self.error}"
|
|
45
|
+
return (
|
|
46
|
+
f"Found: {self.file_name} ({self.file_size_bytes} bytes, {self.mime_type})"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_mime_type(file_path: Path) -> str | None:
|
|
51
|
+
"""Get MIME type for a file based on extension."""
|
|
52
|
+
suffix = file_path.suffix.lower()
|
|
53
|
+
return MIME_TYPES.get(suffix)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _format_file_size(size_bytes: int) -> str:
|
|
57
|
+
"""Format file size in human-readable format."""
|
|
58
|
+
if size_bytes < 1024:
|
|
59
|
+
return f"{size_bytes} B"
|
|
60
|
+
elif size_bytes < 1024 * 1024:
|
|
61
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
62
|
+
else:
|
|
63
|
+
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@register_tool(
|
|
67
|
+
category=ToolCategory.CODEBASE_UNDERSTANDING,
|
|
68
|
+
display_text="Reading file (multimodal)",
|
|
69
|
+
key_arg="file_path",
|
|
70
|
+
)
|
|
71
|
+
async def multimodal_file_read(
|
|
72
|
+
ctx: RunContext[AgentDeps],
|
|
73
|
+
file_path: str,
|
|
74
|
+
) -> ToolReturn:
|
|
75
|
+
"""Verify a PDF or image file exists and return its path.
|
|
76
|
+
|
|
77
|
+
This tool checks that the file exists and is a supported type (PDF, image),
|
|
78
|
+
then returns the absolute path. Include this path in your `files_found`
|
|
79
|
+
response so the Router can load it for visual analysis.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
ctx: RunContext containing AgentDeps
|
|
83
|
+
file_path: Path to the file (absolute or relative to CWD)
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
ToolReturn with file info and absolute path
|
|
87
|
+
"""
|
|
88
|
+
logger.debug("Checking multimodal file: %s", file_path)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Resolve the path
|
|
92
|
+
path = Path(file_path).expanduser().resolve()
|
|
93
|
+
|
|
94
|
+
# Check if file exists
|
|
95
|
+
if not path.exists():
|
|
96
|
+
error_result = MultimodalFileReadResult(
|
|
97
|
+
success=False,
|
|
98
|
+
file_path=str(path),
|
|
99
|
+
error=f"File not found: {file_path}",
|
|
100
|
+
)
|
|
101
|
+
return ToolReturn(return_value=str(error_result))
|
|
102
|
+
|
|
103
|
+
if path.is_dir():
|
|
104
|
+
error_result = MultimodalFileReadResult(
|
|
105
|
+
success=False,
|
|
106
|
+
file_path=str(path),
|
|
107
|
+
error=f"'{file_path}' is a directory, not a file",
|
|
108
|
+
)
|
|
109
|
+
return ToolReturn(return_value=str(error_result))
|
|
110
|
+
|
|
111
|
+
# Check MIME type
|
|
112
|
+
mime_type = _get_mime_type(path)
|
|
113
|
+
if mime_type is None:
|
|
114
|
+
supported = ", ".join(MIME_TYPES.keys())
|
|
115
|
+
error_result = MultimodalFileReadResult(
|
|
116
|
+
success=False,
|
|
117
|
+
file_path=str(path),
|
|
118
|
+
error=f"Unsupported file type: {path.suffix}. Supported: {supported}",
|
|
119
|
+
)
|
|
120
|
+
return ToolReturn(return_value=str(error_result))
|
|
121
|
+
|
|
122
|
+
# Check file size
|
|
123
|
+
file_size = path.stat().st_size
|
|
124
|
+
if file_size > MAX_FILE_SIZE_BYTES:
|
|
125
|
+
error_result = MultimodalFileReadResult(
|
|
126
|
+
success=False,
|
|
127
|
+
file_path=str(path),
|
|
128
|
+
file_size_bytes=file_size,
|
|
129
|
+
error=f"File too large: {_format_file_size(file_size)} (max: {_format_file_size(MAX_FILE_SIZE_BYTES)})",
|
|
130
|
+
)
|
|
131
|
+
return ToolReturn(return_value=str(error_result))
|
|
132
|
+
|
|
133
|
+
logger.debug(
|
|
134
|
+
"Found multimodal file: %s (%s, %s)",
|
|
135
|
+
path.name,
|
|
136
|
+
_format_file_size(file_size),
|
|
137
|
+
mime_type,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Return file info with absolute path
|
|
141
|
+
file_type = "PDF" if mime_type == "application/pdf" else "Image"
|
|
142
|
+
summary = f"""{file_type} found: {path.name}
|
|
143
|
+
Size: {_format_file_size(file_size)}
|
|
144
|
+
Type: {mime_type}
|
|
145
|
+
Absolute path: {path}
|
|
146
|
+
|
|
147
|
+
IMPORTANT: Include the absolute path above in your `files_found` response field.
|
|
148
|
+
The Router will then be able to load and analyze this file's visual content."""
|
|
149
|
+
|
|
150
|
+
return ToolReturn(return_value=summary)
|
|
151
|
+
|
|
152
|
+
except PermissionError:
|
|
153
|
+
error_result = MultimodalFileReadResult(
|
|
154
|
+
success=False,
|
|
155
|
+
file_path=file_path,
|
|
156
|
+
error=f"Permission denied: {file_path}",
|
|
157
|
+
)
|
|
158
|
+
return ToolReturn(return_value=str(error_result))
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
logger.error("Error checking multimodal file: %s", str(e))
|
|
162
|
+
error_result = MultimodalFileReadResult(
|
|
163
|
+
success=False,
|
|
164
|
+
file_path=file_path,
|
|
165
|
+
error=f"Error: {str(e)}",
|
|
166
|
+
)
|
|
167
|
+
return ToolReturn(return_value=str(error_result))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Markdown manipulation tools for Pydantic AI agents."""
|
|
2
|
+
|
|
3
|
+
from .insert_section import insert_markdown_section
|
|
4
|
+
from .models import (
|
|
5
|
+
CloseMatch,
|
|
6
|
+
HeadingList,
|
|
7
|
+
HeadingMatch,
|
|
8
|
+
MarkdownFileContext,
|
|
9
|
+
MarkdownHeading,
|
|
10
|
+
SectionMatchResult,
|
|
11
|
+
SectionNumber,
|
|
12
|
+
)
|
|
13
|
+
from .remove_section import remove_markdown_section
|
|
14
|
+
from .replace_section import replace_markdown_section
|
|
15
|
+
from .utils import (
|
|
16
|
+
decrement_section_number,
|
|
17
|
+
detect_line_ending,
|
|
18
|
+
extract_headings,
|
|
19
|
+
find_and_validate_section,
|
|
20
|
+
find_close_matches,
|
|
21
|
+
find_matching_heading,
|
|
22
|
+
find_section_bounds,
|
|
23
|
+
get_heading_level,
|
|
24
|
+
increment_section_number,
|
|
25
|
+
load_markdown_file,
|
|
26
|
+
normalize_section_content,
|
|
27
|
+
parse_section_number,
|
|
28
|
+
renumber_headings_after,
|
|
29
|
+
split_normalized_content,
|
|
30
|
+
write_markdown_file,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
# Tools
|
|
35
|
+
"replace_markdown_section",
|
|
36
|
+
"insert_markdown_section",
|
|
37
|
+
"remove_markdown_section",
|
|
38
|
+
# Models
|
|
39
|
+
"MarkdownHeading",
|
|
40
|
+
"HeadingList",
|
|
41
|
+
"HeadingMatch",
|
|
42
|
+
"CloseMatch",
|
|
43
|
+
"SectionNumber",
|
|
44
|
+
"MarkdownFileContext",
|
|
45
|
+
"SectionMatchResult",
|
|
46
|
+
# Utilities
|
|
47
|
+
"get_heading_level",
|
|
48
|
+
"extract_headings",
|
|
49
|
+
"find_matching_heading",
|
|
50
|
+
"find_close_matches",
|
|
51
|
+
"find_section_bounds",
|
|
52
|
+
"detect_line_ending",
|
|
53
|
+
"normalize_section_content",
|
|
54
|
+
"split_normalized_content",
|
|
55
|
+
"parse_section_number",
|
|
56
|
+
"increment_section_number",
|
|
57
|
+
"decrement_section_number",
|
|
58
|
+
"renumber_headings_after",
|
|
59
|
+
"load_markdown_file",
|
|
60
|
+
"find_and_validate_section",
|
|
61
|
+
"write_markdown_file",
|
|
62
|
+
]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Tool for inserting content into markdown sections."""
|
|
2
|
+
|
|
3
|
+
from pydantic_ai import RunContext
|
|
4
|
+
|
|
5
|
+
from shotgun.agents.models import AgentDeps, FileOperationType
|
|
6
|
+
from shotgun.agents.tools.file_management import _validate_agent_scoped_path
|
|
7
|
+
from shotgun.agents.tools.registry import ToolCategory, register_tool
|
|
8
|
+
from shotgun.logging_config import get_logger
|
|
9
|
+
|
|
10
|
+
from .utils import (
|
|
11
|
+
find_and_validate_section,
|
|
12
|
+
get_heading_level,
|
|
13
|
+
load_markdown_file,
|
|
14
|
+
parse_section_number,
|
|
15
|
+
renumber_headings_after,
|
|
16
|
+
split_normalized_content,
|
|
17
|
+
write_markdown_file,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@register_tool(
|
|
24
|
+
category=ToolCategory.ARTIFACT_MANAGEMENT,
|
|
25
|
+
display_text="Inserting content",
|
|
26
|
+
key_arg="filename",
|
|
27
|
+
secondary_key_arg="new_heading",
|
|
28
|
+
)
|
|
29
|
+
async def insert_markdown_section(
|
|
30
|
+
ctx: RunContext[AgentDeps],
|
|
31
|
+
filename: str,
|
|
32
|
+
after_heading: str,
|
|
33
|
+
content: str,
|
|
34
|
+
new_heading: str | None = None,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Insert content at the end of a Markdown section.
|
|
37
|
+
|
|
38
|
+
PREFER THIS TOOL over rewriting the entire file - it is faster, less costly,
|
|
39
|
+
and less error-prone. Use this to append content to an existing section.
|
|
40
|
+
|
|
41
|
+
Uses fuzzy matching on headings so minor typos are tolerated.
|
|
42
|
+
Inserts content just before the next heading at the same or higher level.
|
|
43
|
+
|
|
44
|
+
Note: If new_heading contains a section number (e.g., "### 4.4 New Section"),
|
|
45
|
+
subsequent numbered sections at the same level will be automatically incremented
|
|
46
|
+
to maintain proper numbering order.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
ctx: Run context with agent dependencies
|
|
50
|
+
filename: Path to the Markdown file (relative to .shotgun directory)
|
|
51
|
+
after_heading: The heading to insert after (e.g., '## Requirements'). Fuzzy matched.
|
|
52
|
+
content: The content to insert at the end of the section
|
|
53
|
+
new_heading: Optional heading for the inserted content (creates a subsection)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Success message or error message
|
|
57
|
+
"""
|
|
58
|
+
logger.debug("Inserting content into section '%s' in: %s", after_heading, filename)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
# Validate path with agent scoping
|
|
62
|
+
file_path = _validate_agent_scoped_path(filename, ctx.deps.agent_mode)
|
|
63
|
+
|
|
64
|
+
# Load and parse the markdown file
|
|
65
|
+
file_ctx = await load_markdown_file(file_path, filename)
|
|
66
|
+
if isinstance(file_ctx, str):
|
|
67
|
+
return file_ctx # Error message
|
|
68
|
+
|
|
69
|
+
# Find and validate the target section
|
|
70
|
+
match = find_and_validate_section(file_ctx, after_heading)
|
|
71
|
+
if not match.is_success:
|
|
72
|
+
return match.error # type: ignore[return-value]
|
|
73
|
+
|
|
74
|
+
# Build insert content
|
|
75
|
+
insert_content_lines = split_normalized_content(content)
|
|
76
|
+
|
|
77
|
+
# Build the insert lines
|
|
78
|
+
insert_lines: list[str] = [""] # Blank line separator before new content
|
|
79
|
+
|
|
80
|
+
if new_heading:
|
|
81
|
+
insert_lines.append(new_heading)
|
|
82
|
+
insert_lines.append("") # Blank line after heading
|
|
83
|
+
|
|
84
|
+
insert_lines.extend(insert_content_lines)
|
|
85
|
+
|
|
86
|
+
# Add trailing blank line if not at EOF
|
|
87
|
+
if match.end_line < len(file_ctx.lines):
|
|
88
|
+
insert_lines.append("")
|
|
89
|
+
|
|
90
|
+
# Insert before section end (before next heading or EOF)
|
|
91
|
+
new_lines = (
|
|
92
|
+
file_ctx.lines[: match.end_line]
|
|
93
|
+
+ insert_lines
|
|
94
|
+
+ file_ctx.lines[match.end_line :]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# If new_heading has a section number, renumber subsequent sections
|
|
98
|
+
if new_heading:
|
|
99
|
+
new_heading_level = get_heading_level(new_heading)
|
|
100
|
+
if new_heading_level:
|
|
101
|
+
section_num = parse_section_number(new_heading)
|
|
102
|
+
if section_num:
|
|
103
|
+
# Calculate the line number where subsequent sections start
|
|
104
|
+
# (after the inserted content)
|
|
105
|
+
renumber_start = match.end_line + len(insert_lines)
|
|
106
|
+
new_lines = renumber_headings_after(
|
|
107
|
+
new_lines,
|
|
108
|
+
start_line=renumber_start,
|
|
109
|
+
heading_level=new_heading_level,
|
|
110
|
+
increment=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Write the modified file
|
|
114
|
+
await write_markdown_file(file_ctx, new_lines)
|
|
115
|
+
|
|
116
|
+
# Track the file operation
|
|
117
|
+
ctx.deps.file_tracker.add_operation(file_path, FileOperationType.UPDATED)
|
|
118
|
+
|
|
119
|
+
logger.debug(
|
|
120
|
+
"Successfully inserted content into section '%s' in %s",
|
|
121
|
+
match.heading.text, # type: ignore[union-attr]
|
|
122
|
+
filename,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
lines_added = len(insert_lines)
|
|
126
|
+
confidence_display = f"{int(match.confidence * 100)}%"
|
|
127
|
+
|
|
128
|
+
if new_heading:
|
|
129
|
+
return (
|
|
130
|
+
f"Successfully inserted '{new_heading}' into '{match.heading.text}' in {filename} " # type: ignore[union-attr]
|
|
131
|
+
f"(matched with {confidence_display} confidence, {lines_added} lines added)"
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
return (
|
|
135
|
+
f"Successfully inserted content into '{match.heading.text}' in {filename} " # type: ignore[union-attr]
|
|
136
|
+
f"(matched with {confidence_display} confidence, {lines_added} lines added)"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
except ValueError as e:
|
|
140
|
+
# Path validation errors
|
|
141
|
+
error_msg = f"Error inserting into '{filename}': {e}"
|
|
142
|
+
logger.error("Section insertion failed: %s", error_msg)
|
|
143
|
+
return error_msg
|
|
144
|
+
|
|
145
|
+
except Exception as e:
|
|
146
|
+
error_msg = f"Error inserting into '{filename}': {e}"
|
|
147
|
+
logger.error("Section insertion failed: %s", error_msg)
|
|
148
|
+
return error_msg
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Pydantic models for markdown tools."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MarkdownHeading(BaseModel):
|
|
9
|
+
"""Represents a heading found in a Markdown file."""
|
|
10
|
+
|
|
11
|
+
line_number: int
|
|
12
|
+
text: str
|
|
13
|
+
level: int
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def normalized_text(self) -> str:
|
|
17
|
+
"""Return heading text without # prefix, stripped and lowercased."""
|
|
18
|
+
return self.text.lstrip("#").strip().lower()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
HeadingList = list[MarkdownHeading]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HeadingMatch(BaseModel):
|
|
25
|
+
"""Result of a successful heading match."""
|
|
26
|
+
|
|
27
|
+
heading: MarkdownHeading
|
|
28
|
+
confidence: float
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CloseMatch(BaseModel):
|
|
32
|
+
"""A close match result for error messages."""
|
|
33
|
+
|
|
34
|
+
heading_text: str
|
|
35
|
+
confidence: float
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SectionNumber(BaseModel):
|
|
39
|
+
"""Parsed section number from a heading.
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
- "## 3. Title" -> prefix="3", has_trailing_dot=True
|
|
43
|
+
- "### 4.4 Title" -> prefix="4.4", has_trailing_dot=False
|
|
44
|
+
- "#### 1.2.3.4 Title" -> prefix="1.2.3.4", has_trailing_dot=False
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
prefix: str # The number part, e.g., "4.4" or "3"
|
|
48
|
+
has_trailing_dot: bool # Whether it ends with a dot before the title
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MarkdownFileContext(BaseModel):
|
|
52
|
+
"""Context for a loaded markdown file ready for section operations.
|
|
53
|
+
|
|
54
|
+
This encapsulates the common state needed by all section manipulation tools:
|
|
55
|
+
file path, content split into lines, line ending style, and extracted headings.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
file_path: Path
|
|
59
|
+
filename: str # Original filename for error messages
|
|
60
|
+
lines: list[str]
|
|
61
|
+
line_ending: str
|
|
62
|
+
headings: HeadingList
|
|
63
|
+
|
|
64
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SectionMatchResult(BaseModel):
|
|
68
|
+
"""Result of finding and validating a section match.
|
|
69
|
+
|
|
70
|
+
Either contains a successful match with the heading and bounds,
|
|
71
|
+
or an error message explaining why the match failed.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# Success fields (all present when error is None)
|
|
75
|
+
heading: MarkdownHeading | None = None
|
|
76
|
+
confidence: float = 0.0
|
|
77
|
+
start_line: int = 0
|
|
78
|
+
end_line: int = 0
|
|
79
|
+
|
|
80
|
+
# Error field (present when match failed)
|
|
81
|
+
error: str | None = None
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def is_success(self) -> bool:
|
|
85
|
+
"""Return True if this is a successful match."""
|
|
86
|
+
return self.error is None and self.heading is not None
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Tool for removing markdown sections."""
|
|
2
|
+
|
|
3
|
+
from pydantic_ai import RunContext
|
|
4
|
+
|
|
5
|
+
from shotgun.agents.models import AgentDeps, FileOperationType
|
|
6
|
+
from shotgun.agents.tools.file_management import _validate_agent_scoped_path
|
|
7
|
+
from shotgun.agents.tools.registry import ToolCategory, register_tool
|
|
8
|
+
from shotgun.logging_config import get_logger
|
|
9
|
+
|
|
10
|
+
from .utils import (
|
|
11
|
+
find_and_validate_section,
|
|
12
|
+
load_markdown_file,
|
|
13
|
+
parse_section_number,
|
|
14
|
+
renumber_headings_after,
|
|
15
|
+
write_markdown_file,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@register_tool(
|
|
22
|
+
category=ToolCategory.ARTIFACT_MANAGEMENT,
|
|
23
|
+
display_text="Removing section",
|
|
24
|
+
key_arg="filename",
|
|
25
|
+
secondary_key_arg="section_heading",
|
|
26
|
+
)
|
|
27
|
+
async def remove_markdown_section(
|
|
28
|
+
ctx: RunContext[AgentDeps],
|
|
29
|
+
filename: str,
|
|
30
|
+
section_heading: str,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Remove an entire section from a Markdown file.
|
|
33
|
+
|
|
34
|
+
Uses fuzzy matching on headings so minor typos are tolerated.
|
|
35
|
+
Removes from the target heading down to (but not including) the next
|
|
36
|
+
heading at the same or higher level.
|
|
37
|
+
|
|
38
|
+
Note: If the removed section has a numbered heading (e.g., "### 4.4 Title"),
|
|
39
|
+
subsequent numbered sections at the same level will be automatically
|
|
40
|
+
decremented to maintain proper numbering order.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
ctx: Run context with agent dependencies
|
|
44
|
+
filename: Path to the Markdown file (relative to .shotgun directory)
|
|
45
|
+
section_heading: The heading to find and remove. Fuzzy matched.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Success message or error message
|
|
49
|
+
"""
|
|
50
|
+
logger.debug("Removing section '%s' from: %s", section_heading, filename)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
# Validate path with agent scoping
|
|
54
|
+
file_path = _validate_agent_scoped_path(filename, ctx.deps.agent_mode)
|
|
55
|
+
|
|
56
|
+
# Load and parse the markdown file
|
|
57
|
+
file_ctx = await load_markdown_file(file_path, filename)
|
|
58
|
+
if isinstance(file_ctx, str):
|
|
59
|
+
return file_ctx # Error message
|
|
60
|
+
|
|
61
|
+
# Find and validate the target section
|
|
62
|
+
match = find_and_validate_section(file_ctx, section_heading)
|
|
63
|
+
if not match.is_success:
|
|
64
|
+
return match.error # type: ignore[return-value]
|
|
65
|
+
|
|
66
|
+
removed_lines = match.end_line - match.start_line
|
|
67
|
+
|
|
68
|
+
# Check if the removed section has a numbered heading
|
|
69
|
+
section_num = parse_section_number(match.heading.text) # type: ignore[union-attr]
|
|
70
|
+
heading_level = match.heading.level # type: ignore[union-attr]
|
|
71
|
+
|
|
72
|
+
# Remove the section
|
|
73
|
+
new_lines = (
|
|
74
|
+
file_ctx.lines[: match.start_line] + file_ctx.lines[match.end_line :]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# If the removed section had a numbered heading, decrement subsequent sections
|
|
78
|
+
if section_num:
|
|
79
|
+
new_lines = renumber_headings_after(
|
|
80
|
+
new_lines,
|
|
81
|
+
start_line=match.start_line,
|
|
82
|
+
heading_level=heading_level,
|
|
83
|
+
increment=False,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Write the modified file
|
|
87
|
+
await write_markdown_file(file_ctx, new_lines)
|
|
88
|
+
|
|
89
|
+
# Track the file operation
|
|
90
|
+
ctx.deps.file_tracker.add_operation(file_path, FileOperationType.UPDATED)
|
|
91
|
+
|
|
92
|
+
logger.debug(
|
|
93
|
+
"Successfully removed section '%s' from %s",
|
|
94
|
+
match.heading.text, # type: ignore[union-attr]
|
|
95
|
+
filename,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
confidence_display = f"{int(match.confidence * 100)}%"
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
f"Successfully removed section '{match.heading.text}' from {filename} " # type: ignore[union-attr]
|
|
102
|
+
f"(matched with {confidence_display} confidence, {removed_lines} lines removed)"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
except ValueError as e:
|
|
106
|
+
# Path validation errors
|
|
107
|
+
error_msg = f"Error removing section from '{filename}': {e}"
|
|
108
|
+
logger.error("Section removal failed: %s", error_msg)
|
|
109
|
+
return error_msg
|
|
110
|
+
|
|
111
|
+
except Exception as e:
|
|
112
|
+
error_msg = f"Error removing section from '{filename}': {e}"
|
|
113
|
+
logger.error("Section removal failed: %s", error_msg)
|
|
114
|
+
return error_msg
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Tool for replacing markdown sections."""
|
|
2
|
+
|
|
3
|
+
from pydantic_ai import RunContext
|
|
4
|
+
|
|
5
|
+
from shotgun.agents.models import AgentDeps, FileOperationType
|
|
6
|
+
from shotgun.agents.tools.file_management import _validate_agent_scoped_path
|
|
7
|
+
from shotgun.agents.tools.registry import ToolCategory, register_tool
|
|
8
|
+
from shotgun.logging_config import get_logger
|
|
9
|
+
|
|
10
|
+
from .utils import (
|
|
11
|
+
find_and_validate_section,
|
|
12
|
+
load_markdown_file,
|
|
13
|
+
split_normalized_content,
|
|
14
|
+
write_markdown_file,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@register_tool(
|
|
21
|
+
category=ToolCategory.ARTIFACT_MANAGEMENT,
|
|
22
|
+
display_text="Replacing section",
|
|
23
|
+
key_arg="filename",
|
|
24
|
+
secondary_key_arg="section_heading",
|
|
25
|
+
)
|
|
26
|
+
async def replace_markdown_section(
|
|
27
|
+
ctx: RunContext[AgentDeps],
|
|
28
|
+
filename: str,
|
|
29
|
+
section_heading: str,
|
|
30
|
+
new_contents: str,
|
|
31
|
+
new_heading: str | None = None,
|
|
32
|
+
) -> str:
|
|
33
|
+
"""Replace an entire section in a Markdown file.
|
|
34
|
+
|
|
35
|
+
PREFER THIS TOOL over rewriting the entire file - it is faster, less costly,
|
|
36
|
+
and less error-prone.
|
|
37
|
+
|
|
38
|
+
Uses fuzzy matching on headings so minor typos are tolerated.
|
|
39
|
+
Replaces from the target heading down to (but not including) the next
|
|
40
|
+
heading at the same or higher level.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
ctx: Run context with agent dependencies
|
|
44
|
+
filename: Path to the Markdown file (relative to .shotgun directory)
|
|
45
|
+
section_heading: The heading to find (e.g., '## Requirements'). Fuzzy matched.
|
|
46
|
+
new_contents: The new content for the section body (not including the heading)
|
|
47
|
+
new_heading: Optional new heading text to replace the old one
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Success message or error message
|
|
51
|
+
"""
|
|
52
|
+
logger.debug("Replacing section '%s' in: %s", section_heading, filename)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Validate path with agent scoping
|
|
56
|
+
file_path = _validate_agent_scoped_path(filename, ctx.deps.agent_mode)
|
|
57
|
+
|
|
58
|
+
# Load and parse the markdown file
|
|
59
|
+
file_ctx = await load_markdown_file(file_path, filename)
|
|
60
|
+
if isinstance(file_ctx, str):
|
|
61
|
+
return file_ctx # Error message
|
|
62
|
+
|
|
63
|
+
# Find and validate the target section
|
|
64
|
+
match = find_and_validate_section(file_ctx, section_heading)
|
|
65
|
+
if not match.is_success:
|
|
66
|
+
return match.error # type: ignore[return-value]
|
|
67
|
+
|
|
68
|
+
old_section_lines = match.end_line - match.start_line
|
|
69
|
+
|
|
70
|
+
# Build new section
|
|
71
|
+
final_heading = new_heading if new_heading else match.heading.text # type: ignore[union-attr]
|
|
72
|
+
new_content_lines = split_normalized_content(new_contents)
|
|
73
|
+
|
|
74
|
+
# Build the new section: heading + blank line + content
|
|
75
|
+
new_section_lines = [final_heading, ""]
|
|
76
|
+
new_section_lines.extend(new_content_lines)
|
|
77
|
+
|
|
78
|
+
# Add trailing blank line if not at EOF
|
|
79
|
+
if match.end_line < len(file_ctx.lines):
|
|
80
|
+
new_section_lines.append("")
|
|
81
|
+
|
|
82
|
+
# Replace section
|
|
83
|
+
new_lines = (
|
|
84
|
+
file_ctx.lines[: match.start_line]
|
|
85
|
+
+ new_section_lines
|
|
86
|
+
+ file_ctx.lines[match.end_line :]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Write the modified file
|
|
90
|
+
await write_markdown_file(file_ctx, new_lines)
|
|
91
|
+
|
|
92
|
+
# Track the file operation
|
|
93
|
+
ctx.deps.file_tracker.add_operation(file_path, FileOperationType.UPDATED)
|
|
94
|
+
|
|
95
|
+
logger.debug(
|
|
96
|
+
"Successfully replaced section '%s' in %s",
|
|
97
|
+
match.heading.text, # type: ignore[union-attr]
|
|
98
|
+
filename,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
new_section_line_count = len(new_section_lines)
|
|
102
|
+
confidence_display = f"{int(match.confidence * 100)}%"
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
f"Successfully replaced section '{match.heading.text}' in {filename} " # type: ignore[union-attr]
|
|
106
|
+
f"(matched with {confidence_display} confidence, "
|
|
107
|
+
f"{old_section_lines} lines -> {new_section_line_count} lines)"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
except ValueError as e:
|
|
111
|
+
# Path validation errors
|
|
112
|
+
error_msg = f"Error replacing section in '{filename}': {e}"
|
|
113
|
+
logger.error("Section replacement failed: %s", error_msg)
|
|
114
|
+
return error_msg
|
|
115
|
+
|
|
116
|
+
except Exception as e:
|
|
117
|
+
error_msg = f"Error replacing section in '{filename}': {e}"
|
|
118
|
+
logger.error("Section replacement failed: %s", error_msg)
|
|
119
|
+
return error_msg
|