shotgun-sh 0.2.11.dev7__py3-none-any.whl → 0.2.19__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 +22 -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 +17 -1
- shotgun/agents/config/provider.py +27 -0
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/build_constants.py +3 -3
- 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/sentry_telemetry.py +140 -2
- shotgun/settings.py +5 -0
- shotgun/tui/app.py +7 -1
- shotgun/tui/screens/chat/chat_screen.py +66 -35
- shotgun/tui/screens/chat_screen/command_providers.py +3 -2
- shotgun/tui/screens/chat_screen/history/chat_history.py +1 -2
- shotgun/tui/screens/directory_setup.py +14 -5
- 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.19.dist-info/METADATA +465 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/RECORD +32 -30
- shotgun_sh-0.2.11.dev7.dist-info/METADATA +0 -130
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Utility for testing streaming capability of OpenAI models."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
# Maximum number of attempts to test streaming capability
|
|
10
|
+
MAX_STREAMING_TEST_ATTEMPTS = 3
|
|
11
|
+
|
|
12
|
+
# Timeout for each streaming test attempt (in seconds)
|
|
13
|
+
STREAMING_TEST_TIMEOUT = 10.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def check_streaming_capability(
|
|
17
|
+
api_key: str, model_name: str, max_attempts: int = MAX_STREAMING_TEST_ATTEMPTS
|
|
18
|
+
) -> bool:
|
|
19
|
+
"""Check if the given OpenAI model supports streaming with this API key.
|
|
20
|
+
|
|
21
|
+
Retries multiple times to handle transient network issues. Only returns False
|
|
22
|
+
if streaming definitively fails after all retry attempts.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
api_key: The OpenAI API key to test
|
|
26
|
+
model_name: The model name (e.g., "gpt-5", "gpt-5-mini")
|
|
27
|
+
max_attempts: Maximum number of attempts (default: 3)
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
True if streaming is supported, False if it definitively fails
|
|
31
|
+
"""
|
|
32
|
+
url = "https://api.openai.com/v1/chat/completions"
|
|
33
|
+
headers = {
|
|
34
|
+
"Authorization": f"Bearer {api_key}",
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
}
|
|
37
|
+
# GPT-5 family uses max_completion_tokens instead of max_tokens
|
|
38
|
+
payload = {
|
|
39
|
+
"model": model_name,
|
|
40
|
+
"messages": [{"role": "user", "content": "test"}],
|
|
41
|
+
"stream": True,
|
|
42
|
+
"max_completion_tokens": 10,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
last_error = None
|
|
46
|
+
|
|
47
|
+
for attempt in range(1, max_attempts + 1):
|
|
48
|
+
logger.debug(
|
|
49
|
+
f"Streaming test attempt {attempt}/{max_attempts} for {model_name}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
async with httpx.AsyncClient(timeout=STREAMING_TEST_TIMEOUT) as client:
|
|
54
|
+
async with client.stream(
|
|
55
|
+
"POST", url, json=payload, headers=headers
|
|
56
|
+
) as response:
|
|
57
|
+
# Check if we get a successful response
|
|
58
|
+
if response.status_code != 200:
|
|
59
|
+
last_error = f"HTTP {response.status_code}"
|
|
60
|
+
logger.warning(
|
|
61
|
+
f"Streaming test attempt {attempt} failed for {model_name}: {last_error}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# For definitive errors (403 Forbidden, 404 Not Found), don't retry
|
|
65
|
+
if response.status_code in (403, 404):
|
|
66
|
+
logger.info(
|
|
67
|
+
f"Streaming definitively unsupported for {model_name} (HTTP {response.status_code})"
|
|
68
|
+
)
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
# For other errors, retry
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# Try to read at least one chunk from the stream
|
|
75
|
+
try:
|
|
76
|
+
async for _ in response.aiter_bytes():
|
|
77
|
+
# Successfully received streaming data
|
|
78
|
+
logger.info(
|
|
79
|
+
f"Streaming test passed for {model_name} (attempt {attempt})"
|
|
80
|
+
)
|
|
81
|
+
return True
|
|
82
|
+
except Exception as e:
|
|
83
|
+
last_error = str(e)
|
|
84
|
+
logger.warning(
|
|
85
|
+
f"Streaming test attempt {attempt} failed for {model_name} while reading stream: {e}"
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
except httpx.TimeoutException:
|
|
90
|
+
last_error = "timeout"
|
|
91
|
+
logger.warning(
|
|
92
|
+
f"Streaming test attempt {attempt} timed out for {model_name}"
|
|
93
|
+
)
|
|
94
|
+
continue
|
|
95
|
+
except httpx.HTTPStatusError as e:
|
|
96
|
+
last_error = str(e)
|
|
97
|
+
logger.warning(
|
|
98
|
+
f"Streaming test attempt {attempt} failed for {model_name}: {e}"
|
|
99
|
+
)
|
|
100
|
+
continue
|
|
101
|
+
except Exception as e:
|
|
102
|
+
last_error = str(e)
|
|
103
|
+
logger.warning(
|
|
104
|
+
f"Streaming test attempt {attempt} failed for {model_name} with unexpected error: {e}"
|
|
105
|
+
)
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# If we got here without reading any chunks, streaming didn't work
|
|
109
|
+
last_error = "no data received"
|
|
110
|
+
logger.warning(
|
|
111
|
+
f"Streaming test attempt {attempt} failed for {model_name}: no data received"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# All attempts exhausted
|
|
115
|
+
logger.error(
|
|
116
|
+
f"Streaming test failed for {model_name} after {max_attempts} attempts. "
|
|
117
|
+
f"Last error: {last_error}. Assuming streaming is NOT supported."
|
|
118
|
+
)
|
|
119
|
+
return False
|
shotgun/build_constants.py
CHANGED
|
@@ -8,12 +8,12 @@ DO NOT EDIT MANUALLY.
|
|
|
8
8
|
SENTRY_DSN = 'https://2818a6d165c64eccc94cfd51ce05d6aa@o4506813296738304.ingest.us.sentry.io/4510045952409600'
|
|
9
9
|
|
|
10
10
|
# PostHog configuration embedded at build time (empty strings if not provided)
|
|
11
|
-
POSTHOG_API_KEY = ''
|
|
11
|
+
POSTHOG_API_KEY = 'phc_KKnChzZUKeNqZDOTJ6soCBWNQSx3vjiULdwTR9H5Mcr'
|
|
12
12
|
POSTHOG_PROJECT_ID = '191396'
|
|
13
13
|
|
|
14
14
|
# Logfire configuration embedded at build time (only for dev builds)
|
|
15
|
-
LOGFIRE_ENABLED = '
|
|
16
|
-
LOGFIRE_TOKEN = '
|
|
15
|
+
LOGFIRE_ENABLED = ''
|
|
16
|
+
LOGFIRE_TOKEN = ''
|
|
17
17
|
|
|
18
18
|
# Build metadata
|
|
19
19
|
BUILD_TIME_ENV = "production" if SENTRY_DSN else "development"
|
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/sentry_telemetry.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Sentry observability setup for Shotgun."""
|
|
2
2
|
|
|
3
|
+
from pathlib import Path
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
5
6
|
from shotgun import __version__
|
|
@@ -10,6 +11,122 @@ from shotgun.settings import settings
|
|
|
10
11
|
logger = get_early_logger(__name__)
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
def _scrub_path(path: str) -> str:
|
|
15
|
+
"""Scrub sensitive information from file paths.
|
|
16
|
+
|
|
17
|
+
Removes home directory and current working directory prefixes to prevent
|
|
18
|
+
leaking usernames that might be part of the path.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
path: The file path to scrub
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The scrubbed path with sensitive prefixes removed
|
|
25
|
+
"""
|
|
26
|
+
if not path:
|
|
27
|
+
return path
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
# Get home and cwd as Path objects for comparison
|
|
31
|
+
home = Path.home()
|
|
32
|
+
cwd = Path.cwd()
|
|
33
|
+
|
|
34
|
+
# Convert path to Path object
|
|
35
|
+
path_obj = Path(path)
|
|
36
|
+
|
|
37
|
+
# Try to make path relative to cwd first (most common case)
|
|
38
|
+
try:
|
|
39
|
+
relative_to_cwd = path_obj.relative_to(cwd)
|
|
40
|
+
return str(relative_to_cwd)
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
# Try to replace home directory with ~
|
|
45
|
+
try:
|
|
46
|
+
relative_to_home = path_obj.relative_to(home)
|
|
47
|
+
return f"~/{relative_to_home}"
|
|
48
|
+
except ValueError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
# If path is absolute but not under cwd or home, just return filename
|
|
52
|
+
if path_obj.is_absolute():
|
|
53
|
+
return path_obj.name
|
|
54
|
+
|
|
55
|
+
# Return as-is if already relative
|
|
56
|
+
return path
|
|
57
|
+
|
|
58
|
+
except Exception:
|
|
59
|
+
# If anything goes wrong, return the original path
|
|
60
|
+
# Better to leak a path than break error reporting
|
|
61
|
+
return path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _scrub_sensitive_paths(event: dict[str, Any]) -> None:
|
|
65
|
+
"""Scrub sensitive paths from Sentry event data.
|
|
66
|
+
|
|
67
|
+
Modifies the event in-place to remove:
|
|
68
|
+
- Home directory paths (might contain usernames)
|
|
69
|
+
- Current working directory paths (might contain usernames)
|
|
70
|
+
- Server name/hostname
|
|
71
|
+
- Paths in sys.argv
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
event: The Sentry event dictionary to scrub
|
|
75
|
+
"""
|
|
76
|
+
extra = event.get("extra", {})
|
|
77
|
+
if "sys.argv" in extra:
|
|
78
|
+
argv = extra["sys.argv"]
|
|
79
|
+
if isinstance(argv, list):
|
|
80
|
+
extra["sys.argv"] = [
|
|
81
|
+
_scrub_path(arg) if isinstance(arg, str) else arg for arg in argv
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
# Scrub server name if present
|
|
85
|
+
if "server_name" in event:
|
|
86
|
+
event["server_name"] = ""
|
|
87
|
+
|
|
88
|
+
# Scrub contexts that might contain paths
|
|
89
|
+
if "contexts" in event:
|
|
90
|
+
contexts = event["contexts"]
|
|
91
|
+
# Remove runtime context if it has CWD
|
|
92
|
+
if "runtime" in contexts:
|
|
93
|
+
if "cwd" in contexts["runtime"]:
|
|
94
|
+
del contexts["runtime"]["cwd"]
|
|
95
|
+
# Scrub sys.argv to remove paths
|
|
96
|
+
if "sys.argv" in contexts["runtime"]:
|
|
97
|
+
argv = contexts["runtime"]["sys.argv"]
|
|
98
|
+
if isinstance(argv, list):
|
|
99
|
+
contexts["runtime"]["sys.argv"] = [
|
|
100
|
+
_scrub_path(arg) if isinstance(arg, str) else arg
|
|
101
|
+
for arg in argv
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
# Scrub exception stack traces
|
|
105
|
+
if "exception" in event and "values" in event["exception"]:
|
|
106
|
+
for exception in event["exception"]["values"]:
|
|
107
|
+
if "stacktrace" in exception and "frames" in exception["stacktrace"]:
|
|
108
|
+
for frame in exception["stacktrace"]["frames"]:
|
|
109
|
+
# Scrub file paths
|
|
110
|
+
if "abs_path" in frame:
|
|
111
|
+
frame["abs_path"] = _scrub_path(frame["abs_path"])
|
|
112
|
+
if "filename" in frame:
|
|
113
|
+
frame["filename"] = _scrub_path(frame["filename"])
|
|
114
|
+
|
|
115
|
+
# Scrub local variables that might contain paths
|
|
116
|
+
if "vars" in frame:
|
|
117
|
+
for var_name, var_value in frame["vars"].items():
|
|
118
|
+
if isinstance(var_value, str):
|
|
119
|
+
frame["vars"][var_name] = _scrub_path(var_value)
|
|
120
|
+
|
|
121
|
+
# Scrub breadcrumbs that might contain paths
|
|
122
|
+
if "breadcrumbs" in event and "values" in event["breadcrumbs"]:
|
|
123
|
+
for breadcrumb in event["breadcrumbs"]["values"]:
|
|
124
|
+
if "data" in breadcrumb:
|
|
125
|
+
for key, value in breadcrumb["data"].items():
|
|
126
|
+
if isinstance(value, str):
|
|
127
|
+
breadcrumb["data"][key] = _scrub_path(value)
|
|
128
|
+
|
|
129
|
+
|
|
13
130
|
def setup_sentry_observability() -> bool:
|
|
14
131
|
"""Set up Sentry observability for error tracking.
|
|
15
132
|
|
|
@@ -41,18 +158,38 @@ def setup_sentry_observability() -> bool:
|
|
|
41
158
|
environment = "production"
|
|
42
159
|
|
|
43
160
|
def before_send(event: Any, hint: dict[str, Any]) -> Any:
|
|
44
|
-
"""Filter out user-actionable errors
|
|
161
|
+
"""Filter out user-actionable errors and scrub sensitive paths.
|
|
45
162
|
|
|
46
163
|
User-actionable errors (like context size limits) are expected conditions
|
|
47
164
|
that users need to resolve, not bugs that need tracking.
|
|
165
|
+
|
|
166
|
+
Also scrubs sensitive information like usernames from file paths and
|
|
167
|
+
working directories to protect user privacy.
|
|
48
168
|
"""
|
|
169
|
+
|
|
170
|
+
log_record = hint.get("log_record")
|
|
171
|
+
if log_record:
|
|
172
|
+
# Scrub pathname using the helper function
|
|
173
|
+
log_record.pathname = _scrub_path(log_record.pathname)
|
|
174
|
+
|
|
175
|
+
# Scrub traceback text if it exists
|
|
176
|
+
if hasattr(log_record, "exc_text") and isinstance(
|
|
177
|
+
log_record.exc_text, str
|
|
178
|
+
):
|
|
179
|
+
# Replace home directory in traceback text
|
|
180
|
+
home = Path.home()
|
|
181
|
+
log_record.exc_text = log_record.exc_text.replace(str(home), "~")
|
|
182
|
+
|
|
49
183
|
if "exc_info" in hint:
|
|
50
|
-
|
|
184
|
+
_, exc_value, _ = hint["exc_info"]
|
|
51
185
|
from shotgun.exceptions import ErrorNotPickedUpBySentry
|
|
52
186
|
|
|
53
187
|
if isinstance(exc_value, ErrorNotPickedUpBySentry):
|
|
54
188
|
# Don't send to Sentry - this is user-actionable, not a bug
|
|
55
189
|
return None
|
|
190
|
+
|
|
191
|
+
# Scrub sensitive paths from the event
|
|
192
|
+
_scrub_sensitive_paths(event)
|
|
56
193
|
return event
|
|
57
194
|
|
|
58
195
|
# Initialize Sentry
|
|
@@ -61,6 +198,7 @@ def setup_sentry_observability() -> bool:
|
|
|
61
198
|
release=f"shotgun-sh@{__version__}",
|
|
62
199
|
environment=environment,
|
|
63
200
|
send_default_pii=False, # Privacy-first: never send PII
|
|
201
|
+
server_name="", # Privacy: don't send hostname (may contain username)
|
|
64
202
|
traces_sample_rate=0.1 if environment == "production" else 1.0,
|
|
65
203
|
profiles_sample_rate=0.1 if environment == "production" else 1.0,
|
|
66
204
|
before_send=before_send,
|
shotgun/settings.py
CHANGED
|
@@ -108,6 +108,11 @@ class LoggingSettings(BaseSettings):
|
|
|
108
108
|
default=True,
|
|
109
109
|
description="Enable file logging output",
|
|
110
110
|
)
|
|
111
|
+
max_log_files: int = Field(
|
|
112
|
+
default=10,
|
|
113
|
+
description="Maximum number of log files to keep (older files are deleted)",
|
|
114
|
+
ge=1,
|
|
115
|
+
)
|
|
111
116
|
|
|
112
117
|
model_config = SettingsConfigDict(
|
|
113
118
|
env_prefix="SHOTGUN_",
|
shotgun/tui/app.py
CHANGED
|
@@ -6,7 +6,10 @@ from textual.binding import Binding
|
|
|
6
6
|
from textual.screen import Screen
|
|
7
7
|
|
|
8
8
|
from shotgun.agents.agent_manager import AgentManager
|
|
9
|
-
from shotgun.agents.config import
|
|
9
|
+
from shotgun.agents.config import (
|
|
10
|
+
ConfigManager,
|
|
11
|
+
get_config_manager,
|
|
12
|
+
)
|
|
10
13
|
from shotgun.agents.models import AgentType
|
|
11
14
|
from shotgun.logging_config import get_logger
|
|
12
15
|
from shotgun.tui.containers import TUIContainer
|
|
@@ -99,7 +102,10 @@ class ShotgunApp(App[None]):
|
|
|
99
102
|
# Run async config loading in worker
|
|
100
103
|
async def _check_config() -> None:
|
|
101
104
|
# Show welcome screen if no providers are configured OR if user hasn't seen it yet
|
|
105
|
+
# Note: If config migration fails, ConfigManager will auto-create fresh config
|
|
106
|
+
# and set migration_failed flag, which WelcomeScreen will display
|
|
102
107
|
config = await self.config_manager.load()
|
|
108
|
+
|
|
103
109
|
has_any_key = await self.config_manager.has_any_provider_key()
|
|
104
110
|
if not has_any_key or not config.shown_welcome_screen:
|
|
105
111
|
if isinstance(self.screen, WelcomeScreen):
|