shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.3.3.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. shotgun/agents/agent_manager.py +382 -60
  2. shotgun/agents/common.py +15 -9
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/constants.py +0 -6
  6. shotgun/agents/config/manager.py +383 -82
  7. shotgun/agents/config/models.py +122 -18
  8. shotgun/agents/config/provider.py +81 -15
  9. shotgun/agents/config/streaming_test.py +119 -0
  10. shotgun/agents/context_analyzer/__init__.py +28 -0
  11. shotgun/agents/context_analyzer/analyzer.py +475 -0
  12. shotgun/agents/context_analyzer/constants.py +9 -0
  13. shotgun/agents/context_analyzer/formatter.py +115 -0
  14. shotgun/agents/context_analyzer/models.py +212 -0
  15. shotgun/agents/conversation/__init__.py +18 -0
  16. shotgun/agents/conversation/filters.py +164 -0
  17. shotgun/agents/conversation/history/chunking.py +278 -0
  18. shotgun/agents/{history → conversation/history}/compaction.py +36 -5
  19. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  20. shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
  21. shotgun/agents/{history → conversation/history}/history_processors.py +380 -8
  22. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +25 -1
  23. shotgun/agents/{history → conversation/history}/token_counting/base.py +14 -3
  24. shotgun/agents/{history → conversation/history}/token_counting/openai.py +11 -1
  25. shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +8 -0
  26. shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +3 -1
  27. shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -3
  28. shotgun/agents/{conversation_manager.py → conversation/manager.py} +36 -20
  29. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -92
  30. shotgun/agents/error/__init__.py +11 -0
  31. shotgun/agents/error/models.py +19 -0
  32. shotgun/agents/export.py +2 -2
  33. shotgun/agents/plan.py +2 -2
  34. shotgun/agents/research.py +3 -3
  35. shotgun/agents/runner.py +230 -0
  36. shotgun/agents/specify.py +2 -2
  37. shotgun/agents/tasks.py +2 -2
  38. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  39. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  40. shotgun/agents/tools/codebase/file_read.py +11 -2
  41. shotgun/agents/tools/codebase/query_graph.py +6 -0
  42. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  43. shotgun/agents/tools/file_management.py +27 -7
  44. shotgun/agents/tools/registry.py +217 -0
  45. shotgun/agents/tools/web_search/__init__.py +8 -8
  46. shotgun/agents/tools/web_search/anthropic.py +8 -2
  47. shotgun/agents/tools/web_search/gemini.py +7 -1
  48. shotgun/agents/tools/web_search/openai.py +8 -2
  49. shotgun/agents/tools/web_search/utils.py +2 -2
  50. shotgun/agents/usage_manager.py +16 -11
  51. shotgun/api_endpoints.py +7 -3
  52. shotgun/build_constants.py +2 -2
  53. shotgun/cli/clear.py +53 -0
  54. shotgun/cli/compact.py +188 -0
  55. shotgun/cli/config.py +8 -5
  56. shotgun/cli/context.py +154 -0
  57. shotgun/cli/error_handler.py +24 -0
  58. shotgun/cli/export.py +34 -34
  59. shotgun/cli/feedback.py +4 -2
  60. shotgun/cli/models.py +1 -0
  61. shotgun/cli/plan.py +34 -34
  62. shotgun/cli/research.py +18 -10
  63. shotgun/cli/spec/__init__.py +5 -0
  64. shotgun/cli/spec/backup.py +81 -0
  65. shotgun/cli/spec/commands.py +132 -0
  66. shotgun/cli/spec/models.py +48 -0
  67. shotgun/cli/spec/pull_service.py +219 -0
  68. shotgun/cli/specify.py +20 -19
  69. shotgun/cli/tasks.py +34 -34
  70. shotgun/cli/update.py +16 -2
  71. shotgun/codebase/core/change_detector.py +5 -3
  72. shotgun/codebase/core/code_retrieval.py +4 -2
  73. shotgun/codebase/core/ingestor.py +163 -15
  74. shotgun/codebase/core/manager.py +13 -4
  75. shotgun/codebase/core/nl_query.py +1 -1
  76. shotgun/codebase/models.py +2 -0
  77. shotgun/exceptions.py +357 -0
  78. shotgun/llm_proxy/__init__.py +17 -0
  79. shotgun/llm_proxy/client.py +215 -0
  80. shotgun/llm_proxy/models.py +137 -0
  81. shotgun/logging_config.py +60 -27
  82. shotgun/main.py +77 -11
  83. shotgun/posthog_telemetry.py +38 -29
  84. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
  85. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  86. shotgun/prompts/agents/plan.j2 +16 -0
  87. shotgun/prompts/agents/research.j2 +16 -3
  88. shotgun/prompts/agents/specify.j2 +54 -1
  89. shotgun/prompts/agents/state/system_state.j2 +0 -2
  90. shotgun/prompts/agents/tasks.j2 +16 -0
  91. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  92. shotgun/prompts/history/combine_summaries.j2 +53 -0
  93. shotgun/sdk/codebase.py +14 -3
  94. shotgun/sentry_telemetry.py +163 -16
  95. shotgun/settings.py +243 -0
  96. shotgun/shotgun_web/__init__.py +67 -1
  97. shotgun/shotgun_web/client.py +42 -1
  98. shotgun/shotgun_web/constants.py +46 -0
  99. shotgun/shotgun_web/exceptions.py +29 -0
  100. shotgun/shotgun_web/models.py +390 -0
  101. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  102. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  103. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  104. shotgun/shotgun_web/shared_specs/models.py +71 -0
  105. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  106. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  107. shotgun/shotgun_web/specs_client.py +703 -0
  108. shotgun/shotgun_web/supabase_client.py +31 -0
  109. shotgun/telemetry.py +10 -33
  110. shotgun/tui/app.py +310 -46
  111. shotgun/tui/commands/__init__.py +1 -1
  112. shotgun/tui/components/context_indicator.py +179 -0
  113. shotgun/tui/components/mode_indicator.py +70 -0
  114. shotgun/tui/components/status_bar.py +48 -0
  115. shotgun/tui/containers.py +91 -0
  116. shotgun/tui/dependencies.py +39 -0
  117. shotgun/tui/layout.py +5 -0
  118. shotgun/tui/protocols.py +45 -0
  119. shotgun/tui/screens/chat/__init__.py +5 -0
  120. shotgun/tui/screens/chat/chat.tcss +54 -0
  121. shotgun/tui/screens/chat/chat_screen.py +1531 -0
  122. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +243 -0
  123. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  124. shotgun/tui/screens/chat/help_text.py +40 -0
  125. shotgun/tui/screens/chat/prompt_history.py +48 -0
  126. shotgun/tui/screens/chat.tcss +11 -0
  127. shotgun/tui/screens/chat_screen/command_providers.py +91 -4
  128. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  129. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  130. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  131. shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
  132. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  133. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  134. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  135. shotgun/tui/screens/confirmation_dialog.py +191 -0
  136. shotgun/tui/screens/directory_setup.py +45 -41
  137. shotgun/tui/screens/feedback.py +14 -7
  138. shotgun/tui/screens/github_issue.py +111 -0
  139. shotgun/tui/screens/model_picker.py +77 -32
  140. shotgun/tui/screens/onboarding.py +580 -0
  141. shotgun/tui/screens/pipx_migration.py +205 -0
  142. shotgun/tui/screens/provider_config.py +116 -35
  143. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  144. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  145. shotgun/tui/screens/shared_specs/models.py +56 -0
  146. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  147. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  148. shotgun/tui/screens/shotgun_auth.py +112 -18
  149. shotgun/tui/screens/spec_pull.py +288 -0
  150. shotgun/tui/screens/welcome.py +137 -11
  151. shotgun/tui/services/__init__.py +5 -0
  152. shotgun/tui/services/conversation_service.py +187 -0
  153. shotgun/tui/state/__init__.py +7 -0
  154. shotgun/tui/state/processing_state.py +185 -0
  155. shotgun/tui/utils/mode_progress.py +14 -7
  156. shotgun/tui/widgets/__init__.py +5 -0
  157. shotgun/tui/widgets/widget_coordinator.py +263 -0
  158. shotgun/utils/file_system_utils.py +22 -2
  159. shotgun/utils/marketing.py +110 -0
  160. shotgun/utils/update_checker.py +69 -14
  161. shotgun_sh-0.3.3.dev1.dist-info/METADATA +472 -0
  162. shotgun_sh-0.3.3.dev1.dist-info/RECORD +229 -0
  163. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
  164. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +1 -0
  165. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +1 -1
  166. shotgun/tui/screens/chat.py +0 -996
  167. shotgun/tui/screens/chat_screen/history.py +0 -335
  168. shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
  169. shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
  170. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  171. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  172. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  173. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  174. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  175. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
@@ -0,0 +1,475 @@
1
+ """Core context analysis logic."""
2
+
3
+ import json
4
+ from collections.abc import Sequence
5
+
6
+ from pydantic_ai.messages import (
7
+ ModelMessage,
8
+ ModelRequest,
9
+ ModelResponse,
10
+ SystemPromptPart,
11
+ TextPart,
12
+ ToolCallPart,
13
+ ToolReturnPart,
14
+ UserPromptPart,
15
+ )
16
+
17
+ from shotgun.agents.config.models import ModelConfig
18
+ from shotgun.agents.conversation.history.token_counting.utils import (
19
+ count_tokens_from_messages,
20
+ )
21
+ from shotgun.agents.conversation.history.token_estimation import (
22
+ estimate_tokens_from_messages,
23
+ )
24
+ from shotgun.agents.messages import AgentSystemPrompt, SystemStatusPrompt
25
+ from shotgun.logging_config import get_logger
26
+ from shotgun.tui.screens.chat_screen.hint_message import HintMessage
27
+
28
+ from .constants import ToolCategory, get_tool_category
29
+ from .models import ContextAnalysis, MessageTypeStats, TokenAllocation
30
+
31
+ logger = get_logger(__name__)
32
+
33
+
34
+ class ContextAnalyzer:
35
+ """Analyzes conversation message history for context composition."""
36
+
37
+ def __init__(self, model_config: ModelConfig):
38
+ """Initialize the analyzer with model configuration for token counting.
39
+
40
+ Args:
41
+ model_config: Model configuration for accurate token counting
42
+ """
43
+ self.model_config = model_config
44
+
45
+ async def _allocate_tokens_from_usage(
46
+ self,
47
+ message_history: list[ModelMessage],
48
+ ) -> TokenAllocation:
49
+ """Allocate tokens from actual API usage data proportionally to parts.
50
+
51
+ This uses the ground truth token counts from ModelResponse.usage instead of
52
+ creating synthetic messages, which avoids inflating counts with message framing overhead.
53
+
54
+ IMPORTANT: usage.input_tokens is cumulative (includes all conversation history), so we:
55
+ 1. Use the LAST response's input_tokens as the ground truth total
56
+ 2. Calculate proportions based on content size across ALL requests
57
+ 3. Allocate the ground truth total proportionally
58
+
59
+ If usage data is missing or zero (e.g., after compaction), falls back to token estimation.
60
+
61
+ Args:
62
+ message_history: List of actual messages from conversation
63
+
64
+ Returns:
65
+ TokenAllocation with token counts by message/tool type
66
+ """
67
+ # Step 1: Find the last response's usage data (ground truth for input tokens)
68
+ last_input_tokens = 0
69
+ total_output_tokens = 0
70
+
71
+ for msg in reversed(message_history):
72
+ if isinstance(msg, ModelResponse) and msg.usage:
73
+ last_input_tokens = msg.usage.input_tokens + msg.usage.cache_read_tokens
74
+ break
75
+
76
+ if last_input_tokens == 0:
77
+ # Fallback to token estimation (no logging to reduce verbosity)
78
+ last_input_tokens = await estimate_tokens_from_messages(
79
+ message_history, self.model_config
80
+ )
81
+
82
+ # Step 2: Calculate total output tokens (sum across all responses)
83
+ for msg in message_history:
84
+ if isinstance(msg, ModelResponse) and msg.usage:
85
+ total_output_tokens += msg.usage.output_tokens
86
+
87
+ # Step 3: Calculate content size proportions for each part type across ALL requests
88
+ # Initialize size accumulators
89
+ user_size = 0
90
+ system_prompts_size = 0
91
+ system_status_size = 0
92
+ codebase_understanding_input_size = 0
93
+ artifact_management_input_size = 0
94
+ web_research_input_size = 0
95
+ unknown_input_size = 0
96
+
97
+ for msg in message_history:
98
+ if isinstance(msg, ModelRequest):
99
+ for part in msg.parts:
100
+ if isinstance(part, (SystemPromptPart, UserPromptPart)):
101
+ size = len(part.content)
102
+ elif isinstance(part, ToolReturnPart):
103
+ # ToolReturnPart.content can be Any type
104
+ try:
105
+ content_str = (
106
+ json.dumps(part.content)
107
+ if part.content is not None
108
+ else ""
109
+ )
110
+ except (TypeError, ValueError):
111
+ content_str = (
112
+ str(part.content) if part.content is not None else ""
113
+ )
114
+ size = len(content_str)
115
+ else:
116
+ size = 0
117
+
118
+ # Categorize by part type
119
+ # Note: Check subclasses first (AgentSystemPrompt, SystemStatusPrompt)
120
+ # before checking base class (SystemPromptPart)
121
+ if isinstance(part, SystemStatusPrompt):
122
+ system_status_size += size
123
+ elif isinstance(part, AgentSystemPrompt):
124
+ system_prompts_size += size
125
+ elif isinstance(part, SystemPromptPart):
126
+ # Generic system prompt (not AgentSystemPrompt or SystemStatusPrompt)
127
+ system_prompts_size += size
128
+ elif isinstance(part, UserPromptPart):
129
+ user_size += size
130
+ elif isinstance(part, ToolReturnPart):
131
+ # Categorize tool results by tool category
132
+ category = get_tool_category(part.tool_name)
133
+ if category == ToolCategory.CODEBASE_UNDERSTANDING:
134
+ codebase_understanding_input_size += size
135
+ elif category == ToolCategory.ARTIFACT_MANAGEMENT:
136
+ artifact_management_input_size += size
137
+ elif category == ToolCategory.WEB_RESEARCH:
138
+ web_research_input_size += size
139
+ elif category == ToolCategory.UNKNOWN:
140
+ unknown_input_size += size
141
+
142
+ # Step 4: Calculate output proportions by tool category
143
+ codebase_understanding_size = 0
144
+ artifact_management_size = 0
145
+ web_research_size = 0
146
+ unknown_size = 0
147
+ agent_response_size = 0
148
+
149
+ for msg in message_history:
150
+ if isinstance(msg, ModelResponse):
151
+ for part in msg.parts: # type: ignore[assignment]
152
+ if isinstance(part, ToolCallPart):
153
+ category = get_tool_category(part.tool_name)
154
+ size = len(str(part.args))
155
+
156
+ if category == ToolCategory.AGENT_RESPONSE:
157
+ agent_response_size += size
158
+ elif category == ToolCategory.CODEBASE_UNDERSTANDING:
159
+ codebase_understanding_size += size
160
+ elif category == ToolCategory.ARTIFACT_MANAGEMENT:
161
+ artifact_management_size += size
162
+ elif category == ToolCategory.WEB_RESEARCH:
163
+ web_research_size += size
164
+ elif category == ToolCategory.UNKNOWN:
165
+ unknown_size += size
166
+ elif isinstance(part, TextPart):
167
+ agent_response_size += len(part.content)
168
+
169
+ # Step 5: Allocate input tokens proportionally
170
+ # Initialize TokenAllocation fields
171
+ user_tokens = 0
172
+ agent_response_tokens = 0
173
+ system_prompt_tokens = 0
174
+ system_status_tokens = 0
175
+ codebase_understanding_tokens = 0
176
+ artifact_management_tokens = 0
177
+ web_research_tokens = 0
178
+ unknown_tokens = 0
179
+
180
+ total_input_size = (
181
+ user_size
182
+ + system_prompts_size
183
+ + system_status_size
184
+ + codebase_understanding_input_size
185
+ + artifact_management_input_size
186
+ + web_research_input_size
187
+ + unknown_input_size
188
+ )
189
+
190
+ if total_input_size > 0 and last_input_tokens > 0:
191
+ user_tokens = int(last_input_tokens * (user_size / total_input_size))
192
+ system_prompt_tokens = int(
193
+ last_input_tokens * (system_prompts_size / total_input_size)
194
+ )
195
+ system_status_tokens = int(
196
+ last_input_tokens * (system_status_size / total_input_size)
197
+ )
198
+ codebase_understanding_tokens = int(
199
+ last_input_tokens
200
+ * (codebase_understanding_input_size / total_input_size)
201
+ )
202
+ artifact_management_tokens = int(
203
+ last_input_tokens * (artifact_management_input_size / total_input_size)
204
+ )
205
+ web_research_tokens = int(
206
+ last_input_tokens * (web_research_input_size / total_input_size)
207
+ )
208
+ unknown_tokens = int(
209
+ last_input_tokens * (unknown_input_size / total_input_size)
210
+ )
211
+
212
+ # Step 6: Allocate output tokens proportionally
213
+ total_output_size = (
214
+ codebase_understanding_size
215
+ + artifact_management_size
216
+ + web_research_size
217
+ + unknown_size
218
+ + agent_response_size
219
+ )
220
+
221
+ if total_output_size > 0 and total_output_tokens > 0:
222
+ codebase_understanding_tokens += int(
223
+ total_output_tokens * (codebase_understanding_size / total_output_size)
224
+ )
225
+ artifact_management_tokens += int(
226
+ total_output_tokens * (artifact_management_size / total_output_size)
227
+ )
228
+ web_research_tokens += int(
229
+ total_output_tokens * (web_research_size / total_output_size)
230
+ )
231
+ unknown_tokens += int(
232
+ total_output_tokens * (unknown_size / total_output_size)
233
+ )
234
+ agent_response_tokens += int(
235
+ total_output_tokens * (agent_response_size / total_output_size)
236
+ )
237
+ elif total_output_tokens > 0:
238
+ # If no content, put all in agent responses
239
+ agent_response_tokens = total_output_tokens
240
+
241
+ # Token allocation complete (no logging to reduce verbosity)
242
+
243
+ # Create TokenAllocation model
244
+ return TokenAllocation(
245
+ user=user_tokens,
246
+ agent_responses=agent_response_tokens,
247
+ system_prompts=system_prompt_tokens,
248
+ system_status=system_status_tokens,
249
+ codebase_understanding=codebase_understanding_tokens,
250
+ artifact_management=artifact_management_tokens,
251
+ web_research=web_research_tokens,
252
+ unknown=unknown_tokens,
253
+ )
254
+
255
+ async def analyze_conversation(
256
+ self,
257
+ message_history: list[ModelMessage],
258
+ ui_message_history: list[ModelMessage | HintMessage],
259
+ ) -> ContextAnalysis:
260
+ """Analyze the conversation to determine message type composition.
261
+
262
+ Args:
263
+ message_history: The agent message history (for token counting)
264
+ ui_message_history: The UI message history (includes hints)
265
+
266
+ Returns:
267
+ ContextAnalysis with statistics for each message type
268
+ """
269
+ # Track counts for each message type
270
+ user_count = 0
271
+ agent_responses_count = 0
272
+ system_prompts_count = 0
273
+ system_status_count = 0
274
+ codebase_understanding_count = 0
275
+ artifact_management_count = 0
276
+ web_research_count = 0
277
+ unknown_count = 0
278
+
279
+ # Analyze message_history to count message types
280
+ for msg in message_history:
281
+ if isinstance(msg, ModelRequest):
282
+ # Track what types are in this message for counting
283
+ has_user_prompt = False
284
+ has_system_prompt = False
285
+ has_system_status = False
286
+
287
+ # Check what part types this message contains
288
+ for part in msg.parts:
289
+ if isinstance(part, AgentSystemPrompt):
290
+ has_system_prompt = True
291
+ elif isinstance(part, SystemStatusPrompt):
292
+ has_system_status = True
293
+ elif isinstance(part, SystemPromptPart):
294
+ # Generic system prompt
295
+ has_system_prompt = True
296
+ elif isinstance(part, UserPromptPart):
297
+ has_user_prompt = True
298
+ elif isinstance(part, ToolReturnPart):
299
+ # Categorize tool results by category
300
+ category = get_tool_category(part.tool_name)
301
+ if category == ToolCategory.CODEBASE_UNDERSTANDING:
302
+ codebase_understanding_count += 1
303
+ elif category == ToolCategory.ARTIFACT_MANAGEMENT:
304
+ artifact_management_count += 1
305
+ elif category == ToolCategory.WEB_RESEARCH:
306
+ web_research_count += 1
307
+ elif category == ToolCategory.UNKNOWN:
308
+ unknown_count += 1
309
+
310
+ # Count the message types (only count once per message)
311
+ if has_system_prompt:
312
+ system_prompts_count += 1
313
+ if has_system_status:
314
+ system_status_count += 1
315
+ if has_user_prompt:
316
+ user_count += 1
317
+
318
+ elif isinstance(msg, ModelResponse):
319
+ # Agent responses - count entire response as one
320
+ agent_responses_count += 1
321
+
322
+ # Check for tool calls in the response
323
+ for part in msg.parts: # type: ignore[assignment]
324
+ if isinstance(part, ToolCallPart):
325
+ category = get_tool_category(part.tool_name)
326
+ if category == ToolCategory.CODEBASE_UNDERSTANDING:
327
+ codebase_understanding_count += 1
328
+ elif category == ToolCategory.ARTIFACT_MANAGEMENT:
329
+ artifact_management_count += 1
330
+ elif category == ToolCategory.WEB_RESEARCH:
331
+ web_research_count += 1
332
+ elif category == ToolCategory.UNKNOWN:
333
+ unknown_count += 1
334
+
335
+ # Count hints from ui_message_history
336
+ hint_count = sum(
337
+ 1 for msg in ui_message_history if isinstance(msg, HintMessage)
338
+ )
339
+
340
+ # Use actual API usage data for accurate token counting (avoids synthetic message overhead)
341
+ usage_tokens = await self._allocate_tokens_from_usage(message_history)
342
+
343
+ user_tokens = usage_tokens.user
344
+ agent_response_tokens = usage_tokens.agent_responses
345
+ system_prompt_tokens = usage_tokens.system_prompts
346
+ system_status_tokens = usage_tokens.system_status
347
+ codebase_understanding_tokens = usage_tokens.codebase_understanding
348
+ artifact_management_tokens = usage_tokens.artifact_management
349
+ web_research_tokens = usage_tokens.web_research
350
+ unknown_tokens = usage_tokens.unknown
351
+
352
+ # Estimate hint tokens (rough estimate based on character count)
353
+ hint_tokens = 0
354
+ for msg in ui_message_history: # type: ignore[assignment]
355
+ if isinstance(msg, HintMessage):
356
+ # Rough estimate: ~4 chars per token
357
+ hint_tokens += len(msg.message) // 4
358
+
359
+ # Calculate agent context tokens (excluding UI-only hints)
360
+ agent_context_tokens = (
361
+ user_tokens
362
+ + agent_response_tokens
363
+ + system_prompt_tokens
364
+ + system_status_tokens
365
+ + codebase_understanding_tokens
366
+ + artifact_management_tokens
367
+ + web_research_tokens
368
+ + unknown_tokens
369
+ )
370
+
371
+ # Total tokens includes hints for display purposes, but agent_context_tokens does not
372
+ total_tokens = agent_context_tokens + hint_tokens
373
+ total_messages = (
374
+ user_count
375
+ + agent_responses_count
376
+ + system_prompts_count
377
+ + system_status_count
378
+ + codebase_understanding_count
379
+ + artifact_management_count
380
+ + web_research_count
381
+ + unknown_count
382
+ + hint_count
383
+ )
384
+
385
+ # Calculate usable context limit (80% of max_input_tokens) and free space
386
+ # This matches the TOKEN_LIMIT_RATIO = 0.8 from history/constants.py
387
+ max_usable_tokens = int(self.model_config.max_input_tokens * 0.8)
388
+ free_space_tokens = max_usable_tokens - agent_context_tokens
389
+
390
+ return ContextAnalysis(
391
+ user_messages=MessageTypeStats(count=user_count, tokens=user_tokens),
392
+ agent_responses=MessageTypeStats(
393
+ count=agent_responses_count, tokens=agent_response_tokens
394
+ ),
395
+ system_prompts=MessageTypeStats(
396
+ count=system_prompts_count, tokens=system_prompt_tokens
397
+ ),
398
+ system_status=MessageTypeStats(
399
+ count=system_status_count, tokens=system_status_tokens
400
+ ),
401
+ codebase_understanding=MessageTypeStats(
402
+ count=codebase_understanding_count,
403
+ tokens=codebase_understanding_tokens,
404
+ ),
405
+ artifact_management=MessageTypeStats(
406
+ count=artifact_management_count, tokens=artifact_management_tokens
407
+ ),
408
+ web_research=MessageTypeStats(
409
+ count=web_research_count, tokens=web_research_tokens
410
+ ),
411
+ unknown=MessageTypeStats(count=unknown_count, tokens=unknown_tokens),
412
+ hint_messages=MessageTypeStats(count=hint_count, tokens=hint_tokens),
413
+ total_tokens=total_tokens,
414
+ total_messages=total_messages,
415
+ context_window=self.model_config.max_input_tokens,
416
+ agent_context_tokens=agent_context_tokens,
417
+ model_name=self.model_config.name.value,
418
+ max_usable_tokens=max_usable_tokens,
419
+ free_space_tokens=free_space_tokens,
420
+ )
421
+
422
+ async def _count_tokens_for_parts(
423
+ self,
424
+ parts: Sequence[
425
+ UserPromptPart | SystemPromptPart | ToolReturnPart | ToolCallPart
426
+ ],
427
+ part_type: str,
428
+ ) -> int:
429
+ """Count tokens for a list of parts by creating synthetic single-part messages.
430
+
431
+ This avoids double-counting when a message contains multiple part types.
432
+
433
+ Args:
434
+ parts: List of parts to count tokens for
435
+ part_type: Type of parts ("user", "system", "tool_return", "tool_call")
436
+
437
+ Returns:
438
+ Total token count for all parts
439
+ """
440
+ if not parts:
441
+ return 0
442
+
443
+ # Create synthetic messages with single parts for accurate token counting
444
+ synthetic_messages: list[ModelMessage] = []
445
+
446
+ for part in parts:
447
+ if part_type in ("user", "system", "tool_return"):
448
+ # These are request parts - wrap in ModelRequest
449
+ synthetic_messages.append(ModelRequest(parts=[part])) # type: ignore[list-item]
450
+ elif part_type == "tool_call":
451
+ # Tool calls are in responses - wrap in ModelResponse
452
+ synthetic_messages.append(ModelResponse(parts=[part])) # type: ignore[list-item]
453
+
454
+ # Count tokens for the synthetic messages
455
+ return await self._count_tokens_safe(synthetic_messages)
456
+
457
+ async def _count_tokens_safe(self, messages: Sequence[ModelMessage]) -> int:
458
+ """Count tokens for a list of messages, returning 0 on error.
459
+
460
+ Args:
461
+ messages: List of messages to count tokens for
462
+
463
+ Returns:
464
+ Token count or 0 if counting fails
465
+ """
466
+ if not messages:
467
+ return 0
468
+
469
+ try:
470
+ return await count_tokens_from_messages(list(messages), self.model_config)
471
+ except Exception as e:
472
+ logger.warning(f"Failed to count tokens: {e}")
473
+ # Fallback to rough estimate
474
+ total_chars = sum(len(str(msg)) for msg in messages)
475
+ return total_chars // 4 # Rough estimate: 4 chars per token
@@ -0,0 +1,9 @@
1
+ """Tool category registry for context analysis.
2
+
3
+ This module re-exports the tool registry functionality for backward compatibility.
4
+ The actual implementation is in shotgun.agents.tools.registry.
5
+ """
6
+
7
+ from shotgun.agents.tools.registry import ToolCategory, get_tool_category
8
+
9
+ __all__ = ["ToolCategory", "get_tool_category"]
@@ -0,0 +1,115 @@
1
+ """Format context analysis for various output types."""
2
+
3
+ from typing import Any
4
+
5
+ from .models import ContextAnalysis
6
+
7
+
8
+ class ContextFormatter:
9
+ """Formats context analysis for various output types."""
10
+
11
+ @staticmethod
12
+ def format_markdown(analysis: ContextAnalysis) -> str:
13
+ """Format the analysis as markdown for display.
14
+
15
+ Args:
16
+ analysis: Context analysis to format
17
+
18
+ Returns:
19
+ Markdown-formatted string
20
+ """
21
+ lines = ["# Conversation Context Analysis", ""]
22
+
23
+ # Top-level summary with model and usage info
24
+ usage_percent = (
25
+ (analysis.agent_context_tokens / analysis.max_usable_tokens * 100)
26
+ if analysis.max_usable_tokens > 0
27
+ else 0
28
+ )
29
+ free_percent = (
30
+ (analysis.free_space_tokens / analysis.max_usable_tokens * 100)
31
+ if analysis.max_usable_tokens > 0
32
+ else 0
33
+ )
34
+
35
+ lines.extend(
36
+ [
37
+ f"Model: {analysis.model_name}",
38
+ "",
39
+ f"Total Context: {analysis.agent_context_tokens:,} / {analysis.max_usable_tokens:,} tokens ({usage_percent:.1f}%)",
40
+ "",
41
+ f"Free Space: {analysis.free_space_tokens:,} tokens ({free_percent:.1f}%)",
42
+ "",
43
+ "Autocompact Buffer: 500 tokens",
44
+ "",
45
+ ]
46
+ )
47
+
48
+ # Create 25-character visual bar showing proportional usage
49
+ # Each character represents 4% of total context
50
+ filled_chars = int(usage_percent / 4)
51
+ empty_chars = 25 - filled_chars
52
+ visual_bar = "●" * filled_chars + "○" * empty_chars
53
+
54
+ lines.extend(
55
+ [
56
+ "## Context Composition",
57
+ visual_bar,
58
+ "",
59
+ ]
60
+ )
61
+
62
+ # Add agent context categories only (hints are not part of agent context)
63
+ agent_categories = [
64
+ ("🧑 User Messages", analysis.user_messages),
65
+ ("🤖 Agent Responses", analysis.agent_responses),
66
+ ("📋 System Prompts", analysis.system_prompts),
67
+ ("📊 System Status", analysis.system_status),
68
+ ("🔍 Codebase Understanding", analysis.codebase_understanding),
69
+ ("📦 Artifact Management", analysis.artifact_management),
70
+ ("🌐 Web Research", analysis.web_research),
71
+ ]
72
+
73
+ # Only add unknown if it has content
74
+ if analysis.unknown.count > 0:
75
+ agent_categories.append(("⚠️ Unknown Tools", analysis.unknown))
76
+
77
+ for label, stats in agent_categories:
78
+ if stats.count > 0:
79
+ percentage = analysis.get_percentage(stats)
80
+ # Align labels to 30 characters for clean visual layout
81
+ lines.append(
82
+ f"{label:<30} {percentage:>5.1f}% ({stats.count} messages, ~{stats.tokens:,} tokens)"
83
+ )
84
+ # Add blank line to prevent Textual's Markdown widget from reflowing
85
+ lines.append("")
86
+
87
+ return "\n".join(lines)
88
+
89
+ @staticmethod
90
+ def format_json(analysis: ContextAnalysis) -> dict[str, Any]:
91
+ """Format the analysis as a JSON-serializable dictionary.
92
+
93
+ Args:
94
+ analysis: Context analysis to format
95
+
96
+ Returns:
97
+ Dictionary with context analysis data
98
+ """
99
+ # Use Pydantic's model_dump() to serialize the model
100
+ data = analysis.model_dump()
101
+
102
+ # Add computed summary field
103
+ data["summary"] = {
104
+ "total_messages": analysis.total_messages - analysis.hint_messages.count,
105
+ "agent_context_tokens": analysis.agent_context_tokens,
106
+ "context_window": analysis.context_window,
107
+ "usage_percentage": round(
108
+ (analysis.agent_context_tokens / analysis.context_window * 100)
109
+ if analysis.context_window > 0
110
+ else 0,
111
+ 1,
112
+ ),
113
+ }
114
+
115
+ return data