shotgun-sh 0.3.3.dev1__py3-none-any.whl → 0.6.2__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 (159) hide show
  1. shotgun/agents/agent_manager.py +497 -30
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +90 -77
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +52 -8
  6. shotgun/agents/config/models.py +21 -27
  7. shotgun/agents/config/provider.py +44 -27
  8. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  9. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  10. shotgun/agents/export.py +12 -13
  11. shotgun/agents/file_read.py +176 -0
  12. shotgun/agents/messages.py +15 -3
  13. shotgun/agents/models.py +90 -2
  14. shotgun/agents/plan.py +12 -13
  15. shotgun/agents/research.py +13 -10
  16. shotgun/agents/router/__init__.py +47 -0
  17. shotgun/agents/router/models.py +384 -0
  18. shotgun/agents/router/router.py +185 -0
  19. shotgun/agents/router/tools/__init__.py +18 -0
  20. shotgun/agents/router/tools/delegation_tools.py +557 -0
  21. shotgun/agents/router/tools/plan_tools.py +403 -0
  22. shotgun/agents/runner.py +17 -2
  23. shotgun/agents/specify.py +12 -13
  24. shotgun/agents/tasks.py +12 -13
  25. shotgun/agents/tools/__init__.py +8 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  27. shotgun/agents/tools/codebase/file_read.py +26 -35
  28. shotgun/agents/tools/codebase/query_graph.py +9 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  30. shotgun/agents/tools/file_management.py +81 -3
  31. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  32. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  33. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  34. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  35. shotgun/agents/tools/markdown_tools/models.py +86 -0
  36. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  37. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  38. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  39. shotgun/agents/tools/registry.py +46 -6
  40. shotgun/agents/tools/web_search/__init__.py +1 -2
  41. shotgun/agents/tools/web_search/gemini.py +1 -3
  42. shotgun/agents/tools/web_search/openai.py +42 -23
  43. shotgun/attachments/__init__.py +41 -0
  44. shotgun/attachments/errors.py +60 -0
  45. shotgun/attachments/models.py +107 -0
  46. shotgun/attachments/parser.py +257 -0
  47. shotgun/attachments/processor.py +193 -0
  48. shotgun/build_constants.py +4 -7
  49. shotgun/cli/clear.py +2 -2
  50. shotgun/cli/codebase/commands.py +181 -65
  51. shotgun/cli/compact.py +2 -2
  52. shotgun/cli/context.py +2 -2
  53. shotgun/cli/error_handler.py +2 -2
  54. shotgun/cli/run.py +90 -0
  55. shotgun/cli/spec/backup.py +2 -1
  56. shotgun/codebase/__init__.py +2 -0
  57. shotgun/codebase/benchmarks/__init__.py +35 -0
  58. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  59. shotgun/codebase/benchmarks/exporters.py +119 -0
  60. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  61. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  62. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  63. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  64. shotgun/codebase/benchmarks/models.py +129 -0
  65. shotgun/codebase/core/__init__.py +4 -0
  66. shotgun/codebase/core/call_resolution.py +91 -0
  67. shotgun/codebase/core/change_detector.py +11 -6
  68. shotgun/codebase/core/errors.py +159 -0
  69. shotgun/codebase/core/extractors/__init__.py +23 -0
  70. shotgun/codebase/core/extractors/base.py +138 -0
  71. shotgun/codebase/core/extractors/factory.py +63 -0
  72. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  73. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  74. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  75. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  76. shotgun/codebase/core/extractors/protocol.py +109 -0
  77. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  78. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  79. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  80. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  81. shotgun/codebase/core/extractors/types.py +15 -0
  82. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  83. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  84. shotgun/codebase/core/gitignore.py +252 -0
  85. shotgun/codebase/core/ingestor.py +644 -354
  86. shotgun/codebase/core/kuzu_compat.py +119 -0
  87. shotgun/codebase/core/language_config.py +239 -0
  88. shotgun/codebase/core/manager.py +256 -46
  89. shotgun/codebase/core/metrics_collector.py +310 -0
  90. shotgun/codebase/core/metrics_types.py +347 -0
  91. shotgun/codebase/core/parallel_executor.py +424 -0
  92. shotgun/codebase/core/work_distributor.py +254 -0
  93. shotgun/codebase/core/worker.py +768 -0
  94. shotgun/codebase/indexing_state.py +86 -0
  95. shotgun/codebase/models.py +94 -0
  96. shotgun/codebase/service.py +13 -0
  97. shotgun/exceptions.py +9 -9
  98. shotgun/main.py +3 -16
  99. shotgun/posthog_telemetry.py +165 -24
  100. shotgun/prompts/agents/export.j2 +2 -0
  101. shotgun/prompts/agents/file_read.j2 +48 -0
  102. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -52
  103. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  104. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  105. shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
  106. shotgun/prompts/agents/plan.j2 +38 -12
  107. shotgun/prompts/agents/research.j2 +70 -31
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +53 -16
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -13
  112. shotgun/prompts/agents/tasks.j2 +72 -34
  113. shotgun/settings.py +49 -10
  114. shotgun/tui/app.py +154 -24
  115. shotgun/tui/commands/__init__.py +9 -1
  116. shotgun/tui/components/attachment_bar.py +87 -0
  117. shotgun/tui/components/mode_indicator.py +120 -25
  118. shotgun/tui/components/prompt_input.py +25 -28
  119. shotgun/tui/components/status_bar.py +14 -7
  120. shotgun/tui/dependencies.py +58 -8
  121. shotgun/tui/protocols.py +55 -0
  122. shotgun/tui/screens/chat/chat.tcss +24 -1
  123. shotgun/tui/screens/chat/chat_screen.py +1376 -213
  124. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  125. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  126. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  127. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  128. shotgun/tui/screens/chat_screen/history/chat_history.py +58 -6
  129. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  130. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  131. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  132. shotgun/tui/screens/chat_screen/messages.py +219 -0
  133. shotgun/tui/screens/database_locked_dialog.py +219 -0
  134. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  135. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  136. shotgun/tui/screens/model_picker.py +1 -3
  137. shotgun/tui/screens/models.py +11 -0
  138. shotgun/tui/state/processing_state.py +19 -0
  139. shotgun/tui/utils/mode_progress.py +20 -86
  140. shotgun/tui/widgets/__init__.py +2 -1
  141. shotgun/tui/widgets/approval_widget.py +152 -0
  142. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  143. shotgun/tui/widgets/plan_panel.py +129 -0
  144. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  145. shotgun/tui/widgets/widget_coordinator.py +18 -0
  146. shotgun/utils/file_system_utils.py +4 -1
  147. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +88 -35
  148. shotgun_sh-0.6.2.dist-info/RECORD +291 -0
  149. shotgun/cli/export.py +0 -81
  150. shotgun/cli/plan.py +0 -73
  151. shotgun/cli/research.py +0 -93
  152. shotgun/cli/specify.py +0 -70
  153. shotgun/cli/tasks.py +0 -78
  154. shotgun/sentry_telemetry.py +0 -232
  155. shotgun/tui/screens/onboarding.py +0 -580
  156. shotgun_sh-0.3.3.dev1.dist-info/RECORD +0 -229
  157. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
  158. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
  159. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,86 @@
1
+ """State tracking for codebase indexing operations."""
2
+
3
+ import asyncio
4
+ from typing import ClassVar
5
+
6
+ from shotgun.logging_config import get_logger
7
+
8
+ logger = get_logger(__name__)
9
+
10
+
11
+ class IndexingState:
12
+ """Tracks which graph_ids are currently being indexed.
13
+
14
+ This is a simple state container that tools can check to determine
15
+ if a graph is available or currently being built. This prevents
16
+ race conditions on Windows where Kuzu uses exclusive file locking.
17
+
18
+ Uses class-level state so all service instances share the same state.
19
+ This is similar to how CodebaseGraphManager shares connections.
20
+ """
21
+
22
+ # Error message for when tools try to access a graph being indexed
23
+ INDEXING_IN_PROGRESS_ERROR = (
24
+ "This codebase is currently being indexed. "
25
+ "Please wait for indexing to complete before accessing it."
26
+ )
27
+
28
+ # Class-level state shared across all instances
29
+ _active_graphs: ClassVar[set[str]] = set()
30
+ _lock: ClassVar[asyncio.Lock | None] = None
31
+
32
+ @classmethod
33
+ def _get_lock(cls) -> asyncio.Lock:
34
+ """Get or create the class-level lock."""
35
+ if cls._lock is None:
36
+ cls._lock = asyncio.Lock()
37
+ return cls._lock
38
+
39
+ async def start(self, graph_id: str) -> None:
40
+ """Mark a graph as being indexed.
41
+
42
+ Args:
43
+ graph_id: The graph ID that is starting to be indexed
44
+ """
45
+ lock = self._get_lock()
46
+ async with lock:
47
+ self._active_graphs.add(graph_id)
48
+ logger.debug(f"Indexing started for graph: {graph_id}")
49
+
50
+ async def complete(self, graph_id: str) -> None:
51
+ """Mark a graph as finished indexing.
52
+
53
+ Args:
54
+ graph_id: The graph ID that finished indexing
55
+ """
56
+ lock = self._get_lock()
57
+ async with lock:
58
+ self._active_graphs.discard(graph_id)
59
+ logger.debug(f"Indexing completed for graph: {graph_id}")
60
+
61
+ def is_active(self, graph_id: str) -> bool:
62
+ """Check if a specific graph is currently being indexed.
63
+
64
+ Args:
65
+ graph_id: The graph ID to check
66
+
67
+ Returns:
68
+ True if the graph is currently being indexed
69
+ """
70
+ return graph_id in self._active_graphs
71
+
72
+ def has_active(self) -> bool:
73
+ """Check if any graph is currently being indexed.
74
+
75
+ Returns:
76
+ True if any graph is being indexed
77
+ """
78
+ return len(self._active_graphs) > 0
79
+
80
+ def get_active_ids(self) -> set[str]:
81
+ """Get set of graph IDs currently being indexed.
82
+
83
+ Returns:
84
+ Copy of the set of graph IDs being indexed
85
+ """
86
+ return self._active_graphs.copy()
@@ -16,6 +16,15 @@ class GraphStatus(StrEnum):
16
16
  ERROR = "ERROR" # Last operation failed
17
17
 
18
18
 
19
+ class IgnoreReason(StrEnum):
20
+ """Reason why a file or directory was ignored during indexing."""
21
+
22
+ HARDCODED = (
23
+ "hardcoded" # Matched hardcoded ignore patterns (venv, node_modules, etc.)
24
+ )
25
+ GITIGNORE = "gitignore" # Matched .gitignore pattern
26
+
27
+
19
28
  class QueryType(StrEnum):
20
29
  """Type of query being executed."""
21
30
 
@@ -33,6 +42,55 @@ class ProgressPhase(StrEnum):
33
42
  FLUSH_RELATIONSHIPS = "flush_relationships" # Flushing relationships to database
34
43
 
35
44
 
45
+ class NodeLabel(StrEnum):
46
+ """Node type labels for the code knowledge graph."""
47
+
48
+ PROJECT = "Project" # Top-level project node
49
+ PACKAGE = "Package" # Python package/namespace
50
+ FOLDER = "Folder" # Directory structure
51
+ FILE = "File" # Source file
52
+ MODULE = "Module" # Python module
53
+ CLASS = "Class" # Class definition
54
+ FUNCTION = "Function" # Function definition
55
+ METHOD = "Method" # Method definition (inside a class)
56
+ FILE_METADATA = "FileMetadata" # File tracking metadata (hash, mtime)
57
+ EXTERNAL_PACKAGE = "ExternalPackage" # External dependency
58
+ DELETION_LOG = "DeletionLog" # Deletion audit trail
59
+
60
+
61
+ class RelationshipType(StrEnum):
62
+ """Relationship types for the code knowledge graph."""
63
+
64
+ # Containment relationships (used with suffixes _PKG, _FOLDER in tables)
65
+ CONTAINS_PACKAGE = "CONTAINS_PACKAGE" # Container to Package
66
+ CONTAINS_FOLDER = "CONTAINS_FOLDER" # Container to Folder
67
+ CONTAINS_FILE = "CONTAINS_FILE" # Container to File
68
+ CONTAINS_MODULE = "CONTAINS_MODULE" # Container to Module
69
+
70
+ # Definition relationships
71
+ DEFINES = "DEFINES" # Module to Class
72
+ DEFINES_FUNC = "DEFINES_FUNC" # Module to Function
73
+ DEFINES_METHOD = "DEFINES_METHOD" # Class to Method
74
+
75
+ # Call relationships
76
+ CALLS = "CALLS" # Function to Function
77
+ CALLS_FM = "CALLS_FM" # Function to Method
78
+ CALLS_MF = "CALLS_MF" # Method to Function
79
+ CALLS_MM = "CALLS_MM" # Method to Method
80
+
81
+ # Tracking relationships (FileMetadata to entity)
82
+ TRACKS_MODULE = "TRACKS_Module" # FileMetadata to Module
83
+ TRACKS_CLASS = "TRACKS_Class" # FileMetadata to Class
84
+ TRACKS_FUNCTION = "TRACKS_Function" # FileMetadata to Function
85
+ TRACKS_METHOD = "TRACKS_Method" # FileMetadata to Method
86
+
87
+ # Other relationships
88
+ INHERITS = "INHERITS" # Child Class to Parent Class
89
+ OVERRIDES = "OVERRIDES" # Method to Method (override)
90
+ IMPORTS = "IMPORTS" # Module to Module
91
+ DEPENDS_ON_EXTERNAL = "DEPENDS_ON_EXTERNAL" # Project to ExternalPackage
92
+
93
+
36
94
  class IndexProgress(BaseModel):
37
95
  """Progress information for codebase indexing."""
38
96
 
@@ -49,6 +107,42 @@ class IndexProgress(BaseModel):
49
107
  ProgressCallback = Callable[[IndexProgress], None]
50
108
 
51
109
 
110
+ class GitignoreStats(BaseModel):
111
+ """Statistics from gitignore pattern matching."""
112
+
113
+ gitignore_files_loaded: int = Field(
114
+ default=0, description="Number of .gitignore files loaded"
115
+ )
116
+ patterns_loaded: int = Field(default=0, description="Total patterns loaded")
117
+ files_checked: int = Field(default=0, description="Number of paths checked")
118
+ files_ignored: int = Field(
119
+ default=0, description="Number of paths ignored by gitignore"
120
+ )
121
+
122
+
123
+ class IndexingStats(BaseModel):
124
+ """Statistics from codebase indexing."""
125
+
126
+ dirs_scanned: int = Field(default=0, description="Directories scanned")
127
+ dirs_ignored_hardcoded: int = Field(
128
+ default=0, description="Directories ignored by hardcoded patterns"
129
+ )
130
+ dirs_ignored_gitignore: int = Field(
131
+ default=0, description="Directories ignored by gitignore"
132
+ )
133
+ files_scanned: int = Field(default=0, description="Files scanned")
134
+ files_ignored_hardcoded: int = Field(
135
+ default=0, description="Files ignored by hardcoded patterns"
136
+ )
137
+ files_ignored_gitignore: int = Field(
138
+ default=0, description="Files ignored by gitignore"
139
+ )
140
+ files_ignored_no_parser: int = Field(
141
+ default=0, description="Files ignored due to no parser available"
142
+ )
143
+ files_processed: int = Field(default=0, description="Files successfully processed")
144
+
145
+
52
146
  class OperationStats(BaseModel):
53
147
  """Statistics for a graph operation (build/update)."""
54
148
 
@@ -7,6 +7,7 @@ from typing import Any
7
7
  from shotgun.codebase.core.cypher_models import CypherGenerationNotPossibleError
8
8
  from shotgun.codebase.core.manager import CodebaseGraphManager
9
9
  from shotgun.codebase.core.nl_query import generate_cypher
10
+ from shotgun.codebase.indexing_state import IndexingState
10
11
  from shotgun.codebase.models import CodebaseGraph, QueryResult, QueryType
11
12
  from shotgun.logging_config import get_logger
12
13
 
@@ -28,6 +29,18 @@ class CodebaseService:
28
29
  self.storage_dir = storage_dir
29
30
  self.storage_dir.mkdir(parents=True, exist_ok=True)
30
31
  self.manager = CodebaseGraphManager(storage_dir)
32
+ self.indexing = IndexingState()
33
+
34
+ def compute_graph_id(self, repo_path: str | Path) -> str:
35
+ """Compute graph_id for a repo path without creating the graph.
36
+
37
+ Args:
38
+ repo_path: Path to the repository
39
+
40
+ Returns:
41
+ The graph_id that would be used for this repo path
42
+ """
43
+ return self.manager.generate_graph_id(str(repo_path))
31
44
 
32
45
  async def list_graphs(self) -> list[CodebaseGraph]:
33
46
  """List all existing graphs.
shotgun/exceptions.py CHANGED
@@ -7,8 +7,8 @@ SHOTGUN_SIGNUP_URL = "https://shotgun.sh"
7
7
  SHOTGUN_CONTACT_EMAIL = "contact@shotgun.sh"
8
8
 
9
9
 
10
- class ErrorNotPickedUpBySentry(Exception): # noqa: N818
11
- """Base for user-actionable errors that shouldn't be sent to Sentry.
10
+ class UserActionableError(Exception): # noqa: N818
11
+ """Base for user-actionable errors that shouldn't be sent to telemetry.
12
12
 
13
13
  These errors represent expected user conditions requiring action
14
14
  rather than bugs that need tracking.
@@ -37,7 +37,7 @@ class ErrorNotPickedUpBySentry(Exception): # noqa: N818
37
37
  # ============================================================================
38
38
 
39
39
 
40
- class AgentCancelledException(ErrorNotPickedUpBySentry):
40
+ class AgentCancelledException(UserActionableError): # noqa: N818
41
41
  """Raised when user cancels an agent operation."""
42
42
 
43
43
  def __init__(self) -> None:
@@ -53,7 +53,7 @@ class AgentCancelledException(ErrorNotPickedUpBySentry):
53
53
  return "⚠️ Operation cancelled by user"
54
54
 
55
55
 
56
- class ContextSizeLimitExceeded(ErrorNotPickedUpBySentry):
56
+ class ContextSizeLimitExceeded(UserActionableError): # noqa: N818
57
57
  """Raised when conversation context exceeds the model's limits.
58
58
 
59
59
  This is a user-actionable error - they need to either:
@@ -81,7 +81,7 @@ class ContextSizeLimitExceeded(ErrorNotPickedUpBySentry):
81
81
  f"⚠️ **Context too large for {self.model_name}**\n\n"
82
82
  f"Your conversation history exceeds this model's limit ({self.max_tokens:,} tokens).\n\n"
83
83
  f"**Choose an action:**\n\n"
84
- f"1. Switch to a larger model (`Ctrl+P` → Change Model)\n"
84
+ f"1. Switch to a larger model (`/` → Change Model)\n"
85
85
  f"2. Switch to a larger model, compact (`/compact`), then switch back to {self.model_name}\n"
86
86
  f"3. Clear conversation (`/clear`)\n"
87
87
  )
@@ -103,7 +103,7 @@ class ContextSizeLimitExceeded(ErrorNotPickedUpBySentry):
103
103
  # ============================================================================
104
104
 
105
105
 
106
- class ShotgunAccountException(ErrorNotPickedUpBySentry):
106
+ class ShotgunAccountException(UserActionableError): # noqa: N818
107
107
  """Base class for Shotgun Account service errors.
108
108
 
109
109
  TUI will check isinstance() of this class to show contact email UI.
@@ -216,7 +216,7 @@ class ShotgunRateLimitException(ShotgunAccountException):
216
216
  # ============================================================================
217
217
 
218
218
 
219
- class BYOKAPIException(ErrorNotPickedUpBySentry):
219
+ class BYOKAPIException(UserActionableError): # noqa: N818
220
220
  """Base class for BYOK API errors.
221
221
 
222
222
  All BYOK errors suggest using Shotgun Account to avoid the issue.
@@ -313,7 +313,7 @@ class BYOKGenericAPIException(BYOKAPIException):
313
313
  # ============================================================================
314
314
 
315
315
 
316
- class GenericAPIStatusException(ErrorNotPickedUpBySentry):
316
+ class GenericAPIStatusException(UserActionableError): # noqa: N818
317
317
  """Raised for generic API status errors that don't fit other categories."""
318
318
 
319
319
  def __init__(self, message: str):
@@ -334,7 +334,7 @@ class GenericAPIStatusException(ErrorNotPickedUpBySentry):
334
334
  return f"⚠️ AI service error: {self.api_message}"
335
335
 
336
336
 
337
- class UnknownAgentException(ErrorNotPickedUpBySentry):
337
+ class UnknownAgentException(UserActionableError): # noqa: N818
338
338
  """Raised for unknown/unclassified agent errors."""
339
339
 
340
340
  def __init__(self, original_exception: Exception):
shotgun/main.py CHANGED
@@ -28,18 +28,13 @@ from shotgun.cli import (
28
28
  compact,
29
29
  config,
30
30
  context,
31
- export,
32
31
  feedback,
33
- plan,
34
- research,
32
+ run,
35
33
  spec,
36
- specify,
37
- tasks,
38
34
  update,
39
35
  )
40
36
  from shotgun.logging_config import configure_root_logger, get_logger
41
37
  from shotgun.posthog_telemetry import setup_posthog_observability
42
- from shotgun.sentry_telemetry import setup_sentry_observability
43
38
  from shotgun.telemetry import setup_logfire_observability
44
39
  from shotgun.tui import app as tui_app
45
40
  from shotgun.utils.update_checker import perform_auto_update_async
@@ -66,11 +61,7 @@ try:
66
61
  except Exception as e:
67
62
  logger.debug("Configuration initialization warning: %s", e)
68
63
 
69
- # Initialize Sentry telemetry
70
- _sentry_enabled = setup_sentry_observability()
71
- logger.debug("Sentry observability enabled: %s", _sentry_enabled)
72
-
73
- # Initialize PostHog analytics
64
+ # Initialize PostHog analytics (includes exception tracking)
74
65
  _posthog_enabled = setup_posthog_observability()
75
66
  logger.debug("PostHog analytics enabled: %s", _posthog_enabled)
76
67
 
@@ -89,11 +80,7 @@ app.add_typer(
89
80
  app.add_typer(context.app, name="context", help="Analyze conversation context usage")
90
81
  app.add_typer(compact.app, name="compact", help="Compact conversation history")
91
82
  app.add_typer(clear.app, name="clear", help="Clear conversation history")
92
- app.add_typer(research.app, name="research", help="Perform research with agentic loops")
93
- app.add_typer(plan.app, name="plan", help="Generate structured plans")
94
- app.add_typer(specify.app, name="specify", help="Generate comprehensive specifications")
95
- app.add_typer(tasks.app, name="tasks", help="Generate task lists with agentic approach")
96
- app.add_typer(export.app, name="export", help="Export artifacts to various formats")
83
+ app.add_typer(run.app, name="run", help="Run a prompt using the Router agent")
97
84
  app.add_typer(update.app, name="update", help="Check for and install updates")
98
85
  app.add_typer(feedback.app, name="feedback", help="Send us feedback")
99
86
  app.add_typer(spec.app, name="spec", help="Manage shared specifications")
@@ -1,34 +1,86 @@
1
1
  """PostHog analytics setup for Shotgun."""
2
2
 
3
+ import platform
3
4
  from enum import StrEnum
4
5
  from typing import Any
5
6
 
6
- import posthog
7
+ from posthog import Posthog
7
8
  from pydantic import BaseModel
8
9
 
9
10
  from shotgun import __version__
10
11
  from shotgun.agents.config import get_config_manager
11
12
  from shotgun.agents.conversation import ConversationManager
13
+ from shotgun.exceptions import UserActionableError
12
14
  from shotgun.logging_config import get_early_logger
13
15
  from shotgun.settings import settings
14
16
 
15
17
  # Use early logger to prevent automatic StreamHandler creation
16
18
  logger = get_early_logger(__name__)
17
19
 
20
+
21
+ def _get_environment() -> str:
22
+ """Determine environment from version string.
23
+
24
+ Returns:
25
+ 'development' for dev/rc/alpha/beta versions, 'production' otherwise
26
+ """
27
+ if any(marker in __version__ for marker in ["dev", "rc", "alpha", "beta"]):
28
+ return "development"
29
+ return "production"
30
+
31
+
18
32
  # Global PostHog client instance
19
- _posthog_client = None
33
+ _posthog_client: Posthog | None = None
20
34
 
21
- # Cache the shotgun instance ID to avoid async calls during event tracking
35
+ # Cache user context to avoid async calls during event tracking
22
36
  _shotgun_instance_id: str | None = None
37
+ _user_context: dict[str, Any] = {}
38
+
39
+ # Store original exception hook
40
+ _original_excepthook: Any = None
41
+
42
+
43
+ def _install_exception_hook() -> None:
44
+ """Install custom exception hook to capture unhandled exceptions with full context."""
45
+ import sys
46
+
47
+ global _original_excepthook
48
+
49
+ # Store original excepthook
50
+ _original_excepthook = sys.excepthook
51
+
52
+ def custom_excepthook(
53
+ exc_type: type[BaseException],
54
+ exc_value: BaseException,
55
+ exc_traceback: Any,
56
+ ) -> None:
57
+ """Custom exception hook that captures exceptions to PostHog."""
58
+ # Only capture Exception subclasses (not KeyboardInterrupt, SystemExit, etc.)
59
+ if isinstance(exc_value, Exception):
60
+ capture_exception(exc_value)
61
+
62
+ # Flush PostHog to ensure exception is sent before process exits
63
+ if _posthog_client is not None:
64
+ try:
65
+ _posthog_client.flush() # type: ignore[no-untyped-call]
66
+ except Exception: # noqa: S110 - intentionally silent during crash
67
+ pass
68
+
69
+ # Call original excepthook to maintain normal behavior
70
+ if _original_excepthook is not None:
71
+ _original_excepthook(exc_type, exc_value, exc_traceback)
72
+
73
+ sys.excepthook = custom_excepthook
74
+ logger.debug("Installed custom exception hook for PostHog")
23
75
 
24
76
 
25
77
  def setup_posthog_observability() -> bool:
26
- """Set up PostHog analytics for usage tracking.
78
+ """Set up PostHog analytics for usage tracking and exception capture.
27
79
 
28
80
  Returns:
29
81
  True if PostHog was successfully set up, False otherwise
30
82
  """
31
- global _posthog_client, _shotgun_instance_id
83
+ global _posthog_client, _shotgun_instance_id, _user_context
32
84
 
33
85
  try:
34
86
  # Check if PostHog is already initialized
@@ -46,27 +98,49 @@ def setup_posthog_observability() -> bool:
46
98
 
47
99
  logger.debug("Using PostHog API key from settings")
48
100
 
49
- # Determine environment based on version
50
- # Dev versions contain "dev", "rc", "alpha", or "beta"
51
- if any(marker in __version__ for marker in ["dev", "rc", "alpha", "beta"]):
52
- environment = "development"
53
- else:
54
- environment = "production"
101
+ environment = _get_environment()
55
102
 
56
- # Initialize PostHog client
57
- posthog.api_key = api_key
58
- posthog.host = "https://us.i.posthog.com" # Use US cloud instance
103
+ def on_error(e: Exception, batch: list[dict[str, Any]]) -> None:
104
+ """Handle PostHog errors."""
105
+ logger.warning("PostHog error: %s", e)
59
106
 
60
- # Store the client for later use
61
- _posthog_client = posthog
107
+ # Initialize PostHog client (we use custom exception hook instead of autocapture)
108
+ _posthog_client = Posthog(
109
+ project_api_key=api_key,
110
+ host="https://us.i.posthog.com",
111
+ on_error=on_error,
112
+ )
62
113
 
63
- # Cache the shotgun instance ID for later use (avoids async issues)
114
+ # Cache user context for later use (avoids async issues in exception capture)
64
115
  try:
65
116
  import asyncio
66
117
 
67
118
  config_manager = get_config_manager()
68
119
  _shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id())
69
120
 
121
+ # Load config to get account type and model info
122
+ config = asyncio.run(config_manager.load())
123
+
124
+ # Cache user context for exception tracking
125
+ is_shotgun_account = config.shotgun.has_valid_account
126
+ _user_context["account_type"] = "shotgun" if is_shotgun_account else "byok"
127
+ _user_context["selected_model"] = (
128
+ config.selected_model.value if config.selected_model else None
129
+ )
130
+
131
+ # Set user properties for tracking
132
+ _posthog_client.capture(
133
+ distinct_id=_shotgun_instance_id,
134
+ event="$identify",
135
+ properties={
136
+ "$set": {
137
+ "app_version": __version__,
138
+ "environment": environment,
139
+ "account_type": _user_context["account_type"],
140
+ },
141
+ },
142
+ )
143
+
70
144
  logger.debug(
71
145
  "PostHog initialized with shotgun instance ID: %s",
72
146
  _shotgun_instance_id,
@@ -75,6 +149,9 @@ def setup_posthog_observability() -> bool:
75
149
  logger.warning("Failed to load shotgun instance ID: %s", e)
76
150
  # Continue anyway - we'll try to get it during event tracking
77
151
 
152
+ # Install custom exception hook to capture unhandled exceptions with full context
153
+ _install_exception_hook()
154
+
78
155
  logger.debug(
79
156
  "PostHog analytics configured successfully (environment: %s, version: %s)",
80
157
  environment,
@@ -112,12 +189,7 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
112
189
  if properties is None:
113
190
  properties = {}
114
191
  properties["version"] = __version__
115
-
116
- # Determine environment
117
- if any(marker in __version__ for marker in ["dev", "rc", "alpha", "beta"]):
118
- properties["environment"] = "development"
119
- else:
120
- properties["environment"] = "production"
192
+ properties["environment"] = _get_environment()
121
193
 
122
194
  # Track the event using PostHog's capture method
123
195
  _posthog_client.capture(
@@ -128,13 +200,82 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
128
200
  logger.warning("Failed to track PostHog event '%s': %s", event_name, e)
129
201
 
130
202
 
203
+ def capture_exception(
204
+ exception: Exception,
205
+ properties: dict[str, Any] | None = None,
206
+ ) -> None:
207
+ """Manually capture an exception in PostHog.
208
+
209
+ Uses the PostHog SDK's built-in capture_exception method which properly
210
+ formats the exception with stack traces, fingerprinting, and all required
211
+ fields for PostHog's Error Tracking system.
212
+
213
+ Note: UserActionableError exceptions are filtered out as they represent
214
+ expected user conditions, not bugs.
215
+
216
+ Args:
217
+ exception: The exception to capture
218
+ properties: Optional additional properties
219
+ """
220
+ global _posthog_client, _shotgun_instance_id
221
+
222
+ if _posthog_client is None:
223
+ logger.debug("PostHog not initialized, skipping exception capture")
224
+ return
225
+
226
+ # Filter out user-actionable errors - these are expected conditions
227
+ if isinstance(exception, UserActionableError):
228
+ logger.debug(
229
+ "Skipping UserActionableError in PostHog exception capture: %s",
230
+ type(exception).__name__,
231
+ )
232
+ return
233
+
234
+ try:
235
+ if _shotgun_instance_id is None:
236
+ logger.warning(
237
+ "Shotgun instance ID not available, skipping exception capture"
238
+ )
239
+ return
240
+
241
+ # Build properties with app/user context
242
+ event_properties: dict[str, Any] = {
243
+ # App info
244
+ "version": __version__,
245
+ "environment": _get_environment(),
246
+ # System info
247
+ "python_version": platform.python_version(),
248
+ "os": platform.system(),
249
+ "os_version": platform.release(),
250
+ # User context
251
+ "shotgun_instance_id": _shotgun_instance_id,
252
+ "account_type": _user_context.get("account_type"),
253
+ "selected_model": _user_context.get("selected_model"),
254
+ }
255
+
256
+ # Add custom properties
257
+ if properties:
258
+ event_properties.update(properties)
259
+
260
+ # Use the SDK's built-in capture_exception method which properly
261
+ # formats the exception with stack traces, fingerprinting, etc.
262
+ _posthog_client.capture_exception(
263
+ exception,
264
+ distinct_id=_shotgun_instance_id,
265
+ properties=event_properties,
266
+ )
267
+ logger.debug("Captured exception in PostHog: %s", type(exception).__name__)
268
+ except Exception as e:
269
+ logger.warning("Failed to capture exception in PostHog: %s", e)
270
+
271
+
131
272
  def shutdown() -> None:
132
273
  """Shutdown PostHog client and flush any pending events."""
133
274
  global _posthog_client
134
275
 
135
276
  if _posthog_client is not None:
136
277
  try:
137
- _posthog_client.shutdown()
278
+ _posthog_client.shutdown() # type: ignore[no-untyped-call]
138
279
  logger.debug("PostHog client shutdown successfully")
139
280
  except Exception as e:
140
281
  logger.warning("Error shutting down PostHog: %s", e)
@@ -10,6 +10,8 @@ Your primary job is to generate Agents.md or CLAUDE.md files following the https
10
10
 
11
11
  {% include 'agents/partials/common_agent_system_prompt.j2' %}
12
12
 
13
+ {% include 'agents/partials/router_delegation_mode.j2' %}
14
+
13
15
  ## MEMORY MANAGEMENT PROTOCOL
14
16
 
15
17
  - You can write to ANY file EXCEPT protected files
@@ -0,0 +1,48 @@
1
+ You are a File Reading Agent - a lightweight, focused assistant for finding and reading files.
2
+
3
+ ## YOUR PURPOSE
4
+
5
+ Your job is to:
6
+ 1. **Search** for files matching the user's description
7
+ 2. **Read** file contents (including PDFs and images)
8
+ 3. **Summarize** what you found
9
+ 4. **Return** the file paths so they can be loaded into context
10
+
11
+ ## TOOLS AVAILABLE
12
+
13
+ - `directory_lister` - List contents of a directory
14
+ - `file_read` - Read text file contents
15
+ - `read_file` - Read file by path
16
+ - `multimodal_file_read` - Verify PDFs and images exist and get their absolute paths
17
+
18
+ ## WORKFLOW
19
+
20
+ 1. **Understand the request** - What file is the user looking for?
21
+ 2. **Search systematically** - Use directory_lister to explore, then read candidates
22
+ 3. **For PDFs/images** - Use `multimodal_file_read` to verify they exist and get the absolute path
23
+ 4. **Identify the right file** - Confirm you found what was requested
24
+ 5. **Return results** - Include the absolute file path in `files_found` so the Router can load it
25
+
26
+ ## OUTPUT FORMAT
27
+
28
+ When you find the file(s):
29
+ - Provide a brief summary of what you found
30
+ - **Always include absolute file paths in the `files_found` field** of your response
31
+ - This allows the Router to load the files into its context
32
+
33
+ Example response:
34
+ ```json
35
+ {
36
+ "response": "Found the user stories document at /home/user/docs/user_stories_v2.pdf. It contains 3 user stories about authentication, profile management, and notifications.",
37
+ "files_found": ["/home/user/docs/user_stories_v2.pdf"]
38
+ }
39
+ ```
40
+
41
+ ## IMPORTANT NOTES
42
+
43
+ - You are a **read-only** agent - you cannot create or modify files
44
+ - Use `multimodal_file_read` for PDFs and images to verify they exist and get absolute paths
45
+ - The Router will load the actual file content using the paths you return in `files_found`
46
+ - Be efficient - search systematically, don't read every file
47
+ - Return file paths as **absolute paths** for reliability
48
+ - If you can't find the file, say so clearly and suggest alternatives