shotgun-sh 0.3.3.dev1__py3-none-any.whl → 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. shotgun/agents/agent_manager.py +497 -30
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +90 -77
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +52 -8
  6. shotgun/agents/config/models.py +21 -27
  7. shotgun/agents/config/provider.py +44 -27
  8. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  9. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  10. shotgun/agents/export.py +12 -13
  11. shotgun/agents/file_read.py +176 -0
  12. shotgun/agents/messages.py +15 -3
  13. shotgun/agents/models.py +90 -2
  14. shotgun/agents/plan.py +12 -13
  15. shotgun/agents/research.py +13 -10
  16. shotgun/agents/router/__init__.py +47 -0
  17. shotgun/agents/router/models.py +384 -0
  18. shotgun/agents/router/router.py +185 -0
  19. shotgun/agents/router/tools/__init__.py +18 -0
  20. shotgun/agents/router/tools/delegation_tools.py +557 -0
  21. shotgun/agents/router/tools/plan_tools.py +403 -0
  22. shotgun/agents/runner.py +17 -2
  23. shotgun/agents/specify.py +12 -13
  24. shotgun/agents/tasks.py +12 -13
  25. shotgun/agents/tools/__init__.py +8 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  27. shotgun/agents/tools/codebase/file_read.py +26 -35
  28. shotgun/agents/tools/codebase/query_graph.py +9 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  30. shotgun/agents/tools/file_management.py +81 -3
  31. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  32. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  33. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  34. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  35. shotgun/agents/tools/markdown_tools/models.py +86 -0
  36. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  37. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  38. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  39. shotgun/agents/tools/registry.py +46 -6
  40. shotgun/agents/tools/web_search/__init__.py +1 -2
  41. shotgun/agents/tools/web_search/gemini.py +1 -3
  42. shotgun/agents/tools/web_search/openai.py +42 -23
  43. shotgun/attachments/__init__.py +41 -0
  44. shotgun/attachments/errors.py +60 -0
  45. shotgun/attachments/models.py +107 -0
  46. shotgun/attachments/parser.py +257 -0
  47. shotgun/attachments/processor.py +193 -0
  48. shotgun/build_constants.py +4 -7
  49. shotgun/cli/clear.py +2 -2
  50. shotgun/cli/codebase/commands.py +181 -65
  51. shotgun/cli/compact.py +2 -2
  52. shotgun/cli/context.py +2 -2
  53. shotgun/cli/error_handler.py +2 -2
  54. shotgun/cli/run.py +90 -0
  55. shotgun/cli/spec/backup.py +2 -1
  56. shotgun/codebase/__init__.py +2 -0
  57. shotgun/codebase/benchmarks/__init__.py +35 -0
  58. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  59. shotgun/codebase/benchmarks/exporters.py +119 -0
  60. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  61. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  62. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  63. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  64. shotgun/codebase/benchmarks/models.py +129 -0
  65. shotgun/codebase/core/__init__.py +4 -0
  66. shotgun/codebase/core/call_resolution.py +91 -0
  67. shotgun/codebase/core/change_detector.py +11 -6
  68. shotgun/codebase/core/errors.py +159 -0
  69. shotgun/codebase/core/extractors/__init__.py +23 -0
  70. shotgun/codebase/core/extractors/base.py +138 -0
  71. shotgun/codebase/core/extractors/factory.py +63 -0
  72. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  73. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  74. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  75. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  76. shotgun/codebase/core/extractors/protocol.py +109 -0
  77. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  78. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  79. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  80. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  81. shotgun/codebase/core/extractors/types.py +15 -0
  82. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  83. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  84. shotgun/codebase/core/gitignore.py +252 -0
  85. shotgun/codebase/core/ingestor.py +644 -354
  86. shotgun/codebase/core/kuzu_compat.py +119 -0
  87. shotgun/codebase/core/language_config.py +239 -0
  88. shotgun/codebase/core/manager.py +256 -46
  89. shotgun/codebase/core/metrics_collector.py +310 -0
  90. shotgun/codebase/core/metrics_types.py +347 -0
  91. shotgun/codebase/core/parallel_executor.py +424 -0
  92. shotgun/codebase/core/work_distributor.py +254 -0
  93. shotgun/codebase/core/worker.py +768 -0
  94. shotgun/codebase/indexing_state.py +86 -0
  95. shotgun/codebase/models.py +94 -0
  96. shotgun/codebase/service.py +13 -0
  97. shotgun/exceptions.py +9 -9
  98. shotgun/main.py +3 -16
  99. shotgun/posthog_telemetry.py +165 -24
  100. shotgun/prompts/agents/export.j2 +2 -0
  101. shotgun/prompts/agents/file_read.j2 +48 -0
  102. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -52
  103. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  104. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  105. shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
  106. shotgun/prompts/agents/plan.j2 +38 -12
  107. shotgun/prompts/agents/research.j2 +70 -31
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +53 -16
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -13
  112. shotgun/prompts/agents/tasks.j2 +72 -34
  113. shotgun/settings.py +49 -10
  114. shotgun/tui/app.py +154 -24
  115. shotgun/tui/commands/__init__.py +9 -1
  116. shotgun/tui/components/attachment_bar.py +87 -0
  117. shotgun/tui/components/mode_indicator.py +120 -25
  118. shotgun/tui/components/prompt_input.py +25 -28
  119. shotgun/tui/components/status_bar.py +14 -7
  120. shotgun/tui/dependencies.py +58 -8
  121. shotgun/tui/protocols.py +55 -0
  122. shotgun/tui/screens/chat/chat.tcss +24 -1
  123. shotgun/tui/screens/chat/chat_screen.py +1376 -213
  124. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  125. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  126. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  127. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  128. shotgun/tui/screens/chat_screen/history/chat_history.py +58 -6
  129. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  130. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  131. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  132. shotgun/tui/screens/chat_screen/messages.py +219 -0
  133. shotgun/tui/screens/database_locked_dialog.py +219 -0
  134. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  135. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  136. shotgun/tui/screens/model_picker.py +1 -3
  137. shotgun/tui/screens/models.py +11 -0
  138. shotgun/tui/state/processing_state.py +19 -0
  139. shotgun/tui/utils/mode_progress.py +20 -86
  140. shotgun/tui/widgets/__init__.py +2 -1
  141. shotgun/tui/widgets/approval_widget.py +152 -0
  142. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  143. shotgun/tui/widgets/plan_panel.py +129 -0
  144. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  145. shotgun/tui/widgets/widget_coordinator.py +18 -0
  146. shotgun/utils/file_system_utils.py +4 -1
  147. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +88 -35
  148. shotgun_sh-0.6.2.dist-info/RECORD +291 -0
  149. shotgun/cli/export.py +0 -81
  150. shotgun/cli/plan.py +0 -73
  151. shotgun/cli/research.py +0 -93
  152. shotgun/cli/specify.py +0 -70
  153. shotgun/cli/tasks.py +0 -78
  154. shotgun/sentry_telemetry.py +0 -232
  155. shotgun/tui/screens/onboarding.py +0 -580
  156. shotgun_sh-0.3.3.dev1.dist-info/RECORD +0 -229
  157. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
  158. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
  159. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,453 @@
1
+ """Utility functions for markdown parsing and manipulation."""
2
+
3
+ import re
4
+ from difflib import SequenceMatcher
5
+ from pathlib import Path
6
+
7
+ import aiofiles
8
+ import aiofiles.os
9
+
10
+ from .models import (
11
+ CloseMatch,
12
+ HeadingList,
13
+ HeadingMatch,
14
+ MarkdownFileContext,
15
+ MarkdownHeading,
16
+ SectionMatchResult,
17
+ SectionNumber,
18
+ )
19
+
20
+
21
+ def get_heading_level(line: str) -> int | None:
22
+ """Get the heading level (1-6) from a line, or None if not a heading.
23
+
24
+ Args:
25
+ line: A line of text to check
26
+
27
+ Returns:
28
+ The heading level (1-6) or None if not a heading
29
+ """
30
+ match = re.match(r"^(#{1,6})\s+", line)
31
+ return len(match.group(1)) if match else None
32
+
33
+
34
+ def extract_headings(content: str) -> HeadingList:
35
+ """Extract all headings from markdown content.
36
+
37
+ Args:
38
+ content: The markdown content to parse
39
+
40
+ Returns:
41
+ List of MarkdownHeading objects
42
+ """
43
+ headings: HeadingList = []
44
+ for i, line in enumerate(content.splitlines()):
45
+ level = get_heading_level(line)
46
+ if level is not None:
47
+ headings.append(MarkdownHeading(line_number=i, text=line, level=level))
48
+ return headings
49
+
50
+
51
+ def find_matching_heading(
52
+ headings: HeadingList,
53
+ target: str,
54
+ threshold: float = 0.8,
55
+ ) -> HeadingMatch | None:
56
+ """Find the best matching heading above the similarity threshold.
57
+
58
+ Args:
59
+ headings: List of MarkdownHeading objects
60
+ target: The target heading to match (e.g., "## Requirements")
61
+ threshold: Minimum similarity ratio (0.0-1.0)
62
+
63
+ Returns:
64
+ HeadingMatch with the matched heading and confidence, or None if no match
65
+ """
66
+ best_heading: MarkdownHeading | None = None
67
+ best_ratio = 0.0
68
+
69
+ # Normalize target: strip leading #s and whitespace, lowercase
70
+ norm_target = target.lstrip("#").strip().lower()
71
+
72
+ for heading in headings:
73
+ ratio = SequenceMatcher(None, heading.normalized_text, norm_target).ratio()
74
+
75
+ if ratio > best_ratio and ratio >= threshold:
76
+ best_ratio = ratio
77
+ best_heading = heading
78
+
79
+ if best_heading is not None:
80
+ return HeadingMatch(heading=best_heading, confidence=best_ratio)
81
+ return None
82
+
83
+
84
+ def find_close_matches(
85
+ headings: HeadingList,
86
+ target: str,
87
+ threshold: float = 0.6,
88
+ max_matches: int = 3,
89
+ ) -> list[CloseMatch]:
90
+ """Find headings that are close matches to the target.
91
+
92
+ Used for error messages when no exact match is found.
93
+
94
+ Args:
95
+ headings: List of MarkdownHeading objects
96
+ target: The target heading to match
97
+ threshold: Minimum similarity ratio for inclusion
98
+ max_matches: Maximum number of matches to return
99
+
100
+ Returns:
101
+ List of CloseMatch objects, sorted by confidence descending
102
+ """
103
+ norm_target = target.lstrip("#").strip().lower()
104
+ matches: list[CloseMatch] = []
105
+
106
+ for heading in headings:
107
+ ratio = SequenceMatcher(None, heading.normalized_text, norm_target).ratio()
108
+ if ratio >= threshold:
109
+ matches.append(CloseMatch(heading_text=heading.text, confidence=ratio))
110
+
111
+ # Sort by confidence descending
112
+ matches.sort(key=lambda x: x.confidence, reverse=True)
113
+ return matches[:max_matches]
114
+
115
+
116
+ def find_section_bounds(
117
+ lines: list[str],
118
+ heading_line_num: int,
119
+ heading_level: int,
120
+ ) -> tuple[int, int]:
121
+ """Find the boundaries of a section.
122
+
123
+ The section includes everything from the heading to the next heading
124
+ at the same or higher level (exclusive), or end of file.
125
+
126
+ Args:
127
+ lines: All lines of the file
128
+ heading_line_num: Line number of the section heading
129
+ heading_level: Level of the section heading (1-6)
130
+
131
+ Returns:
132
+ Tuple of (start_line, end_line) where end_line is exclusive
133
+ """
134
+ start = heading_line_num
135
+ end = len(lines) # Default to EOF
136
+
137
+ for i in range(heading_line_num + 1, len(lines)):
138
+ level = get_heading_level(lines[i])
139
+ if level is not None and level <= heading_level:
140
+ end = i
141
+ break
142
+
143
+ return (start, end)
144
+
145
+
146
+ def detect_line_ending(content: str) -> str:
147
+ """Detect the line ending style used in the content.
148
+
149
+ Args:
150
+ content: The file content
151
+
152
+ Returns:
153
+ The line ending string ('\\r\\n' or '\\n')
154
+ """
155
+ if "\r\n" in content:
156
+ return "\r\n"
157
+ return "\n"
158
+
159
+
160
+ def normalize_section_content(content: str) -> str:
161
+ """Normalize content to have no leading whitespace and single trailing newline.
162
+
163
+ Args:
164
+ content: The content to normalize
165
+
166
+ Returns:
167
+ Normalized content
168
+ """
169
+ return content.strip() + "\n"
170
+
171
+
172
+ def split_normalized_content(content: str) -> list[str]:
173
+ """Normalize content and split into lines for insertion.
174
+
175
+ Strips whitespace, ensures consistent formatting, and splits into lines
176
+ ready for insertion into a markdown file.
177
+
178
+ Args:
179
+ content: The content to normalize and split
180
+
181
+ Returns:
182
+ List of lines (without trailing empty line from split)
183
+ """
184
+ normalized = normalize_section_content(content)
185
+ lines = normalized.split("\n")
186
+ # Remove empty last line from split (since normalize_section_content adds \n)
187
+ if lines and lines[-1] == "":
188
+ lines.pop()
189
+ return lines
190
+
191
+
192
+ def parse_section_number(heading_text: str) -> SectionNumber | None:
193
+ """Parse section number from heading text.
194
+
195
+ Matches patterns like:
196
+ - "## 3. Title" -> prefix="3", has_trailing_dot=True
197
+ - "### 4.4 Title" -> prefix="4.4", has_trailing_dot=False
198
+ - "### 4.4. Title" -> prefix="4.4", has_trailing_dot=True
199
+ - "## 10.2.3 Title" -> prefix="10.2.3", has_trailing_dot=False
200
+
201
+ Args:
202
+ heading_text: The full heading line (e.g., "### 4.4 Title")
203
+
204
+ Returns:
205
+ SectionNumber if a number is found, None otherwise
206
+ """
207
+ # Pattern: ## <number>[.<number>...][.] <title>
208
+ # The number must be at the start after the hashes
209
+ match = re.match(r"^#{1,6}\s+(\d+(?:\.\d+)*)(\.?)\s+", heading_text)
210
+ if match:
211
+ return SectionNumber(
212
+ prefix=match.group(1),
213
+ has_trailing_dot=bool(match.group(2)),
214
+ )
215
+ return None
216
+
217
+
218
+ def increment_section_number(section_num: SectionNumber) -> str:
219
+ """Increment the last component of a section number.
220
+
221
+ Examples:
222
+ - "4.4" -> "4.5"
223
+ - "3" with trailing dot -> "4."
224
+ - "10.2.3" -> "10.2.4"
225
+
226
+ Args:
227
+ section_num: The parsed section number
228
+
229
+ Returns:
230
+ The incremented number string (with trailing dot if original had one)
231
+ """
232
+ parts = section_num.prefix.split(".")
233
+ parts[-1] = str(int(parts[-1]) + 1)
234
+ result = ".".join(parts)
235
+ if section_num.has_trailing_dot:
236
+ result += "."
237
+ return result
238
+
239
+
240
+ def decrement_section_number(section_num: SectionNumber) -> str:
241
+ """Decrement the last component of a section number.
242
+
243
+ Examples:
244
+ - "4.5" -> "4.4"
245
+ - "4" with trailing dot -> "3."
246
+
247
+ Args:
248
+ section_num: The parsed section number
249
+
250
+ Returns:
251
+ The decremented number string (with trailing dot if original had one)
252
+ """
253
+ parts = section_num.prefix.split(".")
254
+ parts[-1] = str(int(parts[-1]) - 1)
255
+ result = ".".join(parts)
256
+ if section_num.has_trailing_dot:
257
+ result += "."
258
+ return result
259
+
260
+
261
+ def renumber_headings_after(
262
+ lines: list[str],
263
+ start_line: int,
264
+ heading_level: int,
265
+ increment: bool = True,
266
+ ) -> list[str]:
267
+ """Renumber all numbered headings at the given level after start_line.
268
+
269
+ Only renumbers headings at exactly the same level.
270
+ Stops when encountering a heading at a higher level (lower number).
271
+
272
+ Args:
273
+ lines: All lines of the file
274
+ start_line: Line number to start renumbering from (inclusive)
275
+ heading_level: The heading level to renumber (1-6)
276
+ increment: True to increment numbers, False to decrement
277
+
278
+ Returns:
279
+ New list of lines with renumbered headings
280
+ """
281
+ new_lines = lines.copy()
282
+
283
+ for i in range(start_line, len(new_lines)):
284
+ level = get_heading_level(new_lines[i])
285
+ if level is None:
286
+ continue
287
+
288
+ # Stop if we hit a higher-level heading (parent section ended)
289
+ if level < heading_level:
290
+ break
291
+
292
+ # Only renumber headings at the exact same level
293
+ if level != heading_level:
294
+ continue
295
+
296
+ section_num = parse_section_number(new_lines[i])
297
+ if section_num is None:
298
+ continue
299
+
300
+ # Calculate new number
301
+ if increment:
302
+ new_num = increment_section_number(section_num)
303
+ else:
304
+ new_num = decrement_section_number(section_num)
305
+
306
+ # Replace the number in the heading
307
+ new_lines[i] = re.sub(
308
+ r"^(#{1,6}\s+)\d+(?:\.\d+)*\.?\s+",
309
+ f"\\g<1>{new_num} ",
310
+ new_lines[i],
311
+ )
312
+
313
+ return new_lines
314
+
315
+
316
+ async def load_markdown_file(
317
+ file_path: Path,
318
+ filename: str,
319
+ ) -> MarkdownFileContext | str:
320
+ """Load a markdown file and prepare it for section operations.
321
+
322
+ Handles file reading, line ending detection, CRLF normalization,
323
+ and heading extraction.
324
+
325
+ Args:
326
+ file_path: Absolute path to the file
327
+ filename: Original filename for error messages
328
+
329
+ Returns:
330
+ MarkdownFileContext on success, or error message string on failure
331
+ """
332
+ # Check if file exists
333
+ if not await aiofiles.os.path.exists(file_path):
334
+ return f"Error: File '{filename}' not found"
335
+
336
+ # Read file content (newline="" preserves original line endings)
337
+ async with aiofiles.open(file_path, encoding="utf-8", newline="") as f:
338
+ content = await f.read()
339
+
340
+ # Detect line ending style
341
+ line_ending = detect_line_ending(content)
342
+ lines = content.split("\n")
343
+
344
+ # Remove \r from lines if CRLF
345
+ if line_ending == "\r\n":
346
+ lines = [line.rstrip("\r") for line in lines]
347
+
348
+ # Extract headings
349
+ headings = extract_headings(content)
350
+
351
+ if not headings:
352
+ return f"Error: No headings found in '{filename}'. Cannot manipulate sections in files without headings."
353
+
354
+ return MarkdownFileContext(
355
+ file_path=file_path,
356
+ filename=filename,
357
+ lines=lines,
358
+ line_ending=line_ending,
359
+ headings=headings,
360
+ )
361
+
362
+
363
+ def find_and_validate_section(
364
+ ctx: MarkdownFileContext,
365
+ target_heading: str,
366
+ ) -> SectionMatchResult:
367
+ """Find a section by heading with fuzzy matching and validate the match.
368
+
369
+ Handles:
370
+ - Finding the best matching heading
371
+ - Detecting "no match" with helpful suggestions
372
+ - Detecting ambiguous matches
373
+ - Finding section boundaries
374
+
375
+ Args:
376
+ ctx: The loaded markdown file context
377
+ target_heading: The heading to search for (fuzzy matched)
378
+
379
+ Returns:
380
+ SectionMatchResult with either success data or error message
381
+ """
382
+ # Find matching heading
383
+ match_result = find_matching_heading(ctx.headings, target_heading)
384
+
385
+ if match_result is None:
386
+ # No match found - provide helpful error with available headings
387
+ available = [h.text for h in ctx.headings]
388
+ close = find_close_matches(ctx.headings, target_heading)
389
+
390
+ if close and close[0].confidence >= 0.6:
391
+ # There are close matches but below threshold
392
+ close_display = ", ".join(
393
+ f"'{m.heading_text}' ({int(m.confidence * 100)}%)" for m in close
394
+ )
395
+ return SectionMatchResult(
396
+ error=f"No section matching '{target_heading}' found in {ctx.filename}. "
397
+ f"Did you mean: {close_display}"
398
+ )
399
+ else:
400
+ # List available headings
401
+ available_display = ", ".join(available[:5])
402
+ if len(available) > 5:
403
+ available_display += f" (+{len(available) - 5} more)"
404
+ return SectionMatchResult(
405
+ error=f"No section matching '{target_heading}' found in {ctx.filename}. "
406
+ f"Available headings: {available_display}"
407
+ )
408
+
409
+ matched = match_result.heading
410
+ confidence = match_result.confidence
411
+
412
+ # Check for ambiguous matches (multiple close matches)
413
+ if confidence < 1.0:
414
+ close = find_close_matches(
415
+ ctx.headings, target_heading, threshold=confidence - 0.1
416
+ )
417
+ if len(close) > 1 and close[1].confidence >= confidence - 0.05:
418
+ # Second match is very close to first - ambiguous
419
+ close_display = ", ".join(
420
+ f"'{m.heading_text}' ({int(m.confidence * 100)}%)" for m in close[:3]
421
+ )
422
+ return SectionMatchResult(
423
+ error=f"Multiple sections closely match '{target_heading}' in {ctx.filename}: "
424
+ f"{close_display}. Please be more specific."
425
+ )
426
+
427
+ # Find section boundaries
428
+ start_line, end_line = find_section_bounds(
429
+ ctx.lines, matched.line_number, matched.level
430
+ )
431
+
432
+ return SectionMatchResult(
433
+ heading=matched,
434
+ confidence=confidence,
435
+ start_line=start_line,
436
+ end_line=end_line,
437
+ )
438
+
439
+
440
+ async def write_markdown_file(ctx: MarkdownFileContext, new_lines: list[str]) -> None:
441
+ """Write modified lines back to a markdown file.
442
+
443
+ Args:
444
+ ctx: The markdown file context (provides path and line ending)
445
+ new_lines: The new lines to write
446
+ """
447
+ new_content = ctx.line_ending.join(new_lines)
448
+ # Ensure file ends with a newline (standard for text files, prevents corruption
449
+ # when multiple operations are performed sequentially)
450
+ if new_content and not new_content.endswith(ctx.line_ending):
451
+ new_content += ctx.line_ending
452
+ async with aiofiles.open(ctx.file_path, "w", encoding="utf-8", newline="") as f:
453
+ await f.write(new_content)
@@ -12,10 +12,10 @@ from collections.abc import Callable
12
12
  from enum import StrEnum
13
13
  from typing import TypeVar, overload
14
14
 
15
- import sentry_sdk
16
15
  from pydantic import BaseModel
17
16
 
18
17
  from shotgun.logging_config import get_logger
18
+ from shotgun.posthog_telemetry import track_event
19
19
 
20
20
  logger = get_logger(__name__)
21
21
 
@@ -30,6 +30,8 @@ class ToolCategory(StrEnum):
30
30
  ARTIFACT_MANAGEMENT = "artifact_management"
31
31
  WEB_RESEARCH = "web_research"
32
32
  AGENT_RESPONSE = "agent_response"
33
+ PLANNING = "planning"
34
+ DELEGATION = "delegation"
33
35
  UNKNOWN = "unknown"
34
36
 
35
37
 
@@ -39,11 +41,13 @@ class ToolDisplayConfig(BaseModel):
39
41
  Attributes:
40
42
  display_text: Text to show (e.g., "Reading file", "Querying code")
41
43
  key_arg: Primary argument to extract from tool args for display
44
+ secondary_key_arg: Optional secondary argument to display alongside primary
42
45
  hide: Whether to completely hide this tool call from the UI
43
46
  """
44
47
 
45
48
  display_text: str
46
49
  key_arg: str
50
+ secondary_key_arg: str | None = None
47
51
  hide: bool = False
48
52
 
49
53
 
@@ -62,6 +66,16 @@ def register_tool(
62
66
  ) -> Callable[[F], F]: ...
63
67
 
64
68
 
69
+ @overload
70
+ def register_tool(
71
+ category: ToolCategory,
72
+ display_text: str,
73
+ key_arg: str,
74
+ *,
75
+ secondary_key_arg: str,
76
+ ) -> Callable[[F], F]: ...
77
+
78
+
65
79
  @overload
66
80
  def register_tool(
67
81
  category: ToolCategory,
@@ -72,11 +86,23 @@ def register_tool(
72
86
  ) -> Callable[[F], F]: ...
73
87
 
74
88
 
89
+ @overload
75
90
  def register_tool(
76
91
  category: ToolCategory,
77
92
  display_text: str,
78
93
  key_arg: str,
79
94
  *,
95
+ secondary_key_arg: str,
96
+ hide: bool,
97
+ ) -> Callable[[F], F]: ...
98
+
99
+
100
+ def register_tool(
101
+ category: ToolCategory,
102
+ display_text: str,
103
+ key_arg: str,
104
+ *,
105
+ secondary_key_arg: str | None = None,
80
106
  hide: bool = False,
81
107
  ) -> Callable[[F], F]:
82
108
  """Decorator to register a tool's category and display configuration.
@@ -85,6 +111,7 @@ def register_tool(
85
111
  category: The ToolCategory enum value for this tool
86
112
  display_text: Text to show (e.g., "Reading file", "Querying code")
87
113
  key_arg: Primary argument name to extract for display (e.g., "query", "filename")
114
+ secondary_key_arg: Optional secondary argument to display alongside primary
88
115
  hide: Whether to hide this tool call completely from the UI (default: False)
89
116
 
90
117
  Returns:
@@ -93,6 +120,7 @@ def register_tool(
93
120
  Display Format:
94
121
  - When key_arg value is missing: Shows just display_text (e.g., "Reading file")
95
122
  - When key_arg value is present: Shows "display_text: key_arg_value" (e.g., "Reading file: foo.py")
123
+ - With secondary_key_arg: Shows "display_text: key_arg_value → secondary_value"
96
124
 
97
125
  Example:
98
126
  @register_tool(
@@ -102,6 +130,15 @@ def register_tool(
102
130
  )
103
131
  async def query_graph(ctx: RunContext[AgentDeps], query: str) -> str:
104
132
  ...
133
+
134
+ @register_tool(
135
+ category=ToolCategory.ARTIFACT_MANAGEMENT,
136
+ display_text="Replacing section",
137
+ key_arg="filename",
138
+ secondary_key_arg="section_heading",
139
+ )
140
+ async def replace_markdown_section(...) -> str:
141
+ ...
105
142
  """
106
143
 
107
144
  def decorator(func: F) -> F:
@@ -113,6 +150,7 @@ def register_tool(
113
150
  config = ToolDisplayConfig(
114
151
  display_text=display_text,
115
152
  key_arg=key_arg,
153
+ secondary_key_arg=secondary_key_arg,
116
154
  hide=hide,
117
155
  )
118
156
  _TOOL_DISPLAY_REGISTRY[tool_name] = config
@@ -128,7 +166,7 @@ tool_category = register_tool
128
166
 
129
167
 
130
168
  def get_tool_category(tool_name: str) -> ToolCategory:
131
- """Get category for a tool, logging unknown tools to Sentry.
169
+ """Get category for a tool, logging unknown tools to telemetry.
132
170
 
133
171
  Args:
134
172
  tool_name: Name of the tool to look up
@@ -140,10 +178,9 @@ def get_tool_category(tool_name: str) -> ToolCategory:
140
178
 
141
179
  if category is None:
142
180
  logger.warning(f"Unknown tool encountered in context analysis: {tool_name}")
143
- sentry_sdk.capture_message(
144
- f"Unknown tool in context analysis: {tool_name}",
145
- level="warning",
146
- extras={"tool_name": tool_name},
181
+ track_event(
182
+ "unknown_tool_encountered",
183
+ properties={"tool_name": tool_name},
147
184
  )
148
185
  return ToolCategory.UNKNOWN
149
186
 
@@ -183,6 +220,7 @@ def register_tool_display(
183
220
  display_text: str,
184
221
  key_arg: str,
185
222
  *,
223
+ secondary_key_arg: str | None = None,
186
224
  hide: bool = False,
187
225
  ) -> None:
188
226
  """Register a display config for a special tool that doesn't have a decorator.
@@ -193,11 +231,13 @@ def register_tool_display(
193
231
  tool_name: Name of the special tool
194
232
  display_text: Text to show (e.g., "Reading file", "Querying code")
195
233
  key_arg: Primary argument name to extract for display
234
+ secondary_key_arg: Optional secondary argument to display alongside primary
196
235
  hide: Whether to hide this tool call completely
197
236
  """
198
237
  config = ToolDisplayConfig(
199
238
  display_text=display_text,
200
239
  key_arg=key_arg,
240
+ secondary_key_arg=secondary_key_arg,
201
241
  hide=hide,
202
242
  )
203
243
  _TOOL_DISPLAY_REGISTRY[tool_name] = config
@@ -44,9 +44,8 @@ async def get_available_web_search_tools() -> list[WebSearchTool]:
44
44
  # Check if using Shotgun Account
45
45
  config_manager = get_config_manager()
46
46
  config = await config_manager.load()
47
- has_shotgun_key = config.shotgun.api_key is not None
48
47
 
49
- if has_shotgun_key:
48
+ if config.shotgun.has_valid_account:
50
49
  logger.debug("🔑 Shotgun Account - only Gemini web search available")
51
50
 
52
51
  # Gemini: Only search tool available for Shotgun Account
@@ -1,7 +1,7 @@
1
1
  """Gemini web search tool implementation."""
2
2
 
3
3
  from opentelemetry import trace
4
- from pydantic_ai.messages import ModelMessage, ModelRequest
4
+ from pydantic_ai.messages import ModelMessage, ModelRequest, TextPart
5
5
  from pydantic_ai.settings import ModelSettings
6
6
 
7
7
  from shotgun.agents.config import get_provider_model
@@ -82,8 +82,6 @@ async def gemini_web_search_tool(query: str) -> str:
82
82
  )
83
83
 
84
84
  # Extract text from response
85
- from pydantic_ai.messages import TextPart
86
-
87
85
  result_text = "No content returned from search"
88
86
  if response.parts:
89
87
  for part in response.parts: