shotgun-sh 0.2.11.dev7__py3-none-any.whl → 0.2.23.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.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/agent_manager.py +25 -11
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/manager.py +287 -32
- shotgun/agents/config/models.py +26 -1
- shotgun/agents/config/provider.py +27 -0
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/history/token_counting/anthropic.py +8 -0
- shotgun/agents/runner.py +230 -0
- shotgun/build_constants.py +1 -1
- shotgun/cli/context.py +43 -0
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +17 -9
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/exceptions.py +323 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -0
- shotgun/logging_config.py +42 -0
- shotgun/main.py +2 -0
- shotgun/posthog_telemetry.py +18 -25
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
- shotgun/sdk/codebase.py +14 -3
- shotgun/sentry_telemetry.py +140 -2
- shotgun/settings.py +5 -0
- shotgun/tui/app.py +35 -10
- shotgun/tui/screens/chat/chat_screen.py +192 -91
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +62 -11
- shotgun/tui/screens/chat_screen/command_providers.py +3 -2
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/chat_screen/history/chat_history.py +37 -2
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +10 -3
- shotgun/tui/screens/github_issue.py +11 -2
- shotgun/tui/screens/model_picker.py +8 -1
- shotgun/tui/screens/pipx_migration.py +12 -6
- shotgun/tui/screens/provider_config.py +25 -8
- shotgun/tui/screens/shotgun_auth.py +0 -10
- shotgun/tui/screens/welcome.py +32 -0
- shotgun/tui/widgets/widget_coordinator.py +3 -2
- shotgun_sh-0.2.23.dev1.dist-info/METADATA +472 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/RECORD +50 -42
- shotgun_sh-0.2.11.dev7.dist-info/METADATA +0 -130
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.23.dev1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""HTTP client for LiteLLM Proxy API."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from tenacity import (
|
|
8
|
+
before_sleep_log,
|
|
9
|
+
retry,
|
|
10
|
+
retry_if_exception,
|
|
11
|
+
stop_after_attempt,
|
|
12
|
+
wait_exponential_jitter,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from shotgun.api_endpoints import LITELLM_PROXY_BASE_URL
|
|
16
|
+
from shotgun.logging_config import get_logger
|
|
17
|
+
|
|
18
|
+
from .models import BudgetInfo, KeyInfoResponse, TeamInfoResponse
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_retryable_http_error(exception: BaseException) -> bool:
|
|
24
|
+
"""Check if HTTP exception should trigger a retry.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
exception: The exception to check
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
True if the exception is a transient error that should be retried
|
|
31
|
+
"""
|
|
32
|
+
# Retry on network errors and timeouts
|
|
33
|
+
if isinstance(exception, (httpx.RequestError, httpx.TimeoutException)):
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
# Retry on server errors (5xx) and rate limits (429)
|
|
37
|
+
if isinstance(exception, httpx.HTTPStatusError):
|
|
38
|
+
status_code = exception.response.status_code
|
|
39
|
+
return status_code >= 500 or status_code == 429
|
|
40
|
+
|
|
41
|
+
# Don't retry on other errors (e.g., 4xx client errors)
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LiteLLMProxyClient:
|
|
46
|
+
"""HTTP client for LiteLLM Proxy API.
|
|
47
|
+
|
|
48
|
+
Provides methods to query budget information and key/team metadata
|
|
49
|
+
from a LiteLLM proxy server.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
api_key: str,
|
|
55
|
+
base_url: str | None = None,
|
|
56
|
+
timeout: float = 10.0,
|
|
57
|
+
):
|
|
58
|
+
"""Initialize LiteLLM Proxy client.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
api_key: LiteLLM API key for authentication
|
|
62
|
+
base_url: Base URL for LiteLLM proxy. If None, uses LITELLM_PROXY_BASE_URL
|
|
63
|
+
timeout: Request timeout in seconds
|
|
64
|
+
"""
|
|
65
|
+
self.api_key = api_key
|
|
66
|
+
self.base_url = base_url or LITELLM_PROXY_BASE_URL
|
|
67
|
+
self.timeout = timeout
|
|
68
|
+
|
|
69
|
+
@retry(
|
|
70
|
+
stop=stop_after_attempt(3),
|
|
71
|
+
wait=wait_exponential_jitter(initial=1, max=8),
|
|
72
|
+
retry=retry_if_exception(_is_retryable_http_error),
|
|
73
|
+
before_sleep=before_sleep_log(logger, logging.WARNING),
|
|
74
|
+
reraise=True,
|
|
75
|
+
)
|
|
76
|
+
async def _request_with_retry(
|
|
77
|
+
self,
|
|
78
|
+
method: str,
|
|
79
|
+
url: str,
|
|
80
|
+
**kwargs: Any,
|
|
81
|
+
) -> httpx.Response:
|
|
82
|
+
"""Make async HTTP request with exponential backoff retry and jitter.
|
|
83
|
+
|
|
84
|
+
Uses tenacity to retry on transient errors (5xx, 429, network errors)
|
|
85
|
+
with exponential backoff and jitter. Client errors (4xx except 429)
|
|
86
|
+
are not retried.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
method: HTTP method (GET, POST, etc.)
|
|
90
|
+
url: Request URL
|
|
91
|
+
**kwargs: Additional arguments to pass to httpx request
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
HTTP response
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
httpx.HTTPError: If request fails after all retries
|
|
98
|
+
"""
|
|
99
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
100
|
+
response = await client.request(method, url, **kwargs)
|
|
101
|
+
response.raise_for_status()
|
|
102
|
+
return response
|
|
103
|
+
|
|
104
|
+
async def get_key_info(self) -> KeyInfoResponse:
|
|
105
|
+
"""Get key information from LiteLLM proxy.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Key information including spend, budget, and team_id
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
httpx.HTTPError: If request fails
|
|
112
|
+
"""
|
|
113
|
+
url = f"{self.base_url}/key/info"
|
|
114
|
+
params = {"key": self.api_key}
|
|
115
|
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
116
|
+
|
|
117
|
+
logger.debug("Fetching key info from %s", url)
|
|
118
|
+
|
|
119
|
+
response = await self._request_with_retry(
|
|
120
|
+
"GET", url, params=params, headers=headers
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
data = response.json()
|
|
124
|
+
result = KeyInfoResponse.model_validate(data)
|
|
125
|
+
|
|
126
|
+
logger.info(
|
|
127
|
+
"Successfully fetched key info: key_alias=%s, team_id=%s",
|
|
128
|
+
result.info.key_alias,
|
|
129
|
+
result.info.team_id,
|
|
130
|
+
)
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
async def get_team_info(self, team_id: str) -> TeamInfoResponse:
|
|
134
|
+
"""Get team information from LiteLLM proxy.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
team_id: Team identifier
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Team information including spend and budget
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
httpx.HTTPError: If request fails
|
|
144
|
+
"""
|
|
145
|
+
url = f"{self.base_url}/team/info"
|
|
146
|
+
params = {"team_id": team_id}
|
|
147
|
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
148
|
+
|
|
149
|
+
logger.debug("Fetching team info from %s for team_id=%s", url, team_id)
|
|
150
|
+
|
|
151
|
+
response = await self._request_with_retry(
|
|
152
|
+
"GET", url, params=params, headers=headers
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
data = response.json()
|
|
156
|
+
result = TeamInfoResponse.model_validate(data)
|
|
157
|
+
|
|
158
|
+
logger.info(
|
|
159
|
+
"Successfully fetched team info: team_alias=%s",
|
|
160
|
+
result.team_info.team_alias,
|
|
161
|
+
)
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
async def get_budget_info(self) -> BudgetInfo:
|
|
165
|
+
"""Get team-level budget information for this key.
|
|
166
|
+
|
|
167
|
+
Budget is always configured at the team level, never at the key level.
|
|
168
|
+
This method fetches the team_id from the key info, then retrieves
|
|
169
|
+
the team's budget information.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Team-level budget information
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
httpx.HTTPError: If request fails
|
|
176
|
+
ValueError: If team has no budget configured
|
|
177
|
+
"""
|
|
178
|
+
logger.debug("Fetching budget info")
|
|
179
|
+
|
|
180
|
+
# Get key info to retrieve team_id
|
|
181
|
+
key_response = await self.get_key_info()
|
|
182
|
+
key_info = key_response.info
|
|
183
|
+
|
|
184
|
+
# Fetch team budget (budget is always at team level)
|
|
185
|
+
logger.debug(
|
|
186
|
+
"Fetching team budget for team_id=%s",
|
|
187
|
+
key_info.team_id,
|
|
188
|
+
)
|
|
189
|
+
team_response = await self.get_team_info(key_info.team_id)
|
|
190
|
+
team_info = team_response.team_info
|
|
191
|
+
|
|
192
|
+
if team_info.max_budget is None:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"Team (team_id={key_info.team_id}) has no max_budget configured"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
logger.debug("Using team-level budget: $%.6f", team_info.max_budget)
|
|
198
|
+
return BudgetInfo.from_team_info(team_info)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# Convenience function for standalone use
|
|
202
|
+
async def get_budget_info(api_key: str, base_url: str | None = None) -> BudgetInfo:
|
|
203
|
+
"""Get budget information for an API key.
|
|
204
|
+
|
|
205
|
+
Convenience function that creates a client and calls get_budget_info.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
api_key: LiteLLM API key
|
|
209
|
+
base_url: Optional base URL for LiteLLM proxy
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Budget information
|
|
213
|
+
"""
|
|
214
|
+
client = LiteLLMProxyClient(api_key, base_url=base_url)
|
|
215
|
+
return await client.get_budget_info()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Pydantic models for LiteLLM Proxy API."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BudgetSource(StrEnum):
|
|
9
|
+
"""Source of budget information."""
|
|
10
|
+
|
|
11
|
+
KEY = "key"
|
|
12
|
+
TEAM = "team"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class KeyInfoData(BaseModel):
|
|
16
|
+
"""Key information data from /key/info endpoint."""
|
|
17
|
+
|
|
18
|
+
key_name: str = Field(description="Key name/identifier")
|
|
19
|
+
key_alias: str | None = Field(default=None, description="Human-readable key alias")
|
|
20
|
+
spend: float = Field(description="Current spend for this key in USD")
|
|
21
|
+
max_budget: float | None = Field(
|
|
22
|
+
default=None, description="Maximum budget for this key in USD"
|
|
23
|
+
)
|
|
24
|
+
team_id: str = Field(description="Team ID associated with this key")
|
|
25
|
+
user_id: str = Field(description="User ID associated with this key")
|
|
26
|
+
models: list[str] = Field(
|
|
27
|
+
default_factory=list, description="List of models available to this key"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class KeyInfoResponse(BaseModel):
|
|
32
|
+
"""Response from /key/info endpoint."""
|
|
33
|
+
|
|
34
|
+
key: str = Field(description="The API key")
|
|
35
|
+
info: KeyInfoData = Field(description="Key information data")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TeamInfoData(BaseModel):
|
|
39
|
+
"""Team information data from /team/info endpoint."""
|
|
40
|
+
|
|
41
|
+
team_id: str = Field(description="Team identifier")
|
|
42
|
+
team_alias: str | None = Field(
|
|
43
|
+
default=None, description="Human-readable team alias"
|
|
44
|
+
)
|
|
45
|
+
max_budget: float | None = Field(
|
|
46
|
+
default=None, description="Maximum budget for this team in USD"
|
|
47
|
+
)
|
|
48
|
+
spend: float = Field(description="Current spend for this team in USD")
|
|
49
|
+
models: list[str] = Field(
|
|
50
|
+
default_factory=list, description="List of models available to this team"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TeamInfoResponse(BaseModel):
|
|
55
|
+
"""Response from /team/info endpoint."""
|
|
56
|
+
|
|
57
|
+
team_id: str = Field(description="Team identifier")
|
|
58
|
+
team_info: TeamInfoData = Field(description="Team information data")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BudgetInfo(BaseModel):
|
|
62
|
+
"""Unified budget information.
|
|
63
|
+
|
|
64
|
+
Combines key and team budget information to provide a single view
|
|
65
|
+
of budget status. Budget can come from either key-level or team-level,
|
|
66
|
+
with key-level taking priority if set.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
max_budget: float = Field(description="Maximum budget in USD")
|
|
70
|
+
spend: float = Field(description="Current spend in USD")
|
|
71
|
+
remaining: float = Field(description="Remaining budget in USD")
|
|
72
|
+
source: BudgetSource = Field(
|
|
73
|
+
description="Source of budget information (key or team)"
|
|
74
|
+
)
|
|
75
|
+
percentage_used: float = Field(description="Percentage of budget used (0-100)")
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_key_info(cls, key_info: KeyInfoData) -> "BudgetInfo":
|
|
79
|
+
"""Create BudgetInfo from key-level budget.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
key_info: Key information containing budget data
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
BudgetInfo instance with key-level budget
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ValueError: If key does not have max_budget set
|
|
89
|
+
"""
|
|
90
|
+
if key_info.max_budget is None:
|
|
91
|
+
raise ValueError("Key does not have max_budget set")
|
|
92
|
+
|
|
93
|
+
remaining = key_info.max_budget - key_info.spend
|
|
94
|
+
percentage_used = (
|
|
95
|
+
(key_info.spend / key_info.max_budget * 100)
|
|
96
|
+
if key_info.max_budget > 0
|
|
97
|
+
else 0.0
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return cls(
|
|
101
|
+
max_budget=key_info.max_budget,
|
|
102
|
+
spend=key_info.spend,
|
|
103
|
+
remaining=remaining,
|
|
104
|
+
source=BudgetSource.KEY,
|
|
105
|
+
percentage_used=percentage_used,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def from_team_info(cls, team_info: TeamInfoData) -> "BudgetInfo":
|
|
110
|
+
"""Create BudgetInfo from team-level budget.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
team_info: Team information containing budget data
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
BudgetInfo instance with team-level budget
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
ValueError: If team does not have max_budget set
|
|
120
|
+
"""
|
|
121
|
+
if team_info.max_budget is None:
|
|
122
|
+
raise ValueError("Team does not have max_budget set")
|
|
123
|
+
|
|
124
|
+
remaining = team_info.max_budget - team_info.spend
|
|
125
|
+
percentage_used = (
|
|
126
|
+
(team_info.spend / team_info.max_budget * 100)
|
|
127
|
+
if team_info.max_budget > 0
|
|
128
|
+
else 0.0
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return cls(
|
|
132
|
+
max_budget=team_info.max_budget,
|
|
133
|
+
spend=team_info.spend,
|
|
134
|
+
remaining=remaining,
|
|
135
|
+
source=BudgetSource.TEAM,
|
|
136
|
+
percentage_used=percentage_used,
|
|
137
|
+
)
|
shotgun/logging_config.py
CHANGED
|
@@ -27,6 +27,44 @@ def get_log_directory() -> Path:
|
|
|
27
27
|
return log_dir
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def cleanup_old_log_files(log_dir: Path, max_files: int) -> None:
|
|
31
|
+
"""Remove old log files, keeping only the most recent ones.
|
|
32
|
+
|
|
33
|
+
Also removes the legacy shotgun.log file if it exists.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
log_dir: Directory containing log files
|
|
37
|
+
max_files: Maximum number of log files to keep
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
# Remove legacy non-timestamped log file if it exists
|
|
41
|
+
legacy_log = log_dir / "shotgun.log"
|
|
42
|
+
if legacy_log.exists():
|
|
43
|
+
try:
|
|
44
|
+
legacy_log.unlink()
|
|
45
|
+
except OSError:
|
|
46
|
+
pass # noqa: S110
|
|
47
|
+
|
|
48
|
+
# Find all shotgun log files
|
|
49
|
+
log_files = sorted(
|
|
50
|
+
log_dir.glob("shotgun-*.log"),
|
|
51
|
+
key=lambda p: p.stat().st_mtime,
|
|
52
|
+
reverse=True, # Newest first
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Remove files beyond the limit
|
|
56
|
+
files_to_delete = log_files[max_files:]
|
|
57
|
+
for log_file in files_to_delete:
|
|
58
|
+
try:
|
|
59
|
+
log_file.unlink()
|
|
60
|
+
except OSError:
|
|
61
|
+
# Ignore errors when deleting individual files
|
|
62
|
+
pass # noqa: S110
|
|
63
|
+
except Exception: # noqa: S110
|
|
64
|
+
# Silently fail - log cleanup shouldn't break the application
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
30
68
|
class ColoredFormatter(logging.Formatter):
|
|
31
69
|
"""Custom formatter with colors for different log levels."""
|
|
32
70
|
|
|
@@ -123,6 +161,10 @@ def setup_logger(
|
|
|
123
161
|
try:
|
|
124
162
|
# Create file handler with ISO8601 timestamp for each run
|
|
125
163
|
log_dir = get_log_directory()
|
|
164
|
+
|
|
165
|
+
# Clean up old log files before creating a new one
|
|
166
|
+
cleanup_old_log_files(log_dir, settings.logging.max_log_files)
|
|
167
|
+
|
|
126
168
|
log_file = log_dir / f"shotgun-{_RUN_TIMESTAMP}.log"
|
|
127
169
|
|
|
128
170
|
# Use regular FileHandler - each run gets its own isolated log file
|
shotgun/main.py
CHANGED
|
@@ -55,6 +55,8 @@ logger = get_logger(__name__)
|
|
|
55
55
|
logger.debug("Logfire observability enabled: %s", _logfire_enabled)
|
|
56
56
|
|
|
57
57
|
# Initialize configuration
|
|
58
|
+
# Note: If config migration fails, ConfigManager will auto-create fresh config
|
|
59
|
+
# and set migration_failed flag for user notification
|
|
58
60
|
try:
|
|
59
61
|
import asyncio
|
|
60
62
|
|
shotgun/posthog_telemetry.py
CHANGED
|
@@ -18,6 +18,9 @@ logger = get_early_logger(__name__)
|
|
|
18
18
|
# Global PostHog client instance
|
|
19
19
|
_posthog_client = None
|
|
20
20
|
|
|
21
|
+
# Cache the shotgun instance ID to avoid async calls during event tracking
|
|
22
|
+
_shotgun_instance_id: str | None = None
|
|
23
|
+
|
|
21
24
|
|
|
22
25
|
def setup_posthog_observability() -> bool:
|
|
23
26
|
"""Set up PostHog analytics for usage tracking.
|
|
@@ -25,7 +28,7 @@ def setup_posthog_observability() -> bool:
|
|
|
25
28
|
Returns:
|
|
26
29
|
True if PostHog was successfully set up, False otherwise
|
|
27
30
|
"""
|
|
28
|
-
global _posthog_client
|
|
31
|
+
global _posthog_client, _shotgun_instance_id
|
|
29
32
|
|
|
30
33
|
try:
|
|
31
34
|
# Check if PostHog is already initialized
|
|
@@ -57,31 +60,20 @@ def setup_posthog_observability() -> bool:
|
|
|
57
60
|
# Store the client for later use
|
|
58
61
|
_posthog_client = posthog
|
|
59
62
|
|
|
60
|
-
#
|
|
63
|
+
# Cache the shotgun instance ID for later use (avoids async issues)
|
|
61
64
|
try:
|
|
62
65
|
import asyncio
|
|
63
66
|
|
|
64
67
|
config_manager = get_config_manager()
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
# Identify the user in PostHog
|
|
68
|
-
posthog.identify( # type: ignore[attr-defined]
|
|
69
|
-
distinct_id=shotgun_instance_id,
|
|
70
|
-
properties={
|
|
71
|
-
"version": __version__,
|
|
72
|
-
"environment": environment,
|
|
73
|
-
},
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
# Set default properties for all events
|
|
77
|
-
posthog.disabled = False
|
|
78
|
-
posthog.personal_api_key = None # Not needed for event tracking
|
|
68
|
+
_shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
|
|
79
69
|
|
|
80
70
|
logger.debug(
|
|
81
|
-
"PostHog
|
|
71
|
+
"PostHog initialized with shotgun instance ID: %s",
|
|
72
|
+
_shotgun_instance_id,
|
|
82
73
|
)
|
|
83
74
|
except Exception as e:
|
|
84
|
-
logger.warning("Failed to
|
|
75
|
+
logger.warning("Failed to load shotgun instance ID: %s", e)
|
|
76
|
+
# Continue anyway - we'll try to get it during event tracking
|
|
85
77
|
|
|
86
78
|
logger.debug(
|
|
87
79
|
"PostHog analytics configured successfully (environment: %s, version: %s)",
|
|
@@ -102,18 +94,19 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
|
|
|
102
94
|
event_name: Name of the event to track
|
|
103
95
|
properties: Optional properties to include with the event
|
|
104
96
|
"""
|
|
105
|
-
global _posthog_client
|
|
97
|
+
global _posthog_client, _shotgun_instance_id
|
|
106
98
|
|
|
107
99
|
if _posthog_client is None:
|
|
108
100
|
logger.debug("PostHog not initialized, skipping event: %s", event_name)
|
|
109
101
|
return
|
|
110
102
|
|
|
111
103
|
try:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
104
|
+
# Use cached instance ID (loaded during setup)
|
|
105
|
+
if _shotgun_instance_id is None:
|
|
106
|
+
logger.warning(
|
|
107
|
+
"Shotgun instance ID not available, skipping event: %s", event_name
|
|
108
|
+
)
|
|
109
|
+
return
|
|
117
110
|
|
|
118
111
|
# Add version and environment to properties
|
|
119
112
|
if properties is None:
|
|
@@ -128,7 +121,7 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
|
|
|
128
121
|
|
|
129
122
|
# Track the event using PostHog's capture method
|
|
130
123
|
_posthog_client.capture(
|
|
131
|
-
distinct_id=
|
|
124
|
+
distinct_id=_shotgun_instance_id, event=event_name, properties=properties
|
|
132
125
|
)
|
|
133
126
|
logger.debug("Tracked PostHog event: %s", event_name)
|
|
134
127
|
except Exception as e:
|
|
@@ -7,10 +7,11 @@ Your extensive expertise spans, among other things:
|
|
|
7
7
|
## KEY RULES
|
|
8
8
|
|
|
9
9
|
{% if interactive_mode %}
|
|
10
|
-
0. Always ask CLARIFYING QUESTIONS using structured output
|
|
10
|
+
0. Always ask CLARIFYING QUESTIONS using structured output before doing work.
|
|
11
11
|
- Return your response with the clarifying_questions field populated
|
|
12
|
-
- Do not make assumptions about what the user wants
|
|
12
|
+
- Do not make assumptions about what the user wants, get a clear understanding first.
|
|
13
13
|
- Questions should be clear, specific, and answerable
|
|
14
|
+
- Do not ask too many questions that might overwhelm the user; prioritize the most important ones.
|
|
14
15
|
{% endif %}
|
|
15
16
|
1. Above all, prefer using tools to do the work and NEVER respond with text.
|
|
16
17
|
2. IMPORTANT: Always ask for review and go ahead to move forward after using write_file().
|
shotgun/sdk/codebase.py
CHANGED
|
@@ -93,6 +93,19 @@ class CodebaseSDK:
|
|
|
93
93
|
if indexed_from_cwd is None:
|
|
94
94
|
indexed_from_cwd = str(Path.cwd().resolve())
|
|
95
95
|
|
|
96
|
+
# Track codebase indexing started event
|
|
97
|
+
source = detect_source()
|
|
98
|
+
logger.debug(
|
|
99
|
+
"Tracking codebase_index_started event: source=%s",
|
|
100
|
+
source,
|
|
101
|
+
)
|
|
102
|
+
track_event(
|
|
103
|
+
"codebase_index_started",
|
|
104
|
+
{
|
|
105
|
+
"source": source,
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
|
|
96
109
|
graph = await self.service.create_graph(
|
|
97
110
|
resolved_path,
|
|
98
111
|
name,
|
|
@@ -101,9 +114,7 @@ class CodebaseSDK:
|
|
|
101
114
|
)
|
|
102
115
|
file_count = sum(graph.language_stats.values()) if graph.language_stats else 0
|
|
103
116
|
|
|
104
|
-
# Track codebase indexing event
|
|
105
|
-
# Detect if called from TUI by checking the call stack
|
|
106
|
-
source = detect_source()
|
|
117
|
+
# Track codebase indexing completion event (reuse source from start event)
|
|
107
118
|
|
|
108
119
|
logger.debug(
|
|
109
120
|
"Tracking codebase_indexed event: file_count=%d, node_count=%d, relationship_count=%d, source=%s",
|