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.
- shotgun/agents/agent_manager.py +715 -75
- shotgun/agents/common.py +80 -75
- shotgun/agents/config/constants.py +21 -10
- shotgun/agents/config/manager.py +322 -97
- shotgun/agents/config/models.py +114 -84
- shotgun/agents/config/provider.py +232 -88
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +471 -0
- shotgun/agents/context_analyzer/constants.py +9 -0
- shotgun/agents/context_analyzer/formatter.py +115 -0
- shotgun/agents/context_analyzer/models.py +212 -0
- shotgun/agents/conversation_history.py +125 -2
- shotgun/agents/conversation_manager.py +57 -19
- shotgun/agents/export.py +6 -7
- shotgun/agents/history/compaction.py +10 -5
- shotgun/agents/history/context_extraction.py +93 -6
- shotgun/agents/history/history_processors.py +129 -12
- shotgun/agents/history/token_counting/__init__.py +31 -0
- shotgun/agents/history/token_counting/anthropic.py +127 -0
- shotgun/agents/history/token_counting/base.py +78 -0
- shotgun/agents/history/token_counting/openai.py +90 -0
- shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
- shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
- shotgun/agents/history/token_counting/utils.py +144 -0
- shotgun/agents/history/token_estimation.py +12 -12
- shotgun/agents/llm.py +62 -0
- shotgun/agents/models.py +59 -4
- shotgun/agents/plan.py +6 -7
- shotgun/agents/research.py +7 -8
- shotgun/agents/specify.py +6 -7
- shotgun/agents/tasks.py +6 -7
- shotgun/agents/tools/__init__.py +0 -2
- shotgun/agents/tools/codebase/codebase_shell.py +6 -0
- shotgun/agents/tools/codebase/directory_lister.py +6 -0
- shotgun/agents/tools/codebase/file_read.py +11 -2
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +82 -16
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +55 -16
- shotgun/agents/tools/web_search/anthropic.py +76 -51
- shotgun/agents/tools/web_search/gemini.py +50 -27
- shotgun/agents/tools/web_search/openai.py +26 -17
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +164 -0
- shotgun/api_endpoints.py +15 -0
- shotgun/cli/clear.py +53 -0
- shotgun/cli/compact.py +186 -0
- shotgun/cli/config.py +41 -67
- shotgun/cli/context.py +111 -0
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +50 -0
- shotgun/cli/models.py +3 -2
- shotgun/cli/plan.py +1 -1
- shotgun/cli/research.py +1 -1
- shotgun/cli/specify.py +1 -1
- shotgun/cli/tasks.py +1 -1
- shotgun/cli/update.py +16 -2
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +57 -16
- shotgun/codebase/core/manager.py +20 -7
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/codebase/models.py +4 -4
- shotgun/exceptions.py +32 -0
- shotgun/llm_proxy/__init__.py +19 -0
- shotgun/llm_proxy/clients.py +44 -0
- shotgun/llm_proxy/constants.py +15 -0
- shotgun/logging_config.py +18 -27
- shotgun/main.py +91 -12
- shotgun/posthog_telemetry.py +81 -10
- shotgun/prompts/agents/export.j2 +18 -1
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
- shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
- shotgun/prompts/agents/plan.j2 +1 -1
- shotgun/prompts/agents/research.j2 +1 -1
- shotgun/prompts/agents/specify.j2 +270 -3
- shotgun/prompts/agents/state/system_state.j2 +4 -0
- shotgun/prompts/agents/tasks.j2 +1 -1
- shotgun/prompts/loader.py +2 -2
- shotgun/prompts/tools/web_search.j2 +14 -0
- shotgun/sentry_telemetry.py +27 -18
- shotgun/settings.py +238 -0
- shotgun/shotgun_web/__init__.py +19 -0
- shotgun/shotgun_web/client.py +138 -0
- shotgun/shotgun_web/constants.py +21 -0
- shotgun/shotgun_web/models.py +47 -0
- shotgun/telemetry.py +24 -36
- shotgun/tui/app.py +251 -23
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +179 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/containers.py +91 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/protocols.py +45 -0
- shotgun/tui/screens/chat/__init__.py +5 -0
- shotgun/tui/screens/chat/chat.tcss +54 -0
- shotgun/tui/screens/chat/chat_screen.py +1234 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
- shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
- shotgun/tui/screens/chat/help_text.py +40 -0
- shotgun/tui/screens/chat/prompt_history.py +48 -0
- shotgun/tui/screens/chat.tcss +11 -0
- shotgun/tui/screens/chat_screen/command_providers.py +226 -11
- shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
- shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
- shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
- shotgun/tui/screens/confirmation_dialog.py +151 -0
- shotgun/tui/screens/feedback.py +193 -0
- shotgun/tui/screens/github_issue.py +102 -0
- shotgun/tui/screens/model_picker.py +352 -0
- shotgun/tui/screens/onboarding.py +431 -0
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/provider_config.py +156 -39
- shotgun/tui/screens/shotgun_auth.py +295 -0
- shotgun/tui/screens/welcome.py +198 -0
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +184 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/utils/mode_progress.py +14 -7
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +262 -0
- shotgun/utils/datetime_utils.py +77 -0
- shotgun/utils/env_utils.py +13 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.2.11.dist-info/METADATA +130 -0
- shotgun_sh-0.2.11.dist-info/RECORD +194 -0
- {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
- shotgun/agents/history/token_counting.py +0 -429
- shotgun/agents/tools/user_interaction.py +0 -37
- shotgun/tui/screens/chat.py +0 -797
- shotgun/tui/screens/chat_screen/history.py +0 -350
- shotgun_sh-0.1.14.dist-info/METADATA +0 -466
- shotgun_sh-0.1.14.dist-info/RECORD +0 -133
- {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
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
Query: {query}
|
|
54
|
+
# Get datetime context for the search prompt
|
|
55
|
+
dt_context = get_datetime_context()
|
|
46
56
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 =
|
|
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
|
shotgun/api_endpoints.py
ADDED
|
@@ -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
|
+
"""
|