shotgun-sh 0.1.14__py3-none-any.whl → 0.2.11__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.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (143) hide show
  1. shotgun/agents/agent_manager.py +715 -75
  2. shotgun/agents/common.py +80 -75
  3. shotgun/agents/config/constants.py +21 -10
  4. shotgun/agents/config/manager.py +322 -97
  5. shotgun/agents/config/models.py +114 -84
  6. shotgun/agents/config/provider.py +232 -88
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +471 -0
  9. shotgun/agents/context_analyzer/constants.py +9 -0
  10. shotgun/agents/context_analyzer/formatter.py +115 -0
  11. shotgun/agents/context_analyzer/models.py +212 -0
  12. shotgun/agents/conversation_history.py +125 -2
  13. shotgun/agents/conversation_manager.py +57 -19
  14. shotgun/agents/export.py +6 -7
  15. shotgun/agents/history/compaction.py +10 -5
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +129 -12
  18. shotgun/agents/history/token_counting/__init__.py +31 -0
  19. shotgun/agents/history/token_counting/anthropic.py +127 -0
  20. shotgun/agents/history/token_counting/base.py +78 -0
  21. shotgun/agents/history/token_counting/openai.py +90 -0
  22. shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
  23. shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
  24. shotgun/agents/history/token_counting/utils.py +144 -0
  25. shotgun/agents/history/token_estimation.py +12 -12
  26. shotgun/agents/llm.py +62 -0
  27. shotgun/agents/models.py +59 -4
  28. shotgun/agents/plan.py +6 -7
  29. shotgun/agents/research.py +7 -8
  30. shotgun/agents/specify.py +6 -7
  31. shotgun/agents/tasks.py +6 -7
  32. shotgun/agents/tools/__init__.py +0 -2
  33. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  34. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  35. shotgun/agents/tools/codebase/file_read.py +11 -2
  36. shotgun/agents/tools/codebase/query_graph.py +6 -0
  37. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  38. shotgun/agents/tools/file_management.py +82 -16
  39. shotgun/agents/tools/registry.py +217 -0
  40. shotgun/agents/tools/web_search/__init__.py +55 -16
  41. shotgun/agents/tools/web_search/anthropic.py +76 -51
  42. shotgun/agents/tools/web_search/gemini.py +50 -27
  43. shotgun/agents/tools/web_search/openai.py +26 -17
  44. shotgun/agents/tools/web_search/utils.py +2 -2
  45. shotgun/agents/usage_manager.py +164 -0
  46. shotgun/api_endpoints.py +15 -0
  47. shotgun/cli/clear.py +53 -0
  48. shotgun/cli/compact.py +186 -0
  49. shotgun/cli/config.py +41 -67
  50. shotgun/cli/context.py +111 -0
  51. shotgun/cli/export.py +1 -1
  52. shotgun/cli/feedback.py +50 -0
  53. shotgun/cli/models.py +3 -2
  54. shotgun/cli/plan.py +1 -1
  55. shotgun/cli/research.py +1 -1
  56. shotgun/cli/specify.py +1 -1
  57. shotgun/cli/tasks.py +1 -1
  58. shotgun/cli/update.py +16 -2
  59. shotgun/codebase/core/change_detector.py +5 -3
  60. shotgun/codebase/core/code_retrieval.py +4 -2
  61. shotgun/codebase/core/ingestor.py +57 -16
  62. shotgun/codebase/core/manager.py +20 -7
  63. shotgun/codebase/core/nl_query.py +1 -1
  64. shotgun/codebase/models.py +4 -4
  65. shotgun/exceptions.py +32 -0
  66. shotgun/llm_proxy/__init__.py +19 -0
  67. shotgun/llm_proxy/clients.py +44 -0
  68. shotgun/llm_proxy/constants.py +15 -0
  69. shotgun/logging_config.py +18 -27
  70. shotgun/main.py +91 -12
  71. shotgun/posthog_telemetry.py +81 -10
  72. shotgun/prompts/agents/export.j2 +18 -1
  73. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  74. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  75. shotgun/prompts/agents/plan.j2 +1 -1
  76. shotgun/prompts/agents/research.j2 +1 -1
  77. shotgun/prompts/agents/specify.j2 +270 -3
  78. shotgun/prompts/agents/state/system_state.j2 +4 -0
  79. shotgun/prompts/agents/tasks.j2 +1 -1
  80. shotgun/prompts/loader.py +2 -2
  81. shotgun/prompts/tools/web_search.j2 +14 -0
  82. shotgun/sentry_telemetry.py +27 -18
  83. shotgun/settings.py +238 -0
  84. shotgun/shotgun_web/__init__.py +19 -0
  85. shotgun/shotgun_web/client.py +138 -0
  86. shotgun/shotgun_web/constants.py +21 -0
  87. shotgun/shotgun_web/models.py +47 -0
  88. shotgun/telemetry.py +24 -36
  89. shotgun/tui/app.py +251 -23
  90. shotgun/tui/commands/__init__.py +1 -1
  91. shotgun/tui/components/context_indicator.py +179 -0
  92. shotgun/tui/components/mode_indicator.py +70 -0
  93. shotgun/tui/components/status_bar.py +48 -0
  94. shotgun/tui/containers.py +91 -0
  95. shotgun/tui/dependencies.py +39 -0
  96. shotgun/tui/protocols.py +45 -0
  97. shotgun/tui/screens/chat/__init__.py +5 -0
  98. shotgun/tui/screens/chat/chat.tcss +54 -0
  99. shotgun/tui/screens/chat/chat_screen.py +1234 -0
  100. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  101. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  102. shotgun/tui/screens/chat/help_text.py +40 -0
  103. shotgun/tui/screens/chat/prompt_history.py +48 -0
  104. shotgun/tui/screens/chat.tcss +11 -0
  105. shotgun/tui/screens/chat_screen/command_providers.py +226 -11
  106. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  107. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  108. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  109. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  110. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  111. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  112. shotgun/tui/screens/confirmation_dialog.py +151 -0
  113. shotgun/tui/screens/feedback.py +193 -0
  114. shotgun/tui/screens/github_issue.py +102 -0
  115. shotgun/tui/screens/model_picker.py +352 -0
  116. shotgun/tui/screens/onboarding.py +431 -0
  117. shotgun/tui/screens/pipx_migration.py +153 -0
  118. shotgun/tui/screens/provider_config.py +156 -39
  119. shotgun/tui/screens/shotgun_auth.py +295 -0
  120. shotgun/tui/screens/welcome.py +198 -0
  121. shotgun/tui/services/__init__.py +5 -0
  122. shotgun/tui/services/conversation_service.py +184 -0
  123. shotgun/tui/state/__init__.py +7 -0
  124. shotgun/tui/state/processing_state.py +185 -0
  125. shotgun/tui/utils/mode_progress.py +14 -7
  126. shotgun/tui/widgets/__init__.py +5 -0
  127. shotgun/tui/widgets/widget_coordinator.py +262 -0
  128. shotgun/utils/datetime_utils.py +77 -0
  129. shotgun/utils/env_utils.py +13 -0
  130. shotgun/utils/file_system_utils.py +22 -2
  131. shotgun/utils/marketing.py +110 -0
  132. shotgun/utils/update_checker.py +69 -14
  133. shotgun_sh-0.2.11.dist-info/METADATA +130 -0
  134. shotgun_sh-0.2.11.dist-info/RECORD +194 -0
  135. {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
  136. {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
  137. shotgun/agents/history/token_counting.py +0 -429
  138. shotgun/agents/tools/user_interaction.py +0 -37
  139. shotgun/tui/screens/chat.py +0 -797
  140. shotgun/tui/screens/chat_screen/history.py +0 -350
  141. shotgun_sh-0.1.14.dist-info/METADATA +0 -466
  142. shotgun_sh-0.1.14.dist-info/RECORD +0 -133
  143. {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/WHEEL +0 -0
@@ -1,16 +1,27 @@
1
1
  """OpenAI web search tool implementation."""
2
2
 
3
- from openai import OpenAI
3
+ from openai import AsyncOpenAI
4
4
  from opentelemetry import trace
5
5
 
6
6
  from shotgun.agents.config import get_provider_model
7
7
  from shotgun.agents.config.models import ProviderType
8
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
8
9
  from shotgun.logging_config import get_logger
10
+ from shotgun.prompts import PromptLoader
11
+ from shotgun.utils.datetime_utils import get_datetime_context
9
12
 
10
13
  logger = get_logger(__name__)
11
14
 
15
+ # Global prompt loader instance
16
+ prompt_loader = PromptLoader()
12
17
 
13
- def openai_web_search_tool(query: str) -> str:
18
+
19
+ @register_tool(
20
+ category=ToolCategory.WEB_RESEARCH,
21
+ display_text="Searching web",
22
+ key_arg="query",
23
+ )
24
+ async def openai_web_search_tool(query: str) -> str:
14
25
  """Perform a web search and return results.
15
26
 
16
27
  This tool uses OpenAI's web search capabilities to find current information
@@ -32,7 +43,7 @@ def openai_web_search_tool(query: str) -> str:
32
43
 
33
44
  # Get API key from centralized configuration
34
45
  try:
35
- model_config = get_provider_model(ProviderType.OPENAI)
46
+ model_config = await get_provider_model(ProviderType.OPENAI)
36
47
  api_key = model_config.api_key
37
48
  except ValueError as e:
38
49
  error_msg = f"OpenAI API key not configured: {str(e)}"
@@ -40,22 +51,20 @@ def openai_web_search_tool(query: str) -> str:
40
51
  span.set_attribute("output.value", f"**Error:**\n {error_msg}\n")
41
52
  return error_msg
42
53
 
43
- prompt = f"""Please provide current and accurate information about the following query:
44
-
45
- Query: {query}
54
+ # Get datetime context for the search prompt
55
+ dt_context = get_datetime_context()
46
56
 
47
- Instructions:
48
- - Provide comprehensive, factual information
49
- - Include relevant details and context
50
- - Focus on current and recent information
51
- - Be specific and accurate in your response
52
- - You can't ask the user for details, so assume the most relevant details for the query
53
-
54
- ALWAYS PROVIDE THE SOURCES (urls) TO BACK UP THE INFORMATION YOU PROVIDE.
55
- """
57
+ # Render search prompt from template
58
+ prompt = prompt_loader.render(
59
+ "tools/web_search.j2",
60
+ query=query,
61
+ current_datetime=dt_context.datetime_formatted,
62
+ timezone_name=dt_context.timezone_name,
63
+ utc_offset=dt_context.utc_offset,
64
+ )
56
65
 
57
- client = OpenAI(api_key=api_key)
58
- response = client.responses.create( # type: ignore[call-overload]
66
+ client = AsyncOpenAI(api_key=api_key)
67
+ response = await client.responses.create( # type: ignore[call-overload]
59
68
  model="gpt-5-mini",
60
69
  input=[
61
70
  {"role": "user", "content": [{"type": "input_text", "text": prompt}]}
@@ -4,7 +4,7 @@ from shotgun.agents.config import get_provider_model
4
4
  from shotgun.agents.config.models import ProviderType
5
5
 
6
6
 
7
- def is_provider_available(provider: ProviderType) -> bool:
7
+ async def is_provider_available(provider: ProviderType) -> bool:
8
8
  """Check if a provider has API key configured.
9
9
 
10
10
  Args:
@@ -14,7 +14,7 @@ def is_provider_available(provider: ProviderType) -> bool:
14
14
  True if the provider has valid credentials configured (from config or env)
15
15
  """
16
16
  try:
17
- get_provider_model(provider)
17
+ await get_provider_model(provider)
18
18
  return True
19
19
  except ValueError:
20
20
  return False
@@ -0,0 +1,164 @@
1
+ import json
2
+ from collections import defaultdict
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from logging import getLogger
6
+ from pathlib import Path
7
+ from typing import TypeAlias
8
+
9
+ import aiofiles
10
+ import aiofiles.os
11
+ from genai_prices import calc_price
12
+ from pydantic import BaseModel, Field
13
+ from pydantic_ai import RunUsage
14
+
15
+ from shotgun.agents.config.models import ProviderType
16
+ from shotgun.utils import get_shotgun_home
17
+
18
+ logger = getLogger(__name__)
19
+ ModelName: TypeAlias = str
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class UsageSummaryEntry:
24
+ model_name: ModelName
25
+ provider: ProviderType
26
+ usage: RunUsage
27
+
28
+
29
+ class UsageLogEntry(BaseModel):
30
+ timestamp: datetime = Field(default_factory=datetime.now)
31
+ model_name: ModelName
32
+ usage: RunUsage
33
+ provider: ProviderType
34
+
35
+
36
+ class SessionUsage(BaseModel):
37
+ usage: RunUsage
38
+ log: list[UsageLogEntry]
39
+
40
+
41
+ class UsageState(BaseModel):
42
+ usage: dict[ModelName, RunUsage] = Field(default_factory=dict)
43
+ model_providers: dict[ModelName, ProviderType] = Field(default_factory=dict)
44
+ usage_log: list[UsageLogEntry] = Field(default_factory=list)
45
+
46
+
47
+ class SessionUsageManager:
48
+ def __init__(self) -> None:
49
+ self.usage: defaultdict[ModelName, RunUsage] = defaultdict(RunUsage)
50
+ self._model_providers: dict[ModelName, ProviderType] = {}
51
+ self._usage_log: list[UsageLogEntry] = []
52
+ self._usage_path: Path = get_shotgun_home() / "usage.json"
53
+ # Note: restore_usage_state needs to be called asynchronously after init
54
+ # Caller should use: manager = SessionUsageManager(); await manager.restore_usage_state()
55
+
56
+ async def add_usage(
57
+ self, usage: RunUsage, *, model_name: ModelName, provider: ProviderType
58
+ ) -> None:
59
+ self.usage[model_name] += usage
60
+ self._model_providers[model_name] = provider
61
+ self._usage_log.append(
62
+ UsageLogEntry(model_name=model_name, usage=usage, provider=provider)
63
+ )
64
+ await self.persist_usage_state()
65
+
66
+ def get_usage_report(self) -> dict[ModelName, RunUsage]:
67
+ return self.usage.copy()
68
+
69
+ def get_usage_breakdown(self) -> list[UsageSummaryEntry]:
70
+ breakdown: list[UsageSummaryEntry] = []
71
+ for model_name, usage in self.usage.items():
72
+ provider = self._model_providers.get(model_name)
73
+ if provider is None:
74
+ continue
75
+ breakdown.append(
76
+ UsageSummaryEntry(model_name=model_name, provider=provider, usage=usage)
77
+ )
78
+ breakdown.sort(key=lambda entry: entry.model_name.lower())
79
+ return breakdown
80
+
81
+ def build_usage_hint(self) -> str | None:
82
+ return format_usage_hint(self.get_usage_breakdown())
83
+
84
+ async def persist_usage_state(self) -> None:
85
+ state = UsageState(
86
+ usage=dict(self.usage.items()),
87
+ model_providers=self._model_providers.copy(),
88
+ usage_log=self._usage_log.copy(),
89
+ )
90
+
91
+ try:
92
+ await aiofiles.os.makedirs(self._usage_path.parent, exist_ok=True)
93
+ json_content = json.dumps(state.model_dump(mode="json"), indent=2)
94
+ async with aiofiles.open(self._usage_path, "w", encoding="utf-8") as f:
95
+ await f.write(json_content)
96
+ logger.debug("Usage state persisted to %s", self._usage_path)
97
+ except Exception as exc:
98
+ logger.error(
99
+ "Failed to persist usage state to %s: %s", self._usage_path, exc
100
+ )
101
+
102
+ async def restore_usage_state(self) -> None:
103
+ if not await aiofiles.os.path.exists(self._usage_path):
104
+ logger.debug("No usage state file found at %s", self._usage_path)
105
+ return
106
+
107
+ try:
108
+ async with aiofiles.open(self._usage_path, encoding="utf-8") as f:
109
+ content = await f.read()
110
+ data = json.loads(content)
111
+
112
+ state = UsageState.model_validate(data)
113
+ except Exception as exc:
114
+ logger.error(
115
+ "Failed to restore usage state from %s: %s", self._usage_path, exc
116
+ )
117
+ return
118
+
119
+ self.usage = defaultdict(RunUsage)
120
+ for model_name, usage in state.usage.items():
121
+ self.usage[model_name] = usage
122
+
123
+ self._model_providers = state.model_providers.copy()
124
+ self._usage_log = state.usage_log.copy()
125
+
126
+
127
+ def format_usage_hint(breakdown: list[UsageSummaryEntry]) -> str | None:
128
+ if not breakdown:
129
+ return None
130
+
131
+ lines = ["# Token usage by model"]
132
+
133
+ for entry in breakdown:
134
+ usage = entry.usage
135
+ input_tokens = usage.input_tokens
136
+ output_tokens = usage.output_tokens
137
+ cached_tokens = usage.cache_read_tokens
138
+
139
+ cost = calc_price(usage=usage, model_ref=entry.model_name)
140
+ input_line = f"* Input: {input_tokens:,}"
141
+ if cached_tokens > 0:
142
+ input_line += f" (+ {cached_tokens:,} cached)"
143
+ input_line += " tokens"
144
+ section = f"""
145
+ ### {entry.model_name}
146
+
147
+ {input_line}
148
+ * Output: {output_tokens:,} tokens
149
+ * Total: {input_tokens + output_tokens:,} tokens
150
+ * Cost: ${cost.total_price:,.2f}
151
+ """.strip()
152
+ lines.append(section)
153
+
154
+ return "\n\n".join(lines)
155
+
156
+
157
+ _usage_manager = None
158
+
159
+
160
+ def get_session_usage_manager() -> SessionUsageManager:
161
+ global _usage_manager
162
+ if _usage_manager is None:
163
+ _usage_manager = SessionUsageManager()
164
+ return _usage_manager
@@ -0,0 +1,15 @@
1
+ """Shotgun backend service API endpoints and URLs."""
2
+
3
+ from shotgun.settings import settings
4
+
5
+ # Shotgun Web API base URL (for authentication/subscription)
6
+ # Can be overridden with SHOTGUN_WEB_BASE_URL environment variable
7
+ SHOTGUN_WEB_BASE_URL = settings.api.web_base_url
8
+
9
+ # Shotgun's LiteLLM proxy base URL (for AI model requests)
10
+ # Can be overridden with SHOTGUN_ACCOUNT_LLM_BASE_URL environment variable
11
+ LITELLM_PROXY_BASE_URL = settings.api.account_llm_base_url
12
+
13
+ # Provider-specific LiteLLM proxy endpoints
14
+ LITELLM_PROXY_ANTHROPIC_BASE = f"{LITELLM_PROXY_BASE_URL}/anthropic"
15
+ LITELLM_PROXY_OPENAI_BASE = LITELLM_PROXY_BASE_URL
shotgun/cli/clear.py ADDED
@@ -0,0 +1,53 @@
1
+ """Clear command for shotgun CLI."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from shotgun.agents.conversation_manager import ConversationManager
10
+ from shotgun.logging_config import get_logger
11
+
12
+ app = typer.Typer(
13
+ name="clear", help="Clear the conversation history", no_args_is_help=False
14
+ )
15
+ logger = get_logger(__name__)
16
+ console = Console()
17
+
18
+
19
+ @app.callback(invoke_without_command=True)
20
+ def clear() -> None:
21
+ """Clear the current conversation history.
22
+
23
+ This command deletes the conversation file at ~/.shotgun-sh/conversation.json,
24
+ removing all conversation history. Other files in ~/.shotgun-sh/ (config, usage,
25
+ codebases, logs) are preserved.
26
+ """
27
+ try:
28
+ # Get conversation file path
29
+ conversation_file = Path.home() / ".shotgun-sh" / "conversation.json"
30
+
31
+ # Check if file exists
32
+ if not conversation_file.exists():
33
+ console.print(
34
+ "[yellow]No conversation file found.[/yellow] Nothing to clear.",
35
+ style="bold",
36
+ )
37
+ return
38
+
39
+ # Clear the conversation
40
+ manager = ConversationManager(conversation_file)
41
+ asyncio.run(manager.clear())
42
+
43
+ console.print(
44
+ "[green]✓[/green] Conversation cleared successfully", style="bold"
45
+ )
46
+ logger.info("Conversation cleared successfully")
47
+
48
+ except Exception as e:
49
+ console.print(
50
+ f"[red]Error:[/red] Failed to clear conversation: {e}", style="bold"
51
+ )
52
+ logger.debug("Full traceback:", exc_info=True)
53
+ raise typer.Exit(code=1) from e
shotgun/cli/compact.py ADDED
@@ -0,0 +1,186 @@
1
+ """Compact command for shotgun CLI."""
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Annotated, Any
7
+
8
+ import typer
9
+ from pydantic_ai.usage import RequestUsage
10
+ from rich.console import Console
11
+
12
+ from shotgun.agents.config import get_provider_model
13
+ from shotgun.agents.conversation_manager import ConversationManager
14
+ from shotgun.agents.history.history_processors import token_limit_compactor
15
+ from shotgun.agents.history.token_estimation import estimate_tokens_from_messages
16
+ from shotgun.cli.models import OutputFormat
17
+ from shotgun.logging_config import get_logger
18
+
19
+ app = typer.Typer(
20
+ name="compact", help="Compact the conversation history", no_args_is_help=False
21
+ )
22
+ logger = get_logger(__name__)
23
+ console = Console()
24
+
25
+
26
+ @app.callback(invoke_without_command=True)
27
+ def compact(
28
+ format: Annotated[
29
+ OutputFormat,
30
+ typer.Option(
31
+ "--format",
32
+ "-f",
33
+ help="Output format: markdown or json",
34
+ ),
35
+ ] = OutputFormat.MARKDOWN,
36
+ ) -> None:
37
+ """Compact the current conversation history to reduce size.
38
+
39
+ This command compacts the conversation in ~/.shotgun-sh/conversation.json
40
+ by summarizing older messages while preserving recent context. The compacted
41
+ conversation is automatically saved back to the file.
42
+ """
43
+ try:
44
+ result = asyncio.run(compact_conversation())
45
+
46
+ if format == OutputFormat.JSON:
47
+ # Output as JSON
48
+ console.print_json(json.dumps(result, indent=2))
49
+ else:
50
+ # Output as markdown
51
+ console.print(format_markdown(result))
52
+
53
+ except FileNotFoundError as e:
54
+ console.print(
55
+ f"[red]Error:[/red] {e}\n\n"
56
+ "No conversation found. Start a TUI session first with: [cyan]shotgun[/cyan]",
57
+ style="bold",
58
+ )
59
+ raise typer.Exit(code=1) from e
60
+ except Exception as e:
61
+ console.print(
62
+ f"[red]Error:[/red] Failed to compact conversation: {e}", style="bold"
63
+ )
64
+ logger.debug("Full traceback:", exc_info=True)
65
+ raise typer.Exit(code=1) from e
66
+
67
+
68
+ async def compact_conversation() -> dict[str, Any]:
69
+ """Compact the conversation and return statistics.
70
+
71
+ Returns:
72
+ Dictionary with compaction statistics including before/after metrics
73
+ """
74
+ # Get conversation file path
75
+ conversation_file = Path.home() / ".shotgun-sh" / "conversation.json"
76
+
77
+ if not conversation_file.exists():
78
+ raise FileNotFoundError(f"Conversation file not found at {conversation_file}")
79
+
80
+ # Load conversation
81
+ manager = ConversationManager(conversation_file)
82
+ conversation = await manager.load()
83
+
84
+ if not conversation:
85
+ raise ValueError("Conversation file is empty or corrupted")
86
+
87
+ # Get agent messages only (not UI messages)
88
+ agent_messages = conversation.get_agent_messages()
89
+
90
+ if not agent_messages:
91
+ raise ValueError("No agent messages found in conversation")
92
+
93
+ # Get model config
94
+ model_config = await get_provider_model()
95
+
96
+ # Calculate before metrics
97
+ original_message_count = len(agent_messages)
98
+ original_tokens = await estimate_tokens_from_messages(agent_messages, model_config)
99
+
100
+ # For CLI, we can call token_limit_compactor directly without full AgentDeps
101
+ # since we only need the model config and message history
102
+ # Create a minimal context object for compaction
103
+ class CompactContext:
104
+ def __init__(self, model_config: Any, usage: RequestUsage) -> None:
105
+ self.deps = type("Deps", (), {"llm_model": model_config})()
106
+ self.usage = usage
107
+
108
+ # Create minimal usage info for compaction check
109
+ usage = RequestUsage(input_tokens=original_tokens, output_tokens=0)
110
+ ctx = CompactContext(model_config, usage)
111
+
112
+ # Apply compaction with force=True to bypass threshold checks
113
+ compacted_messages = await token_limit_compactor(ctx, agent_messages, force=True)
114
+
115
+ # Calculate after metrics
116
+ compacted_message_count = len(compacted_messages)
117
+ compacted_tokens = await estimate_tokens_from_messages(
118
+ compacted_messages, model_config
119
+ )
120
+
121
+ # Calculate reduction percentages
122
+ message_reduction = (
123
+ ((original_message_count - compacted_message_count) / original_message_count)
124
+ * 100
125
+ if original_message_count > 0
126
+ else 0
127
+ )
128
+ token_reduction = (
129
+ ((original_tokens - compacted_tokens) / original_tokens) * 100
130
+ if original_tokens > 0
131
+ else 0
132
+ )
133
+
134
+ # Save compacted conversation
135
+ conversation.set_agent_messages(compacted_messages)
136
+ await manager.save(conversation)
137
+
138
+ logger.info(
139
+ f"Compacted conversation: {original_message_count} → {compacted_message_count} messages "
140
+ f"({message_reduction:.1f}% reduction)"
141
+ )
142
+
143
+ return {
144
+ "success": True,
145
+ "before": {
146
+ "messages": original_message_count,
147
+ "estimated_tokens": original_tokens,
148
+ },
149
+ "after": {
150
+ "messages": compacted_message_count,
151
+ "estimated_tokens": compacted_tokens,
152
+ },
153
+ "reduction": {
154
+ "messages_percent": round(message_reduction, 1),
155
+ "tokens_percent": round(token_reduction, 1),
156
+ },
157
+ }
158
+
159
+
160
+ def format_markdown(result: dict[str, Any]) -> str:
161
+ """Format compaction result as markdown.
162
+
163
+ Args:
164
+ result: Dictionary with compaction statistics
165
+
166
+ Returns:
167
+ Formatted markdown string
168
+ """
169
+ before = result["before"]
170
+ after = result["after"]
171
+ reduction = result["reduction"]
172
+
173
+ return f"""# Conversation Compacted ✓
174
+
175
+ ## Before
176
+ - **Messages:** {before["messages"]:,}
177
+ - **Estimated Tokens:** {before["estimated_tokens"]:,}
178
+
179
+ ## After
180
+ - **Messages:** {after["messages"]:,}
181
+ - **Estimated Tokens:** {after["estimated_tokens"]:,}
182
+
183
+ ## Reduction
184
+ - **Messages:** {reduction["messages_percent"]}%
185
+ - **Tokens:** {reduction["tokens_percent"]}%
186
+ """