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
@@ -13,24 +13,51 @@ logger = get_logger(__name__)
13
13
 
14
14
 
15
15
  async def apply_persistent_compaction(
16
- messages: list[ModelMessage], deps: AgentDeps
16
+ messages: list[ModelMessage], deps: AgentDeps, force: bool = False
17
17
  ) -> list[ModelMessage]:
18
18
  """Apply compaction to message history for persistent storage.
19
19
 
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
30
+ force: If True, force compaction even if below token threshold
26
31
 
27
32
  Returns:
28
33
  Compacted message history that should be stored as conversation state
29
34
  """
35
+ from .file_content_deduplication import deduplicate_file_content
30
36
  from .history_processors import token_limit_compactor
31
37
 
32
38
  try:
33
- # 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
34
61
  estimated_tokens = await estimate_tokens_from_messages(messages, deps.llm_model)
35
62
 
36
63
  # Create minimal usage info for compaction check
@@ -46,7 +73,7 @@ async def apply_persistent_compaction(
46
73
  self.usage = usage
47
74
 
48
75
  ctx = MockContext(deps, usage)
49
- compacted_messages = await token_limit_compactor(ctx, messages)
76
+ compacted_messages = await token_limit_compactor(ctx, messages, force=force)
50
77
 
51
78
  # Log the result for monitoring
52
79
  original_size = len(messages)
@@ -59,17 +86,21 @@ async def apply_persistent_compaction(
59
86
  f"({reduction_pct:.1f}% reduction)"
60
87
  )
61
88
 
62
- # Track persistent compaction event
89
+ # Track persistent compaction event with simple metrics (fast, no token counting)
63
90
  track_event(
64
91
  "persistent_compaction_applied",
65
92
  {
93
+ # Basic compaction metrics
66
94
  "messages_before": original_size,
67
95
  "messages_after": compacted_size,
68
- "tokens_before": estimated_tokens,
69
96
  "reduction_percentage": round(reduction_pct, 2),
70
97
  "agent_mode": deps.agent_mode.value
71
98
  if hasattr(deps, "agent_mode") and deps.agent_mode
72
99
  else "unknown",
100
+ # Model and provider info (no computation needed)
101
+ "model_name": deps.llm_model.name.value,
102
+ "provider": deps.llm_model.provider.value,
103
+ "key_provider": deps.llm_model.key_provider.value,
73
104
  },
74
105
  )
75
106
  else:
@@ -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,216 @@
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
+ import copy
9
+ import re
10
+ from enum import StrEnum
11
+ from typing import Any
12
+
13
+ from pydantic_ai.messages import (
14
+ ModelMessage,
15
+ ModelRequest,
16
+ ToolReturnPart,
17
+ )
18
+
19
+ from shotgun.logging_config import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class FileReadTool(StrEnum):
25
+ """Tool names that read file content."""
26
+
27
+ CODEBASE = "file_read" # Reads from indexed codebase (Kuzu graph)
28
+ SHOTGUN_FOLDER = "read_file" # Reads from .shotgun/ folder
29
+
30
+
31
+ # Minimum content length to bother deduplicating (skip tiny files)
32
+ MIN_CONTENT_LENGTH = 500
33
+
34
+ # Placeholder templates for each type
35
+ CODEBASE_PLACEHOLDER = (
36
+ "**File**: `{file_path}`\n"
37
+ "**Size**: {size_bytes} bytes | **Language**: {language}\n"
38
+ "**Content**: [Removed for compaction - use `retrieve_code` or `file_read` to access]"
39
+ )
40
+
41
+ SHOTGUN_PLACEHOLDER = (
42
+ "**File**: `.shotgun/{filename}`\n"
43
+ "**Content**: [Removed for compaction - file persisted in .shotgun/ folder]"
44
+ )
45
+
46
+ # Pattern for parsing file_read output (codebase files)
47
+ # Format: **File**: `path`\n**Size**: N bytes\n[optional encoding]\n\n**Content**:\n```lang\ncontent```
48
+ CODEBASE_FILE_PATTERN = re.compile(
49
+ r"\*\*File\*\*:\s*`([^`]+)`\s*\n" # File path
50
+ r"\*\*Size\*\*:\s*(\d+)\s*bytes\s*\n" # Size in bytes
51
+ r"(?:\*\*Encoding\*\*:.*?\n)?" # Optional encoding line
52
+ r"\n\*\*Content\*\*:\s*\n" # Blank line + Content header
53
+ r"```(\w*)\n" # Language tag
54
+ r"(.*?)```", # Actual content
55
+ re.DOTALL,
56
+ )
57
+
58
+
59
+ def _parse_codebase_file_content(
60
+ content: str,
61
+ ) -> tuple[str, int, str, str] | None:
62
+ """Parse file_read tool return content.
63
+
64
+ Args:
65
+ content: The tool return content string
66
+
67
+ Returns:
68
+ Tuple of (file_path, size_bytes, language, actual_content) or None if not parseable
69
+ """
70
+ match = CODEBASE_FILE_PATTERN.search(content)
71
+ if not match:
72
+ return None
73
+
74
+ file_path = match.group(1)
75
+ size_bytes = int(match.group(2))
76
+ language = match.group(3) or ""
77
+ actual_content = match.group(4)
78
+
79
+ return file_path, size_bytes, language, actual_content
80
+
81
+
82
+ def _create_codebase_placeholder(file_path: str, size_bytes: int, language: str) -> str:
83
+ """Create placeholder for codebase file content."""
84
+ return CODEBASE_PLACEHOLDER.format(
85
+ file_path=file_path,
86
+ size_bytes=size_bytes,
87
+ language=language or "unknown",
88
+ )
89
+
90
+
91
+ def _create_shotgun_placeholder(filename: str) -> str:
92
+ """Create placeholder for .shotgun/ file content."""
93
+ return SHOTGUN_PLACEHOLDER.format(filename=filename)
94
+
95
+
96
+ def _estimate_tokens_saved(original: str, replacement: str) -> int:
97
+ """Rough estimate of tokens saved (~4 chars per token)."""
98
+ original_chars = len(original)
99
+ replacement_chars = len(replacement)
100
+ # Rough token estimate: ~4 characters per token for code
101
+ return max(0, (original_chars - replacement_chars) // 4)
102
+
103
+
104
+ def deduplicate_file_content(
105
+ messages: list[ModelMessage],
106
+ retention_window: int = 3,
107
+ ) -> tuple[list[ModelMessage], int]:
108
+ """Replace file read content with placeholders for indexed/persisted files.
109
+
110
+ This is a deterministic pre-compaction pass that reduces tokens without
111
+ requiring an LLM. Files remain accessible via their respective tools.
112
+
113
+ Args:
114
+ messages: Conversation history
115
+ retention_window: Keep full content in last N messages (for recent context)
116
+
117
+ Returns:
118
+ Tuple of (modified_messages, estimated_tokens_saved)
119
+ """
120
+ if not messages:
121
+ return messages, 0
122
+
123
+ # Deep copy to avoid modifying original
124
+ modified_messages = copy.deepcopy(messages)
125
+ total_tokens_saved = 0
126
+ files_deduplicated = 0
127
+
128
+ # Calculate retention boundary (keep last N messages intact)
129
+ retention_start = max(0, len(modified_messages) - retention_window)
130
+
131
+ for msg_idx, message in enumerate(modified_messages):
132
+ # Skip messages in retention window
133
+ if msg_idx >= retention_start:
134
+ continue
135
+
136
+ # Only process ModelRequest (which contains ToolReturnPart)
137
+ if not isinstance(message, ModelRequest):
138
+ continue
139
+
140
+ # Build new parts list, replacing file content where appropriate
141
+ new_parts: list[Any] = []
142
+ message_modified = False
143
+
144
+ for part in message.parts:
145
+ if not isinstance(part, ToolReturnPart):
146
+ new_parts.append(part)
147
+ continue
148
+
149
+ tool_name = part.tool_name
150
+ content = part.content
151
+
152
+ # Skip if content is too short to bother
153
+ if not isinstance(content, str) or len(content) < MIN_CONTENT_LENGTH:
154
+ new_parts.append(part)
155
+ continue
156
+
157
+ replacement = None
158
+ original_content = content
159
+
160
+ # Handle codebase file reads (file_read)
161
+ if tool_name == FileReadTool.CODEBASE:
162
+ parsed = _parse_codebase_file_content(content)
163
+ if parsed:
164
+ file_path, size_bytes, language, actual_content = parsed
165
+ # Only replace if actual content is substantial
166
+ if len(actual_content) >= MIN_CONTENT_LENGTH:
167
+ replacement = _create_codebase_placeholder(
168
+ file_path, size_bytes, language
169
+ )
170
+ logger.debug(
171
+ f"Deduplicating codebase file: {file_path} "
172
+ f"({size_bytes} bytes)"
173
+ )
174
+
175
+ # Handle .shotgun/ file reads (read_file)
176
+ elif tool_name == FileReadTool.SHOTGUN_FOLDER:
177
+ # For read_file, content is raw - we need to figure out filename
178
+ # from the tool call args (but we only have the return here)
179
+ # Use a generic placeholder since we don't have the filename
180
+ if len(content) >= MIN_CONTENT_LENGTH:
181
+ # Try to extract filename from content if it looks like markdown
182
+ # Otherwise use generic placeholder
183
+ replacement = _create_shotgun_placeholder("artifact")
184
+ logger.debug(
185
+ f"Deduplicating .shotgun/ file read ({len(content)} chars)"
186
+ )
187
+
188
+ # Apply replacement if we have one
189
+ if replacement:
190
+ # Create new ToolReturnPart with replaced content
191
+ new_part = ToolReturnPart(
192
+ tool_name=part.tool_name,
193
+ tool_call_id=part.tool_call_id,
194
+ content=replacement,
195
+ timestamp=part.timestamp,
196
+ )
197
+ new_parts.append(new_part)
198
+ message_modified = True
199
+
200
+ tokens_saved = _estimate_tokens_saved(original_content, replacement)
201
+ total_tokens_saved += tokens_saved
202
+ files_deduplicated += 1
203
+ else:
204
+ new_parts.append(part)
205
+
206
+ # Replace message with new parts if modified
207
+ if message_modified:
208
+ modified_messages[msg_idx] = ModelRequest(parts=new_parts)
209
+
210
+ if files_deduplicated > 0:
211
+ logger.info(
212
+ f"File content deduplication: {files_deduplicated} files, "
213
+ f"~{total_tokens_saved:,} tokens saved"
214
+ )
215
+
216
+ return modified_messages, total_tokens_saved