shotgun-sh 0.2.17__py3-none-any.whl → 0.4.0.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 (150) hide show
  1. shotgun/agents/agent_manager.py +219 -37
  2. shotgun/agents/common.py +79 -78
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/manager.py +364 -53
  6. shotgun/agents/config/models.py +101 -21
  7. shotgun/agents/config/provider.py +51 -13
  8. shotgun/agents/config/streaming_test.py +119 -0
  9. shotgun/agents/context_analyzer/analyzer.py +6 -2
  10. shotgun/agents/conversation/__init__.py +18 -0
  11. shotgun/agents/conversation/filters.py +164 -0
  12. shotgun/agents/conversation/history/chunking.py +278 -0
  13. shotgun/agents/{history → conversation/history}/compaction.py +27 -1
  14. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  15. shotgun/agents/conversation/history/file_content_deduplication.py +239 -0
  16. shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
  17. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
  18. shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
  19. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
  20. shotgun/agents/error/__init__.py +11 -0
  21. shotgun/agents/error/models.py +19 -0
  22. shotgun/agents/export.py +12 -13
  23. shotgun/agents/models.py +66 -1
  24. shotgun/agents/plan.py +12 -13
  25. shotgun/agents/research.py +13 -10
  26. shotgun/agents/router/__init__.py +47 -0
  27. shotgun/agents/router/models.py +376 -0
  28. shotgun/agents/router/router.py +185 -0
  29. shotgun/agents/router/tools/__init__.py +18 -0
  30. shotgun/agents/router/tools/delegation_tools.py +503 -0
  31. shotgun/agents/router/tools/plan_tools.py +322 -0
  32. shotgun/agents/runner.py +230 -0
  33. shotgun/agents/specify.py +12 -13
  34. shotgun/agents/tasks.py +12 -13
  35. shotgun/agents/tools/file_management.py +49 -1
  36. shotgun/agents/tools/registry.py +2 -0
  37. shotgun/agents/tools/web_search/__init__.py +1 -2
  38. shotgun/agents/tools/web_search/gemini.py +1 -3
  39. shotgun/agents/tools/web_search/openai.py +1 -1
  40. shotgun/build_constants.py +2 -2
  41. shotgun/cli/clear.py +1 -1
  42. shotgun/cli/compact.py +5 -3
  43. shotgun/cli/context.py +44 -1
  44. shotgun/cli/error_handler.py +24 -0
  45. shotgun/cli/export.py +34 -34
  46. shotgun/cli/plan.py +34 -34
  47. shotgun/cli/research.py +17 -9
  48. shotgun/cli/spec/__init__.py +5 -0
  49. shotgun/cli/spec/backup.py +81 -0
  50. shotgun/cli/spec/commands.py +132 -0
  51. shotgun/cli/spec/models.py +48 -0
  52. shotgun/cli/spec/pull_service.py +219 -0
  53. shotgun/cli/specify.py +20 -19
  54. shotgun/cli/tasks.py +34 -34
  55. shotgun/codebase/core/change_detector.py +1 -1
  56. shotgun/codebase/core/ingestor.py +154 -8
  57. shotgun/codebase/core/manager.py +1 -1
  58. shotgun/codebase/models.py +2 -0
  59. shotgun/exceptions.py +325 -0
  60. shotgun/llm_proxy/__init__.py +17 -0
  61. shotgun/llm_proxy/client.py +215 -0
  62. shotgun/llm_proxy/models.py +137 -0
  63. shotgun/logging_config.py +42 -0
  64. shotgun/main.py +4 -0
  65. shotgun/posthog_telemetry.py +1 -1
  66. shotgun/prompts/agents/export.j2 +2 -0
  67. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +23 -3
  68. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  69. shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
  70. shotgun/prompts/agents/plan.j2 +29 -1
  71. shotgun/prompts/agents/research.j2 +75 -23
  72. shotgun/prompts/agents/router.j2 +440 -0
  73. shotgun/prompts/agents/specify.j2 +80 -4
  74. shotgun/prompts/agents/state/system_state.j2 +15 -8
  75. shotgun/prompts/agents/tasks.j2 +63 -23
  76. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  77. shotgun/prompts/history/combine_summaries.j2 +53 -0
  78. shotgun/sdk/codebase.py +14 -3
  79. shotgun/settings.py +5 -0
  80. shotgun/shotgun_web/__init__.py +67 -1
  81. shotgun/shotgun_web/client.py +42 -1
  82. shotgun/shotgun_web/constants.py +46 -0
  83. shotgun/shotgun_web/exceptions.py +29 -0
  84. shotgun/shotgun_web/models.py +390 -0
  85. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  86. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  87. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  88. shotgun/shotgun_web/shared_specs/models.py +71 -0
  89. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  90. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  91. shotgun/shotgun_web/specs_client.py +703 -0
  92. shotgun/shotgun_web/supabase_client.py +31 -0
  93. shotgun/tui/app.py +78 -15
  94. shotgun/tui/components/mode_indicator.py +120 -25
  95. shotgun/tui/components/status_bar.py +2 -2
  96. shotgun/tui/containers.py +1 -1
  97. shotgun/tui/dependencies.py +64 -9
  98. shotgun/tui/layout.py +5 -0
  99. shotgun/tui/protocols.py +37 -0
  100. shotgun/tui/screens/chat/chat.tcss +9 -1
  101. shotgun/tui/screens/chat/chat_screen.py +1015 -106
  102. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
  103. shotgun/tui/screens/chat_screen/command_providers.py +13 -89
  104. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  105. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  106. shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
  107. shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
  108. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  109. shotgun/tui/screens/chat_screen/messages.py +219 -0
  110. shotgun/tui/screens/confirmation_dialog.py +40 -0
  111. shotgun/tui/screens/directory_setup.py +45 -41
  112. shotgun/tui/screens/feedback.py +10 -3
  113. shotgun/tui/screens/github_issue.py +11 -2
  114. shotgun/tui/screens/model_picker.py +28 -8
  115. shotgun/tui/screens/onboarding.py +179 -26
  116. shotgun/tui/screens/pipx_migration.py +58 -6
  117. shotgun/tui/screens/provider_config.py +66 -8
  118. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  119. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  120. shotgun/tui/screens/shared_specs/models.py +56 -0
  121. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  122. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  123. shotgun/tui/screens/shotgun_auth.py +110 -16
  124. shotgun/tui/screens/spec_pull.py +288 -0
  125. shotgun/tui/screens/welcome.py +123 -0
  126. shotgun/tui/services/conversation_service.py +5 -2
  127. shotgun/tui/utils/mode_progress.py +20 -86
  128. shotgun/tui/widgets/__init__.py +2 -1
  129. shotgun/tui/widgets/approval_widget.py +152 -0
  130. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  131. shotgun/tui/widgets/plan_panel.py +129 -0
  132. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  133. shotgun/tui/widgets/widget_coordinator.py +1 -1
  134. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +11 -4
  135. shotgun_sh-0.4.0.dev1.dist-info/RECORD +242 -0
  136. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +1 -1
  137. shotgun_sh-0.2.17.dist-info/RECORD +0 -194
  138. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  139. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  140. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  141. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  142. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  143. /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
  144. /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
  145. /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
  146. /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
  147. /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
  148. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
  149. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
  150. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,278 @@
1
+ """Pattern-based chunking for oversized conversation compaction.
2
+
3
+ This module provides functions to break oversized conversations into logical
4
+ chunks for summarization, preserving semantic units like tool call sequences.
5
+ """
6
+
7
+ import logging
8
+ from dataclasses import dataclass, field
9
+
10
+ from pydantic_ai.messages import (
11
+ ModelMessage,
12
+ ModelRequest,
13
+ ModelResponse,
14
+ ToolCallPart,
15
+ ToolReturnPart,
16
+ UserPromptPart,
17
+ )
18
+
19
+ from shotgun.agents.config.models import ModelConfig
20
+
21
+ from .constants import CHUNK_TARGET_RATIO, RETENTION_WINDOW_MESSAGES
22
+ from .token_estimation import estimate_tokens_from_messages
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ @dataclass
28
+ class MessageGroup:
29
+ """A logical group of messages that must stay together.
30
+
31
+ Examples:
32
+ - A single user message
33
+ - A tool call sequence: ModelResponse(ToolCallPart) -> ModelRequest(ToolReturnPart)
34
+ - A standalone assistant response
35
+ """
36
+
37
+ messages: list[ModelMessage]
38
+ is_tool_sequence: bool = False
39
+ start_index: int = 0
40
+ end_index: int = 0
41
+ _token_count: int | None = field(default=None, repr=False)
42
+
43
+ async def get_token_count(self, model_config: ModelConfig) -> int:
44
+ """Lazily compute and cache token count for this group."""
45
+ if self._token_count is None:
46
+ self._token_count = await estimate_tokens_from_messages(
47
+ self.messages, model_config
48
+ )
49
+ return self._token_count
50
+
51
+
52
+ @dataclass
53
+ class Chunk:
54
+ """A chunk of message groups ready for summarization."""
55
+
56
+ groups: list[MessageGroup]
57
+ chunk_index: int
58
+ total_token_estimate: int = 0
59
+
60
+ def get_all_messages(self) -> list[ModelMessage]:
61
+ """Flatten all messages in this chunk."""
62
+ messages: list[ModelMessage] = []
63
+ for group in self.groups:
64
+ messages.extend(group.messages)
65
+ return messages
66
+
67
+
68
+ def identify_message_groups(messages: list[ModelMessage]) -> list[MessageGroup]:
69
+ """Identify logical message groups that must stay together.
70
+
71
+ Rules:
72
+ 1. Tool calls must include their responses (matched by tool_call_id)
73
+ 2. User messages are individual groups
74
+ 3. Standalone assistant responses are individual groups
75
+
76
+ Args:
77
+ messages: The full message history
78
+
79
+ Returns:
80
+ List of MessageGroup objects
81
+ """
82
+ groups: list[MessageGroup] = []
83
+
84
+ # Track pending tool calls that need their returns
85
+ # Maps tool_call_id -> group index
86
+ pending_tool_calls: dict[str, int] = {}
87
+
88
+ for i, msg in enumerate(messages):
89
+ if isinstance(msg, ModelResponse):
90
+ # Check for tool calls in response
91
+ tool_calls = [p for p in msg.parts if isinstance(p, ToolCallPart)]
92
+
93
+ if tool_calls:
94
+ # Start a tool sequence group
95
+ group = MessageGroup(
96
+ messages=[msg],
97
+ is_tool_sequence=True,
98
+ start_index=i,
99
+ end_index=i,
100
+ )
101
+ group_idx = len(groups)
102
+ groups.append(group)
103
+
104
+ # Track all tool call IDs in this response
105
+ for tc in tool_calls:
106
+ if tc.tool_call_id:
107
+ pending_tool_calls[tc.tool_call_id] = group_idx
108
+ else:
109
+ # Standalone assistant response (text only)
110
+ groups.append(
111
+ MessageGroup(
112
+ messages=[msg],
113
+ is_tool_sequence=False,
114
+ start_index=i,
115
+ end_index=i,
116
+ )
117
+ )
118
+
119
+ elif isinstance(msg, ModelRequest):
120
+ # Check for tool returns in request
121
+ tool_returns = [p for p in msg.parts if isinstance(p, ToolReturnPart)]
122
+ user_prompts = [p for p in msg.parts if isinstance(p, UserPromptPart)]
123
+
124
+ if tool_returns:
125
+ # Add to corresponding tool call groups
126
+ for tr in tool_returns:
127
+ if tr.tool_call_id and tr.tool_call_id in pending_tool_calls:
128
+ group_idx = pending_tool_calls.pop(tr.tool_call_id)
129
+ groups[group_idx].messages.append(msg)
130
+ groups[group_idx].end_index = i
131
+ # Note: orphaned tool returns are handled by filter_orphaned_tool_responses
132
+
133
+ elif user_prompts:
134
+ # User message - standalone group
135
+ groups.append(
136
+ MessageGroup(
137
+ messages=[msg],
138
+ is_tool_sequence=False,
139
+ start_index=i,
140
+ end_index=i,
141
+ )
142
+ )
143
+ # Note: System prompts are handled separately by compaction
144
+
145
+ logger.debug(
146
+ f"Identified {len(groups)} message groups "
147
+ f"({sum(1 for g in groups if g.is_tool_sequence)} tool sequences)"
148
+ )
149
+
150
+ return groups
151
+
152
+
153
+ async def create_chunks(
154
+ groups: list[MessageGroup],
155
+ model_config: ModelConfig,
156
+ retention_window: int = RETENTION_WINDOW_MESSAGES,
157
+ ) -> tuple[list[Chunk], list[ModelMessage]]:
158
+ """Create chunks from message groups, respecting token limits.
159
+
160
+ Args:
161
+ groups: List of message groups from identify_message_groups()
162
+ model_config: Model configuration for token limits
163
+ retention_window: Number of recent groups to keep outside compaction
164
+
165
+ Returns:
166
+ Tuple of (chunks_to_summarize, retained_recent_messages)
167
+ """
168
+ max_chunk_tokens = int(model_config.max_input_tokens * CHUNK_TARGET_RATIO)
169
+
170
+ # Handle edge case: too few groups
171
+ if len(groups) <= retention_window:
172
+ all_messages: list[ModelMessage] = []
173
+ for g in groups:
174
+ all_messages.extend(g.messages)
175
+ return [], all_messages
176
+
177
+ # Separate retention window from groups to chunk
178
+ groups_to_chunk = groups[:-retention_window]
179
+ retained_groups = groups[-retention_window:]
180
+
181
+ # Build chunks
182
+ chunks: list[Chunk] = []
183
+ current_groups: list[MessageGroup] = []
184
+ current_tokens = 0
185
+
186
+ for group in groups_to_chunk:
187
+ group_tokens = await group.get_token_count(model_config)
188
+
189
+ # Handle oversized single group - becomes its own chunk
190
+ if group_tokens > max_chunk_tokens:
191
+ # Finish current chunk if any
192
+ if current_groups:
193
+ chunks.append(
194
+ Chunk(
195
+ groups=current_groups,
196
+ chunk_index=len(chunks),
197
+ total_token_estimate=current_tokens,
198
+ )
199
+ )
200
+ current_groups = []
201
+ current_tokens = 0
202
+
203
+ # Add oversized as its own chunk
204
+ chunks.append(
205
+ Chunk(
206
+ groups=[group],
207
+ chunk_index=len(chunks),
208
+ total_token_estimate=group_tokens,
209
+ )
210
+ )
211
+ logger.warning(
212
+ f"Oversized message group ({group_tokens:,} tokens) "
213
+ f"added as single chunk - may need special handling"
214
+ )
215
+ continue
216
+
217
+ # Would adding this group exceed limit?
218
+ if current_tokens + group_tokens > max_chunk_tokens:
219
+ # Finish current chunk
220
+ if current_groups:
221
+ chunks.append(
222
+ Chunk(
223
+ groups=current_groups,
224
+ chunk_index=len(chunks),
225
+ total_token_estimate=current_tokens,
226
+ )
227
+ )
228
+ current_groups = [group]
229
+ current_tokens = group_tokens
230
+ else:
231
+ current_groups.append(group)
232
+ current_tokens += group_tokens
233
+
234
+ # Don't forget last chunk
235
+ if current_groups:
236
+ chunks.append(
237
+ Chunk(
238
+ groups=current_groups,
239
+ chunk_index=len(chunks),
240
+ total_token_estimate=current_tokens,
241
+ )
242
+ )
243
+
244
+ # Extract retained messages
245
+ retained_messages: list[ModelMessage] = []
246
+ for g in retained_groups:
247
+ retained_messages.extend(g.messages)
248
+
249
+ # Update chunk indices (in case any were out of order)
250
+ for i, chunk in enumerate(chunks):
251
+ chunk.chunk_index = i
252
+
253
+ logger.info(
254
+ f"Created {len(chunks)} chunks for compaction, "
255
+ f"retaining {len(retained_messages)} recent messages"
256
+ )
257
+
258
+ return chunks, retained_messages
259
+
260
+
261
+ async def chunk_messages_for_compaction(
262
+ messages: list[ModelMessage],
263
+ model_config: ModelConfig,
264
+ ) -> tuple[list[Chunk], list[ModelMessage]]:
265
+ """Main entry point: chunk oversized conversation for summarization.
266
+
267
+ This function identifies logical message groups (preserving tool call sequences),
268
+ then packs them into chunks that fit within model token limits.
269
+
270
+ Args:
271
+ messages: Full conversation message history
272
+ model_config: Model configuration for token limits
273
+
274
+ Returns:
275
+ Tuple of (chunks_to_summarize, retention_window_messages)
276
+ """
277
+ groups = identify_message_groups(messages)
278
+ return await create_chunks(groups, model_config)
@@ -20,6 +20,10 @@ async def apply_persistent_compaction(
20
20
  This ensures that compacted history is actually used as the conversation baseline,
21
21
  preventing cascading compaction issues across both CLI and TUI usage patterns.
22
22
 
23
+ Compaction happens in two phases:
24
+ 1. Deterministic pre-compaction: Remove file content (no LLM needed)
25
+ 2. LLM-based compaction: Summarize conversation if still over threshold
26
+
23
27
  Args:
24
28
  messages: Full message history from agent run
25
29
  deps: Agent dependencies containing model config
@@ -28,10 +32,32 @@ async def apply_persistent_compaction(
28
32
  Returns:
29
33
  Compacted message history that should be stored as conversation state
30
34
  """
35
+ from .file_content_deduplication import deduplicate_file_content
31
36
  from .history_processors import token_limit_compactor
32
37
 
33
38
  try:
34
- # Count actual token usage using shared utility
39
+ # STEP 1: Deterministic pre-compaction (no LLM cost)
40
+ # Remove file content from tool returns - files are still accessible
41
+ # via retrieve_code (codebase) or read_file (.shotgun/ folder)
42
+ messages, tokens_saved = deduplicate_file_content(
43
+ messages,
44
+ retention_window=3, # Keep last 3 messages' file content intact
45
+ )
46
+
47
+ if tokens_saved > 0:
48
+ logger.info(
49
+ f"Pre-compaction: removed ~{tokens_saved:,} tokens of file content"
50
+ )
51
+ track_event(
52
+ "file_content_deduplication",
53
+ {
54
+ "tokens_saved_estimate": tokens_saved,
55
+ "retention_window": 3,
56
+ "model_name": deps.llm_model.name.value,
57
+ },
58
+ )
59
+
60
+ # STEP 2: Count tokens after pre-compaction
35
61
  estimated_tokens = await estimate_tokens_from_messages(messages, deps.llm_model)
36
62
 
37
63
  # Create minimal usage info for compaction check
@@ -10,6 +10,11 @@ INPUT_BUFFER_TOKENS = 500
10
10
  MIN_SUMMARY_TOKENS = 100
11
11
  TOKEN_LIMIT_RATIO = 0.8
12
12
 
13
+ # Chunked compaction constants
14
+ CHUNK_TARGET_RATIO = 0.60 # Target chunk size as % of max_input_tokens
15
+ CHUNK_SAFE_RATIO = 0.70 # Max safe ratio before triggering chunked compaction
16
+ RETENTION_WINDOW_MESSAGES = 5 # Keep last N message groups outside compaction
17
+
13
18
 
14
19
  class SummaryType(Enum):
15
20
  """Types of summarization requests for logging."""
@@ -0,0 +1,239 @@
1
+ """Pre-compaction file content deduplication for conversation history.
2
+
3
+ This module provides a deterministic pre-pass that removes file content from
4
+ tool returns before LLM-based compaction. Files are still accessible via
5
+ `retrieve_code` (codebase) or `read_file` (.shotgun/ folder).
6
+ """
7
+
8
+ from enum import StrEnum
9
+ from typing import Any
10
+
11
+ from pydantic_ai.messages import (
12
+ ModelMessage,
13
+ ModelRequest,
14
+ ToolReturnPart,
15
+ )
16
+
17
+ from shotgun.logging_config import get_logger
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ class FileReadTool(StrEnum):
23
+ """Tool names that read file content."""
24
+
25
+ CODEBASE = "file_read" # Reads from indexed codebase (Kuzu graph)
26
+ SHOTGUN_FOLDER = "read_file" # Reads from .shotgun/ folder
27
+
28
+
29
+ # Minimum content length to bother deduplicating (skip tiny files)
30
+ MIN_CONTENT_LENGTH = 500
31
+
32
+ # Placeholder templates for each type
33
+ CODEBASE_PLACEHOLDER = (
34
+ "**File**: `{file_path}`\n"
35
+ "**Size**: {size_bytes} bytes | **Language**: {language}\n"
36
+ "**Content**: [Removed for compaction - use `retrieve_code` or `file_read` to access]"
37
+ )
38
+
39
+ SHOTGUN_PLACEHOLDER = (
40
+ "**File**: `.shotgun/{filename}`\n"
41
+ "**Content**: [Removed for compaction - file persisted in .shotgun/ folder]"
42
+ )
43
+
44
+ # Simple prefix for detecting file_read output format
45
+ # Instead of using regex, we just check for the expected prefix and extract the file path
46
+ CODEBASE_FILE_PREFIX = "**File**: `"
47
+
48
+
49
+ def _extract_file_path(content: str) -> str | None:
50
+ """Extract file path from file_read tool return content.
51
+
52
+ Uses simple string operations instead of regex for maximum performance.
53
+ The file_read tool output format is: **File**: `path`\\n...
54
+
55
+ Args:
56
+ content: The tool return content string
57
+
58
+ Returns:
59
+ The file path or None if format doesn't match
60
+ """
61
+ # Fast check: content must start with expected prefix
62
+ if not content.startswith(CODEBASE_FILE_PREFIX):
63
+ return None
64
+
65
+ # Find the closing backtick after the prefix
66
+ prefix_len = len(CODEBASE_FILE_PREFIX)
67
+ backtick_pos = content.find("`", prefix_len)
68
+
69
+ if backtick_pos == -1:
70
+ return None
71
+
72
+ return content[prefix_len:backtick_pos]
73
+
74
+
75
+ def _get_language_from_path(file_path: str) -> str:
76
+ """Infer programming language from file extension."""
77
+ from pathlib import Path
78
+
79
+ from shotgun.codebase.core.language_config import get_language_config
80
+
81
+ ext = Path(file_path).suffix
82
+ config = get_language_config(ext)
83
+ return config.name if config else "unknown"
84
+
85
+
86
+ def _create_codebase_placeholder(file_path: str, size_bytes: int, language: str) -> str:
87
+ """Create placeholder for codebase file content."""
88
+ return CODEBASE_PLACEHOLDER.format(
89
+ file_path=file_path,
90
+ size_bytes=size_bytes,
91
+ language=language or "unknown",
92
+ )
93
+
94
+
95
+ def _create_shotgun_placeholder(filename: str) -> str:
96
+ """Create placeholder for .shotgun/ file content."""
97
+ return SHOTGUN_PLACEHOLDER.format(filename=filename)
98
+
99
+
100
+ def _estimate_tokens_saved(original: str, replacement: str) -> int:
101
+ """Rough estimate of tokens saved (~4 chars per token)."""
102
+ original_chars = len(original)
103
+ replacement_chars = len(replacement)
104
+ # Rough token estimate: ~4 characters per token for code
105
+ return max(0, (original_chars - replacement_chars) // 4)
106
+
107
+
108
+ def deduplicate_file_content(
109
+ messages: list[ModelMessage],
110
+ retention_window: int = 3,
111
+ ) -> tuple[list[ModelMessage], int]:
112
+ """Replace file read content with placeholders for indexed/persisted files.
113
+
114
+ This is a deterministic pre-compaction pass that reduces tokens without
115
+ requiring an LLM. Files remain accessible via their respective tools.
116
+
117
+ This function uses copy-on-write semantics: only messages that need
118
+ modification are copied, while unmodified messages are reused by reference.
119
+ This significantly reduces memory allocation and processing time for large
120
+ conversations where only a subset of messages contain file content.
121
+
122
+ Args:
123
+ messages: Conversation history
124
+ retention_window: Keep full content in last N messages (for recent context)
125
+
126
+ Returns:
127
+ Tuple of (modified_messages, estimated_tokens_saved)
128
+ """
129
+ if not messages:
130
+ return messages, 0
131
+
132
+ total_tokens_saved = 0
133
+ files_deduplicated = 0
134
+
135
+ # Calculate retention boundary (keep last N messages intact)
136
+ retention_start = max(0, len(messages) - retention_window)
137
+
138
+ # Track which message indices need replacement
139
+ # We use a dict to store index -> new_message mappings
140
+ replacements: dict[int, ModelMessage] = {}
141
+
142
+ for msg_idx, message in enumerate(messages):
143
+ # Skip messages in retention window
144
+ if msg_idx >= retention_start:
145
+ continue
146
+
147
+ # Only process ModelRequest (which contains ToolReturnPart)
148
+ if not isinstance(message, ModelRequest):
149
+ continue
150
+
151
+ # Build new parts list, replacing file content where appropriate
152
+ new_parts: list[Any] = []
153
+ message_modified = False
154
+
155
+ for part in message.parts:
156
+ if not isinstance(part, ToolReturnPart):
157
+ new_parts.append(part)
158
+ continue
159
+
160
+ tool_name = part.tool_name
161
+ content = part.content
162
+
163
+ # Skip if content is too short to bother
164
+ if not isinstance(content, str) or len(content) < MIN_CONTENT_LENGTH:
165
+ new_parts.append(part)
166
+ continue
167
+
168
+ replacement = None
169
+ original_content = content
170
+
171
+ # Handle codebase file reads (file_read)
172
+ if tool_name == FileReadTool.CODEBASE:
173
+ file_path = _extract_file_path(content)
174
+ if file_path:
175
+ # Use content length as size estimate (includes formatting overhead
176
+ # but close enough for deduplication purposes)
177
+ size_bytes = len(content)
178
+ language = _get_language_from_path(file_path)
179
+ replacement = _create_codebase_placeholder(
180
+ file_path, size_bytes, language
181
+ )
182
+ logger.debug(
183
+ f"Deduplicating codebase file: {file_path} ({size_bytes} bytes)"
184
+ )
185
+
186
+ # Handle .shotgun/ file reads (read_file)
187
+ elif tool_name == FileReadTool.SHOTGUN_FOLDER:
188
+ # For read_file, content is raw - we need to figure out filename
189
+ # from the tool call args (but we only have the return here)
190
+ # Use a generic placeholder since we don't have the filename
191
+ if len(content) >= MIN_CONTENT_LENGTH:
192
+ # Try to extract filename from content if it looks like markdown
193
+ # Otherwise use generic placeholder
194
+ replacement = _create_shotgun_placeholder("artifact")
195
+ logger.debug(
196
+ f"Deduplicating .shotgun/ file read ({len(content)} chars)"
197
+ )
198
+
199
+ # Apply replacement if we have one
200
+ if replacement:
201
+ # Create new ToolReturnPart with replaced content
202
+ new_part = ToolReturnPart(
203
+ tool_name=part.tool_name,
204
+ tool_call_id=part.tool_call_id,
205
+ content=replacement,
206
+ timestamp=part.timestamp,
207
+ )
208
+ new_parts.append(new_part)
209
+ message_modified = True
210
+
211
+ tokens_saved = _estimate_tokens_saved(original_content, replacement)
212
+ total_tokens_saved += tokens_saved
213
+ files_deduplicated += 1
214
+ else:
215
+ new_parts.append(part)
216
+
217
+ # Only create a new message if parts were actually modified
218
+ if message_modified:
219
+ replacements[msg_idx] = ModelRequest(parts=new_parts)
220
+
221
+ # If no modifications were made, return original list (no allocation needed)
222
+ if not replacements:
223
+ return messages, 0
224
+
225
+ # Build result list with copy-on-write: reuse unmodified messages
226
+ modified_messages: list[ModelMessage] = []
227
+ for idx, msg in enumerate(messages):
228
+ if idx in replacements:
229
+ modified_messages.append(replacements[idx])
230
+ else:
231
+ modified_messages.append(msg)
232
+
233
+ if files_deduplicated > 0:
234
+ logger.info(
235
+ f"File content deduplication: {files_deduplicated} files, "
236
+ f"~{total_tokens_saved:,} tokens saved"
237
+ )
238
+
239
+ return modified_messages, total_tokens_saved