shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.2.17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. shotgun/agents/agent_manager.py +354 -46
  2. shotgun/agents/common.py +14 -8
  3. shotgun/agents/config/constants.py +0 -6
  4. shotgun/agents/config/manager.py +66 -35
  5. shotgun/agents/config/models.py +41 -1
  6. shotgun/agents/config/provider.py +33 -5
  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 +2 -0
  13. shotgun/agents/conversation_manager.py +35 -19
  14. shotgun/agents/export.py +2 -2
  15. shotgun/agents/history/compaction.py +9 -4
  16. shotgun/agents/history/history_processors.py +113 -5
  17. shotgun/agents/history/token_counting/anthropic.py +17 -1
  18. shotgun/agents/history/token_counting/base.py +14 -3
  19. shotgun/agents/history/token_counting/openai.py +11 -1
  20. shotgun/agents/history/token_counting/sentencepiece_counter.py +8 -0
  21. shotgun/agents/history/token_counting/tokenizer_cache.py +3 -1
  22. shotgun/agents/history/token_counting/utils.py +0 -3
  23. shotgun/agents/plan.py +2 -2
  24. shotgun/agents/research.py +3 -3
  25. shotgun/agents/specify.py +2 -2
  26. shotgun/agents/tasks.py +2 -2
  27. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  28. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  29. shotgun/agents/tools/codebase/file_read.py +11 -2
  30. shotgun/agents/tools/codebase/query_graph.py +6 -0
  31. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  32. shotgun/agents/tools/file_management.py +27 -7
  33. shotgun/agents/tools/registry.py +217 -0
  34. shotgun/agents/tools/web_search/__init__.py +8 -8
  35. shotgun/agents/tools/web_search/anthropic.py +8 -2
  36. shotgun/agents/tools/web_search/gemini.py +7 -1
  37. shotgun/agents/tools/web_search/openai.py +7 -1
  38. shotgun/agents/tools/web_search/utils.py +2 -2
  39. shotgun/agents/usage_manager.py +16 -11
  40. shotgun/api_endpoints.py +7 -3
  41. shotgun/build_constants.py +3 -3
  42. shotgun/cli/clear.py +53 -0
  43. shotgun/cli/compact.py +186 -0
  44. shotgun/cli/config.py +8 -5
  45. shotgun/cli/context.py +111 -0
  46. shotgun/cli/export.py +1 -1
  47. shotgun/cli/feedback.py +4 -2
  48. shotgun/cli/models.py +1 -0
  49. shotgun/cli/plan.py +1 -1
  50. shotgun/cli/research.py +1 -1
  51. shotgun/cli/specify.py +1 -1
  52. shotgun/cli/tasks.py +1 -1
  53. shotgun/cli/update.py +16 -2
  54. shotgun/codebase/core/change_detector.py +5 -3
  55. shotgun/codebase/core/code_retrieval.py +4 -2
  56. shotgun/codebase/core/ingestor.py +10 -8
  57. shotgun/codebase/core/manager.py +13 -4
  58. shotgun/codebase/core/nl_query.py +1 -1
  59. shotgun/exceptions.py +32 -0
  60. shotgun/logging_config.py +18 -27
  61. shotgun/main.py +73 -11
  62. shotgun/posthog_telemetry.py +37 -28
  63. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
  64. shotgun/sentry_telemetry.py +163 -16
  65. shotgun/settings.py +238 -0
  66. shotgun/telemetry.py +10 -33
  67. shotgun/tui/app.py +243 -43
  68. shotgun/tui/commands/__init__.py +1 -1
  69. shotgun/tui/components/context_indicator.py +179 -0
  70. shotgun/tui/components/mode_indicator.py +70 -0
  71. shotgun/tui/components/status_bar.py +48 -0
  72. shotgun/tui/containers.py +91 -0
  73. shotgun/tui/dependencies.py +39 -0
  74. shotgun/tui/protocols.py +45 -0
  75. shotgun/tui/screens/chat/__init__.py +5 -0
  76. shotgun/tui/screens/chat/chat.tcss +54 -0
  77. shotgun/tui/screens/chat/chat_screen.py +1254 -0
  78. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  79. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  80. shotgun/tui/screens/chat/help_text.py +40 -0
  81. shotgun/tui/screens/chat/prompt_history.py +48 -0
  82. shotgun/tui/screens/chat.tcss +11 -0
  83. shotgun/tui/screens/chat_screen/command_providers.py +78 -2
  84. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  85. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  86. shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
  87. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  88. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  89. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  90. shotgun/tui/screens/confirmation_dialog.py +151 -0
  91. shotgun/tui/screens/feedback.py +4 -4
  92. shotgun/tui/screens/github_issue.py +102 -0
  93. shotgun/tui/screens/model_picker.py +49 -24
  94. shotgun/tui/screens/onboarding.py +431 -0
  95. shotgun/tui/screens/pipx_migration.py +153 -0
  96. shotgun/tui/screens/provider_config.py +50 -27
  97. shotgun/tui/screens/shotgun_auth.py +2 -2
  98. shotgun/tui/screens/welcome.py +14 -11
  99. shotgun/tui/services/__init__.py +5 -0
  100. shotgun/tui/services/conversation_service.py +184 -0
  101. shotgun/tui/state/__init__.py +7 -0
  102. shotgun/tui/state/processing_state.py +185 -0
  103. shotgun/tui/utils/mode_progress.py +14 -7
  104. shotgun/tui/widgets/__init__.py +5 -0
  105. shotgun/tui/widgets/widget_coordinator.py +263 -0
  106. shotgun/utils/file_system_utils.py +22 -2
  107. shotgun/utils/marketing.py +110 -0
  108. shotgun/utils/update_checker.py +69 -14
  109. shotgun_sh-0.2.17.dist-info/METADATA +465 -0
  110. shotgun_sh-0.2.17.dist-info/RECORD +194 -0
  111. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/entry_points.txt +1 -0
  112. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/licenses/LICENSE +1 -1
  113. shotgun/tui/screens/chat.py +0 -996
  114. shotgun/tui/screens/chat_screen/history.py +0 -335
  115. shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
  116. shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
  117. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/WHEEL +0 -0
@@ -5,6 +5,8 @@ import uuid
5
5
  from pathlib import Path
6
6
  from typing import Any
7
7
 
8
+ import aiofiles
9
+ import aiofiles.os
8
10
  from pydantic import SecretStr
9
11
 
10
12
  from shotgun.logging_config import get_logger
@@ -48,7 +50,7 @@ class ConfigManager:
48
50
 
49
51
  self._config: ShotgunConfig | None = None
50
52
 
51
- def load(self, force_reload: bool = True) -> ShotgunConfig:
53
+ async def load(self, force_reload: bool = True) -> ShotgunConfig:
52
54
  """Load configuration from file.
53
55
 
54
56
  Args:
@@ -60,18 +62,19 @@ class ConfigManager:
60
62
  if self._config is not None and not force_reload:
61
63
  return self._config
62
64
 
63
- if not self.config_path.exists():
65
+ if not await aiofiles.os.path.exists(self.config_path):
64
66
  logger.info(
65
67
  "Configuration file not found, creating new config at: %s",
66
68
  self.config_path,
67
69
  )
68
70
  # Create new config with generated shotgun_instance_id
69
- self._config = self.initialize()
71
+ self._config = await self.initialize()
70
72
  return self._config
71
73
 
72
74
  try:
73
- with open(self.config_path, encoding="utf-8") as f:
74
- data = json.load(f)
75
+ async with aiofiles.open(self.config_path, encoding="utf-8") as f:
76
+ content = await f.read()
77
+ data = json.loads(content)
75
78
 
76
79
  # Migration: Rename user_id to shotgun_instance_id (config v2 -> v3)
77
80
  if "user_id" in data and SHOTGUN_INSTANCE_ID_FIELD not in data:
@@ -101,6 +104,12 @@ class ConfigManager:
101
104
  "Existing BYOK user detected: set shown_welcome_screen=False to show welcome screen"
102
105
  )
103
106
 
107
+ # Migration: Add marketing config for v3 -> v4
108
+ if "marketing" not in data:
109
+ data["marketing"] = {"messages": {}}
110
+ data["config_version"] = 4
111
+ logger.info("Migrated config v3->v4: added marketing configuration")
112
+
104
113
  # Convert plain text secrets to SecretStr objects
105
114
  self._convert_secrets_to_secretstr(data)
106
115
 
@@ -117,7 +126,7 @@ class ConfigManager:
117
126
 
118
127
  if self._config.selected_model in MODEL_SPECS:
119
128
  spec = MODEL_SPECS[self._config.selected_model]
120
- if not self.has_provider_key(spec.provider):
129
+ if not await self.has_provider_key(spec.provider):
121
130
  logger.info(
122
131
  "Selected model %s provider has no API key, finding available model",
123
132
  self._config.selected_model.value,
@@ -135,14 +144,14 @@ class ConfigManager:
135
144
  # If no selected_model or it was invalid, find first available model
136
145
  if not self._config.selected_model:
137
146
  for provider in ProviderType:
138
- if self.has_provider_key(provider):
147
+ if await self.has_provider_key(provider):
139
148
  # Set to that provider's default model
140
149
  from .models import MODEL_SPECS, ModelName
141
150
 
142
151
  # Find default model for this provider
143
152
  provider_models = {
144
153
  ProviderType.OPENAI: ModelName.GPT_5,
145
- ProviderType.ANTHROPIC: ModelName.CLAUDE_SONNET_4_5,
154
+ ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
146
155
  ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
147
156
  }
148
157
 
@@ -156,7 +165,7 @@ class ConfigManager:
156
165
  break
157
166
 
158
167
  if should_save:
159
- self.save(self._config)
168
+ await self.save(self._config)
160
169
 
161
170
  return self._config
162
171
 
@@ -165,10 +174,10 @@ class ConfigManager:
165
174
  "Failed to load configuration from %s: %s", self.config_path, e
166
175
  )
167
176
  logger.info("Creating new configuration with generated shotgun_instance_id")
168
- self._config = self.initialize()
177
+ self._config = await self.initialize()
169
178
  return self._config
170
179
 
171
- def save(self, config: ShotgunConfig | None = None) -> None:
180
+ async def save(self, config: ShotgunConfig | None = None) -> None:
172
181
  """Save configuration to file.
173
182
 
174
183
  Args:
@@ -184,15 +193,17 @@ class ConfigManager:
184
193
  )
185
194
 
186
195
  # Ensure directory exists
187
- self.config_path.parent.mkdir(parents=True, exist_ok=True)
196
+ await aiofiles.os.makedirs(self.config_path.parent, exist_ok=True)
188
197
 
189
198
  try:
190
199
  # Convert SecretStr to plain text for JSON serialization
191
200
  data = config.model_dump()
192
201
  self._convert_secretstr_to_plain(data)
202
+ self._convert_datetime_to_isoformat(data)
193
203
 
194
- with open(self.config_path, "w", encoding="utf-8") as f:
195
- json.dump(data, f, indent=2, ensure_ascii=False)
204
+ json_content = json.dumps(data, indent=2, ensure_ascii=False)
205
+ async with aiofiles.open(self.config_path, "w", encoding="utf-8") as f:
206
+ await f.write(json_content)
196
207
 
197
208
  logger.debug("Configuration saved to %s", self.config_path)
198
209
  self._config = config
@@ -201,14 +212,16 @@ class ConfigManager:
201
212
  logger.error("Failed to save configuration to %s: %s", self.config_path, e)
202
213
  raise
203
214
 
204
- def update_provider(self, provider: ProviderType | str, **kwargs: Any) -> None:
215
+ async def update_provider(
216
+ self, provider: ProviderType | str, **kwargs: Any
217
+ ) -> None:
205
218
  """Update provider configuration.
206
219
 
207
220
  Args:
208
221
  provider: Provider to update
209
222
  **kwargs: Configuration fields to update (only api_key supported)
210
223
  """
211
- config = self.load()
224
+ config = await self.load()
212
225
 
213
226
  # Get provider config and check if it's shotgun
214
227
  provider_config, is_shotgun = self._get_provider_config_and_type(
@@ -243,7 +256,7 @@ class ConfigManager:
243
256
 
244
257
  provider_models = {
245
258
  ProviderType.OPENAI: ModelName.GPT_5,
246
- ProviderType.ANTHROPIC: ModelName.CLAUDE_SONNET_4_5,
259
+ ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
247
260
  ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
248
261
  }
249
262
  if provider_enum in provider_models:
@@ -253,11 +266,11 @@ class ConfigManager:
253
266
  # This prevents the welcome screen from showing again after user has made their choice
254
267
  config.shown_welcome_screen = True
255
268
 
256
- self.save(config)
269
+ await self.save(config)
257
270
 
258
- def clear_provider_key(self, provider: ProviderType | str) -> None:
271
+ async def clear_provider_key(self, provider: ProviderType | str) -> None:
259
272
  """Remove the API key for the given provider (LLM provider or shotgun)."""
260
- config = self.load()
273
+ config = await self.load()
261
274
 
262
275
  # Get provider config (shotgun or LLM provider)
263
276
  provider_config, is_shotgun = self._get_provider_config_and_type(
@@ -270,34 +283,34 @@ class ConfigManager:
270
283
  if is_shotgun and isinstance(provider_config, ShotgunAccountConfig):
271
284
  provider_config.supabase_jwt = None
272
285
 
273
- self.save(config)
286
+ await self.save(config)
274
287
 
275
- def update_selected_model(self, model_name: "ModelName") -> None:
288
+ async def update_selected_model(self, model_name: "ModelName") -> None:
276
289
  """Update the selected model.
277
290
 
278
291
  Args:
279
292
  model_name: Model to select
280
293
  """
281
- config = self.load()
294
+ config = await self.load()
282
295
  config.selected_model = model_name
283
- self.save(config)
296
+ await self.save(config)
284
297
 
285
- def has_provider_key(self, provider: ProviderType | str) -> bool:
298
+ async def has_provider_key(self, provider: ProviderType | str) -> bool:
286
299
  """Check if the given provider has a non-empty API key configured.
287
300
 
288
301
  This checks only the configuration file.
289
302
  """
290
303
  # Use force_reload=False to avoid infinite loop when called from load()
291
- config = self.load(force_reload=False)
304
+ config = await self.load(force_reload=False)
292
305
  provider_enum = self._ensure_provider_enum(provider)
293
306
  provider_config = self._get_provider_config(config, provider_enum)
294
307
 
295
308
  return self._provider_has_api_key(provider_config)
296
309
 
297
- def has_any_provider_key(self) -> bool:
310
+ async def has_any_provider_key(self) -> bool:
298
311
  """Determine whether any provider has a configured API key."""
299
312
  # Use force_reload=False to avoid infinite loop when called from load()
300
- config = self.load(force_reload=False)
313
+ config = await self.load(force_reload=False)
301
314
  # Check LLM provider keys (BYOK)
302
315
  has_llm_key = any(
303
316
  self._provider_has_api_key(self._get_provider_config(config, provider))
@@ -311,7 +324,7 @@ class ConfigManager:
311
324
  has_shotgun_key = self._provider_has_api_key(config.shotgun)
312
325
  return has_llm_key or has_shotgun_key
313
326
 
314
- def initialize(self) -> ShotgunConfig:
327
+ async def initialize(self) -> ShotgunConfig:
315
328
  """Initialize configuration with defaults and save to file.
316
329
 
317
330
  Returns:
@@ -321,7 +334,7 @@ class ConfigManager:
321
334
  config = ShotgunConfig(
322
335
  shotgun_instance_id=str(uuid.uuid4()),
323
336
  )
324
- self.save(config)
337
+ await self.save(config)
325
338
  logger.info(
326
339
  "Configuration initialized at %s with shotgun_instance_id: %s",
327
340
  self.config_path,
@@ -377,6 +390,24 @@ class ConfigManager:
377
390
  SUPABASE_JWT_FIELD
378
391
  ].get_secret_value()
379
392
 
393
+ def _convert_datetime_to_isoformat(self, data: dict[str, Any]) -> None:
394
+ """Convert datetime objects in data to ISO8601 format strings for JSON serialization."""
395
+ from datetime import datetime
396
+
397
+ def convert_dict(d: dict[str, Any]) -> None:
398
+ """Recursively convert datetime objects in a dict."""
399
+ for key, value in d.items():
400
+ if isinstance(value, datetime):
401
+ d[key] = value.isoformat()
402
+ elif isinstance(value, dict):
403
+ convert_dict(value)
404
+ elif isinstance(value, list):
405
+ for item in value:
406
+ if isinstance(item, dict):
407
+ convert_dict(item)
408
+
409
+ convert_dict(data)
410
+
380
411
  def _ensure_provider_enum(self, provider: ProviderType | str) -> ProviderType:
381
412
  """Normalize provider values to ProviderType enum."""
382
413
  return (
@@ -440,16 +471,16 @@ class ConfigManager:
440
471
  provider_enum = self._ensure_provider_enum(provider)
441
472
  return (self._get_provider_config(config, provider_enum), False)
442
473
 
443
- def get_shotgun_instance_id(self) -> str:
474
+ async def get_shotgun_instance_id(self) -> str:
444
475
  """Get the shotgun instance ID from configuration.
445
476
 
446
477
  Returns:
447
478
  The unique shotgun instance ID string
448
479
  """
449
- config = self.load()
480
+ config = await self.load()
450
481
  return config.shotgun_instance_id
451
482
 
452
- def update_shotgun_account(
483
+ async def update_shotgun_account(
453
484
  self, api_key: str | None = None, supabase_jwt: str | None = None
454
485
  ) -> None:
455
486
  """Update Shotgun Account configuration.
@@ -458,7 +489,7 @@ class ConfigManager:
458
489
  api_key: LiteLLM proxy API key (optional)
459
490
  supabase_jwt: Supabase authentication JWT (optional)
460
491
  """
461
- config = self.load()
492
+ config = await self.load()
462
493
 
463
494
  if api_key is not None:
464
495
  config.shotgun.api_key = SecretStr(api_key) if api_key else None
@@ -468,7 +499,7 @@ class ConfigManager:
468
499
  SecretStr(supabase_jwt) if supabase_jwt else None
469
500
  )
470
501
 
471
- self.save(config)
502
+ await self.save(config)
472
503
  logger.info("Updated Shotgun Account configuration")
473
504
 
474
505
 
@@ -1,5 +1,6 @@
1
1
  """Pydantic models for configuration."""
2
2
 
3
+ from datetime import datetime
3
4
  from enum import StrEnum
4
5
 
5
6
  from pydantic import BaseModel, Field, PrivateAttr, SecretStr
@@ -28,6 +29,7 @@ class ModelName(StrEnum):
28
29
  GPT_5_MINI = "gpt-5-mini"
29
30
  CLAUDE_OPUS_4_1 = "claude-opus-4-1"
30
31
  CLAUDE_SONNET_4_5 = "claude-sonnet-4-5"
32
+ CLAUDE_HAIKU_4_5 = "claude-haiku-4-5"
31
33
  GEMINI_2_5_PRO = "gemini-2.5-pro"
32
34
  GEMINI_2_5_FLASH = "gemini-2.5-flash"
33
35
 
@@ -42,6 +44,7 @@ class ModelSpec(BaseModel):
42
44
  litellm_proxy_model_name: (
43
45
  str # LiteLLM format (e.g., "openai/gpt-5", "gemini/gemini-2-pro")
44
46
  )
47
+ short_name: str # Display name for UI (e.g., "Sonnet 4.5", "GPT-5")
45
48
 
46
49
 
47
50
  class ModelConfig(BaseModel):
@@ -88,6 +91,7 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
88
91
  max_input_tokens=400_000,
89
92
  max_output_tokens=128_000,
90
93
  litellm_proxy_model_name="openai/gpt-5",
94
+ short_name="GPT-5",
91
95
  ),
92
96
  ModelName.GPT_5_MINI: ModelSpec(
93
97
  name=ModelName.GPT_5_MINI,
@@ -95,6 +99,7 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
95
99
  max_input_tokens=400_000,
96
100
  max_output_tokens=128_000,
97
101
  litellm_proxy_model_name="openai/gpt-5-mini",
102
+ short_name="GPT-5 Mini",
98
103
  ),
99
104
  ModelName.CLAUDE_OPUS_4_1: ModelSpec(
100
105
  name=ModelName.CLAUDE_OPUS_4_1,
@@ -102,6 +107,7 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
102
107
  max_input_tokens=200_000,
103
108
  max_output_tokens=32_000,
104
109
  litellm_proxy_model_name="anthropic/claude-opus-4-1",
110
+ short_name="Opus 4.1",
105
111
  ),
106
112
  ModelName.CLAUDE_SONNET_4_5: ModelSpec(
107
113
  name=ModelName.CLAUDE_SONNET_4_5,
@@ -109,6 +115,15 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
109
115
  max_input_tokens=200_000,
110
116
  max_output_tokens=16_000,
111
117
  litellm_proxy_model_name="anthropic/claude-sonnet-4-5",
118
+ short_name="Sonnet 4.5",
119
+ ),
120
+ ModelName.CLAUDE_HAIKU_4_5: ModelSpec(
121
+ name=ModelName.CLAUDE_HAIKU_4_5,
122
+ provider=ProviderType.ANTHROPIC,
123
+ max_input_tokens=200_000,
124
+ max_output_tokens=64_000,
125
+ litellm_proxy_model_name="anthropic/claude-haiku-4-5",
126
+ short_name="Haiku 4.5",
112
127
  ),
113
128
  ModelName.GEMINI_2_5_PRO: ModelSpec(
114
129
  name=ModelName.GEMINI_2_5_PRO,
@@ -116,6 +131,7 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
116
131
  max_input_tokens=1_000_000,
117
132
  max_output_tokens=64_000,
118
133
  litellm_proxy_model_name="gemini/gemini-2.5-pro",
134
+ short_name="Gemini 2.5 Pro",
119
135
  ),
120
136
  ModelName.GEMINI_2_5_FLASH: ModelSpec(
121
137
  name=ModelName.GEMINI_2_5_FLASH,
@@ -123,6 +139,7 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
123
139
  max_input_tokens=1_000_000,
124
140
  max_output_tokens=64_000,
125
141
  litellm_proxy_model_name="gemini/gemini-2.5-flash",
142
+ short_name="Gemini 2.5 Flash",
126
143
  ),
127
144
  }
128
145
 
@@ -154,6 +171,21 @@ class ShotgunAccountConfig(BaseModel):
154
171
  )
155
172
 
156
173
 
174
+ class MarketingMessageRecord(BaseModel):
175
+ """Record of when a marketing message was shown to the user."""
176
+
177
+ shown_at: datetime = Field(description="Timestamp when the message was shown")
178
+
179
+
180
+ class MarketingConfig(BaseModel):
181
+ """Configuration for marketing messages shown to users."""
182
+
183
+ messages: dict[str, MarketingMessageRecord] = Field(
184
+ default_factory=dict,
185
+ description="Tracking which marketing messages have been shown. Key is message ID (e.g., 'github_star_v1')",
186
+ )
187
+
188
+
157
189
  class ShotgunConfig(BaseModel):
158
190
  """Main configuration for Shotgun CLI."""
159
191
 
@@ -168,8 +200,16 @@ class ShotgunConfig(BaseModel):
168
200
  shotgun_instance_id: str = Field(
169
201
  description="Unique shotgun instance identifier (also used for anonymous telemetry)",
170
202
  )
171
- config_version: int = Field(default=3, description="Configuration schema version")
203
+ config_version: int = Field(default=4, description="Configuration schema version")
172
204
  shown_welcome_screen: bool = Field(
173
205
  default=False,
174
206
  description="Whether the welcome screen has been shown to the user",
175
207
  )
208
+ shown_onboarding_popup: datetime | None = Field(
209
+ default=None,
210
+ description="Timestamp when the onboarding popup was shown to the user (ISO8601 format)",
211
+ )
212
+ marketing: MarketingConfig = Field(
213
+ default_factory=MarketingConfig,
214
+ description="Marketing messages configuration and tracking",
215
+ )
@@ -32,6 +32,34 @@ logger = get_logger(__name__)
32
32
  _model_cache: dict[tuple[ProviderType, KeyProvider, ModelName, str], Model] = {}
33
33
 
34
34
 
35
+ def get_default_model_for_provider(config: ShotgunConfig) -> ModelName:
36
+ """Get the default model based on which provider/account is configured.
37
+
38
+ Checks API keys in priority order and returns appropriate default model.
39
+ Treats Shotgun Account as a provider context.
40
+
41
+ Args:
42
+ config: Shotgun configuration containing API keys
43
+
44
+ Returns:
45
+ Default ModelName for the configured provider/account
46
+ """
47
+ # Priority 1: Shotgun Account
48
+ if _get_api_key(config.shotgun.api_key):
49
+ return ModelName.GPT_5
50
+
51
+ # Priority 2: Individual provider keys
52
+ if _get_api_key(config.anthropic.api_key):
53
+ return ModelName.CLAUDE_HAIKU_4_5
54
+ if _get_api_key(config.openai.api_key):
55
+ return ModelName.GPT_5
56
+ if _get_api_key(config.google.api_key):
57
+ return ModelName.GEMINI_2_5_PRO
58
+
59
+ # Fallback: system-wide default
60
+ return ModelName.CLAUDE_HAIKU_4_5
61
+
62
+
35
63
  def get_or_create_model(
36
64
  provider: ProviderType,
37
65
  key_provider: "KeyProvider",
@@ -142,7 +170,7 @@ def get_or_create_model(
142
170
  return _model_cache[cache_key]
143
171
 
144
172
 
145
- def get_provider_model(
173
+ async def get_provider_model(
146
174
  provider_or_model: ProviderType | ModelName | None = None,
147
175
  ) -> ModelConfig:
148
176
  """Get a fully configured ModelConfig with API key and Model instance.
@@ -161,7 +189,7 @@ def get_provider_model(
161
189
  """
162
190
  config_manager = get_config_manager()
163
191
  # Use cached config for read-only access (performance)
164
- config = config_manager.load(force_reload=False)
192
+ config = await config_manager.load(force_reload=False)
165
193
 
166
194
  # Priority 1: Check if Shotgun key exists - if so, use it for ANY model
167
195
  shotgun_api_key = _get_api_key(config.shotgun.api_key)
@@ -172,7 +200,7 @@ def get_provider_model(
172
200
  model_name = provider_or_model
173
201
  else:
174
202
  # No specific model requested - use selected or default
175
- model_name = config.selected_model or ModelName.CLAUDE_SONNET_4_5
203
+ model_name = config.selected_model or ModelName.GPT_5
176
204
 
177
205
  if model_name not in MODEL_SPECS:
178
206
  raise ValueError(f"Model '{model_name.value}' not found")
@@ -247,8 +275,8 @@ def get_provider_model(
247
275
  if not api_key:
248
276
  raise ValueError("Anthropic API key not configured. Set via config.")
249
277
 
250
- # Use requested model or default to claude-sonnet-4-5
251
- model_name = requested_model if requested_model else ModelName.CLAUDE_SONNET_4_5
278
+ # Use requested model or default to claude-haiku-4-5
279
+ model_name = requested_model if requested_model else ModelName.CLAUDE_HAIKU_4_5
252
280
  if model_name not in MODEL_SPECS:
253
281
  raise ValueError(f"Model '{model_name.value}' not found")
254
282
  spec = MODEL_SPECS[model_name]
@@ -0,0 +1,28 @@
1
+ """Context analysis module for conversation composition statistics.
2
+
3
+ This module provides tools for analyzing conversation context usage, breaking down
4
+ token consumption by message type and tool category.
5
+ """
6
+
7
+ from .analyzer import ContextAnalyzer
8
+ from .constants import ToolCategory, get_tool_category
9
+ from .formatter import ContextFormatter
10
+ from .models import (
11
+ ContextAnalysis,
12
+ ContextAnalysisOutput,
13
+ ContextCompositionTelemetry,
14
+ MessageTypeStats,
15
+ TokenAllocation,
16
+ )
17
+
18
+ __all__ = [
19
+ "ContextAnalyzer",
20
+ "ContextAnalysis",
21
+ "ContextAnalysisOutput",
22
+ "ContextCompositionTelemetry",
23
+ "ContextFormatter",
24
+ "MessageTypeStats",
25
+ "TokenAllocation",
26
+ "ToolCategory",
27
+ "get_tool_category",
28
+ ]