shotgun-sh 0.2.17__py3-none-any.whl → 0.4.0.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.
Files changed (150) hide show
  1. shotgun/agents/agent_manager.py +219 -37
  2. shotgun/agents/common.py +79 -78
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/manager.py +364 -53
  6. shotgun/agents/config/models.py +101 -21
  7. shotgun/agents/config/provider.py +51 -13
  8. shotgun/agents/config/streaming_test.py +119 -0
  9. shotgun/agents/context_analyzer/analyzer.py +6 -2
  10. shotgun/agents/conversation/__init__.py +18 -0
  11. shotgun/agents/conversation/filters.py +164 -0
  12. shotgun/agents/conversation/history/chunking.py +278 -0
  13. shotgun/agents/{history → conversation/history}/compaction.py +27 -1
  14. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  15. shotgun/agents/conversation/history/file_content_deduplication.py +239 -0
  16. shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
  17. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
  18. shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
  19. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
  20. shotgun/agents/error/__init__.py +11 -0
  21. shotgun/agents/error/models.py +19 -0
  22. shotgun/agents/export.py +12 -13
  23. shotgun/agents/models.py +66 -1
  24. shotgun/agents/plan.py +12 -13
  25. shotgun/agents/research.py +13 -10
  26. shotgun/agents/router/__init__.py +47 -0
  27. shotgun/agents/router/models.py +376 -0
  28. shotgun/agents/router/router.py +185 -0
  29. shotgun/agents/router/tools/__init__.py +18 -0
  30. shotgun/agents/router/tools/delegation_tools.py +503 -0
  31. shotgun/agents/router/tools/plan_tools.py +322 -0
  32. shotgun/agents/runner.py +230 -0
  33. shotgun/agents/specify.py +12 -13
  34. shotgun/agents/tasks.py +12 -13
  35. shotgun/agents/tools/file_management.py +49 -1
  36. shotgun/agents/tools/registry.py +2 -0
  37. shotgun/agents/tools/web_search/__init__.py +1 -2
  38. shotgun/agents/tools/web_search/gemini.py +1 -3
  39. shotgun/agents/tools/web_search/openai.py +1 -1
  40. shotgun/build_constants.py +2 -2
  41. shotgun/cli/clear.py +1 -1
  42. shotgun/cli/compact.py +5 -3
  43. shotgun/cli/context.py +44 -1
  44. shotgun/cli/error_handler.py +24 -0
  45. shotgun/cli/export.py +34 -34
  46. shotgun/cli/plan.py +34 -34
  47. shotgun/cli/research.py +17 -9
  48. shotgun/cli/spec/__init__.py +5 -0
  49. shotgun/cli/spec/backup.py +81 -0
  50. shotgun/cli/spec/commands.py +132 -0
  51. shotgun/cli/spec/models.py +48 -0
  52. shotgun/cli/spec/pull_service.py +219 -0
  53. shotgun/cli/specify.py +20 -19
  54. shotgun/cli/tasks.py +34 -34
  55. shotgun/codebase/core/change_detector.py +1 -1
  56. shotgun/codebase/core/ingestor.py +154 -8
  57. shotgun/codebase/core/manager.py +1 -1
  58. shotgun/codebase/models.py +2 -0
  59. shotgun/exceptions.py +325 -0
  60. shotgun/llm_proxy/__init__.py +17 -0
  61. shotgun/llm_proxy/client.py +215 -0
  62. shotgun/llm_proxy/models.py +137 -0
  63. shotgun/logging_config.py +42 -0
  64. shotgun/main.py +4 -0
  65. shotgun/posthog_telemetry.py +1 -1
  66. shotgun/prompts/agents/export.j2 +2 -0
  67. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +23 -3
  68. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  69. shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
  70. shotgun/prompts/agents/plan.j2 +29 -1
  71. shotgun/prompts/agents/research.j2 +75 -23
  72. shotgun/prompts/agents/router.j2 +440 -0
  73. shotgun/prompts/agents/specify.j2 +80 -4
  74. shotgun/prompts/agents/state/system_state.j2 +15 -8
  75. shotgun/prompts/agents/tasks.j2 +63 -23
  76. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  77. shotgun/prompts/history/combine_summaries.j2 +53 -0
  78. shotgun/sdk/codebase.py +14 -3
  79. shotgun/settings.py +5 -0
  80. shotgun/shotgun_web/__init__.py +67 -1
  81. shotgun/shotgun_web/client.py +42 -1
  82. shotgun/shotgun_web/constants.py +46 -0
  83. shotgun/shotgun_web/exceptions.py +29 -0
  84. shotgun/shotgun_web/models.py +390 -0
  85. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  86. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  87. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  88. shotgun/shotgun_web/shared_specs/models.py +71 -0
  89. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  90. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  91. shotgun/shotgun_web/specs_client.py +703 -0
  92. shotgun/shotgun_web/supabase_client.py +31 -0
  93. shotgun/tui/app.py +78 -15
  94. shotgun/tui/components/mode_indicator.py +120 -25
  95. shotgun/tui/components/status_bar.py +2 -2
  96. shotgun/tui/containers.py +1 -1
  97. shotgun/tui/dependencies.py +64 -9
  98. shotgun/tui/layout.py +5 -0
  99. shotgun/tui/protocols.py +37 -0
  100. shotgun/tui/screens/chat/chat.tcss +9 -1
  101. shotgun/tui/screens/chat/chat_screen.py +1015 -106
  102. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
  103. shotgun/tui/screens/chat_screen/command_providers.py +13 -89
  104. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  105. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  106. shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
  107. shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
  108. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  109. shotgun/tui/screens/chat_screen/messages.py +219 -0
  110. shotgun/tui/screens/confirmation_dialog.py +40 -0
  111. shotgun/tui/screens/directory_setup.py +45 -41
  112. shotgun/tui/screens/feedback.py +10 -3
  113. shotgun/tui/screens/github_issue.py +11 -2
  114. shotgun/tui/screens/model_picker.py +28 -8
  115. shotgun/tui/screens/onboarding.py +179 -26
  116. shotgun/tui/screens/pipx_migration.py +58 -6
  117. shotgun/tui/screens/provider_config.py +66 -8
  118. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  119. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  120. shotgun/tui/screens/shared_specs/models.py +56 -0
  121. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  122. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  123. shotgun/tui/screens/shotgun_auth.py +110 -16
  124. shotgun/tui/screens/spec_pull.py +288 -0
  125. shotgun/tui/screens/welcome.py +123 -0
  126. shotgun/tui/services/conversation_service.py +5 -2
  127. shotgun/tui/utils/mode_progress.py +20 -86
  128. shotgun/tui/widgets/__init__.py +2 -1
  129. shotgun/tui/widgets/approval_widget.py +152 -0
  130. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  131. shotgun/tui/widgets/plan_panel.py +129 -0
  132. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  133. shotgun/tui/widgets/widget_coordinator.py +1 -1
  134. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +11 -4
  135. shotgun_sh-0.4.0.dev1.dist-info/RECORD +242 -0
  136. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +1 -1
  137. shotgun_sh-0.2.17.dist-info/RECORD +0 -194
  138. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  139. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  140. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  141. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  142. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  143. /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
  144. /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
  145. /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
  146. /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
  147. /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
  148. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
  149. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
  150. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,48 @@
1
+ """Pydantic models for spec CLI commands."""
2
+
3
+ from datetime import datetime
4
+ from enum import StrEnum
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class PullSource(StrEnum):
10
+ """Source of spec pull operation for analytics."""
11
+
12
+ CLI = "cli"
13
+ TUI = "tui"
14
+
15
+
16
+ class PullPhase(StrEnum):
17
+ """Phases during spec pull operation for analytics."""
18
+
19
+ STARTING = "starting"
20
+ FETCHING = "fetching"
21
+ BACKUP = "backup"
22
+ DOWNLOADING = "downloading"
23
+ FINALIZING = "finalizing"
24
+
25
+
26
+ class SpecMeta(BaseModel):
27
+ """Metadata stored in .shotgun/meta.json after pulling a spec.
28
+
29
+ This file tracks the source of the local spec files and is used
30
+ by the TUI to display version information and enable future sync operations.
31
+ """
32
+
33
+ version_id: str = Field(description="Pulled version UUID")
34
+ spec_id: str = Field(description="Spec UUID")
35
+ spec_name: str = Field(description="Spec name at time of pull")
36
+ workspace_id: str = Field(description="Workspace UUID")
37
+ is_latest: bool = Field(
38
+ description="Whether this was the latest version when pulled"
39
+ )
40
+ pulled_at: datetime = Field(description="Timestamp when spec was pulled (UTC)")
41
+ backup_path: str | None = Field(
42
+ default=None,
43
+ description="Path where previous .shotgun/ files were backed up",
44
+ )
45
+ web_url: str | None = Field(
46
+ default=None,
47
+ description="URL to view this version in the web UI",
48
+ )
@@ -0,0 +1,219 @@
1
+ """Shared spec pull service for CLI and TUI."""
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from shotgun.logging_config import get_logger
10
+ from shotgun.posthog_telemetry import track_event
11
+ from shotgun.shotgun_web.specs_client import SpecsClient
12
+ from shotgun.shotgun_web.supabase_client import download_file_from_url
13
+
14
+ from .backup import clear_shotgun_dir, create_backup
15
+ from .models import PullPhase, PullSource, SpecMeta
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class PullProgress:
22
+ """Progress update during spec pull."""
23
+
24
+ phase: str
25
+ file_index: int | None = None
26
+ total_files: int | None = None
27
+ current_file: str | None = None
28
+
29
+
30
+ @dataclass
31
+ class PullResult:
32
+ """Result of a spec pull operation."""
33
+
34
+ success: bool
35
+ spec_name: str | None = None
36
+ file_count: int = 0
37
+ backup_path: str | None = None
38
+ web_url: str | None = None
39
+ error: str | None = None
40
+
41
+
42
+ class CancelledError(Exception):
43
+ """Raised when pull is cancelled."""
44
+
45
+
46
+ class SpecPullService:
47
+ """Service for pulling spec versions from cloud."""
48
+
49
+ def __init__(self) -> None:
50
+ self._client = SpecsClient()
51
+
52
+ async def pull_version(
53
+ self,
54
+ version_id: str,
55
+ shotgun_dir: Path,
56
+ on_progress: Callable[[PullProgress], None] | None = None,
57
+ is_cancelled: Callable[[], bool] | None = None,
58
+ source: PullSource = PullSource.CLI,
59
+ ) -> PullResult:
60
+ """Pull a spec version to the local directory.
61
+
62
+ Args:
63
+ version_id: The version UUID to pull
64
+ shotgun_dir: Target directory (typically .shotgun/)
65
+ on_progress: Optional callback for progress updates
66
+ is_cancelled: Optional callback to check if cancelled
67
+ source: Source of the pull request (CLI or TUI)
68
+
69
+ Returns:
70
+ PullResult with success status and details
71
+ """
72
+ start_time = time.time()
73
+ current_phase: PullPhase = PullPhase.STARTING
74
+ track_event("spec_pull_started", {"source": source.value})
75
+
76
+ def report(
77
+ phase: str,
78
+ file_index: int | None = None,
79
+ total_files: int | None = None,
80
+ current_file: str | None = None,
81
+ ) -> None:
82
+ if on_progress:
83
+ on_progress(
84
+ PullProgress(
85
+ phase=phase,
86
+ file_index=file_index,
87
+ total_files=total_files,
88
+ current_file=current_file,
89
+ )
90
+ )
91
+
92
+ def check_cancelled() -> None:
93
+ nonlocal current_phase
94
+ if is_cancelled and is_cancelled():
95
+ track_event(
96
+ "spec_pull_cancelled",
97
+ {"source": source.value, "phase": current_phase.value},
98
+ )
99
+ raise CancelledError()
100
+
101
+ try:
102
+ # Phase 1: Fetch version metadata
103
+ current_phase = PullPhase.FETCHING
104
+ report("Fetching version info...")
105
+ check_cancelled()
106
+
107
+ response = await self._client.get_version_with_files(version_id)
108
+ spec_name = response.spec_name
109
+ files = response.files
110
+
111
+ if not files:
112
+ track_event(
113
+ "spec_pull_failed",
114
+ {
115
+ "source": source.value,
116
+ "error_type": "EmptyVersion",
117
+ "phase": current_phase.value,
118
+ },
119
+ )
120
+ return PullResult(
121
+ success=False,
122
+ spec_name=spec_name,
123
+ error="No files in this version.",
124
+ )
125
+
126
+ # Phase 2: Backup existing content
127
+ current_phase = PullPhase.BACKUP
128
+ backup_path: str | None = None
129
+ if shotgun_dir.exists():
130
+ report("Backing up existing files...")
131
+ check_cancelled()
132
+
133
+ backup_path = await create_backup(shotgun_dir)
134
+ if backup_path:
135
+ clear_shotgun_dir(shotgun_dir)
136
+
137
+ # Ensure directory exists
138
+ shotgun_dir.mkdir(parents=True, exist_ok=True)
139
+
140
+ # Phase 3: Download files
141
+ current_phase = PullPhase.DOWNLOADING
142
+ total_files = len(files)
143
+ total_bytes = 0
144
+ for idx, file_info in enumerate(files):
145
+ check_cancelled()
146
+
147
+ report(
148
+ f"Downloading files ({idx + 1}/{total_files})...",
149
+ file_index=idx,
150
+ total_files=total_files,
151
+ current_file=file_info.relative_path,
152
+ )
153
+
154
+ if not file_info.download_url:
155
+ logger.warning(
156
+ "Skipping file without download URL: %s",
157
+ file_info.relative_path,
158
+ )
159
+ continue
160
+
161
+ content = await download_file_from_url(file_info.download_url)
162
+ total_bytes += file_info.size_bytes
163
+
164
+ local_path = shotgun_dir / file_info.relative_path
165
+ local_path.parent.mkdir(parents=True, exist_ok=True)
166
+ local_path.write_bytes(content)
167
+
168
+ # Phase 4: Write meta.json
169
+ current_phase = PullPhase.FINALIZING
170
+ report("Finalizing...")
171
+ check_cancelled()
172
+
173
+ meta = SpecMeta(
174
+ version_id=response.version.id,
175
+ spec_id=response.spec_id,
176
+ spec_name=response.spec_name,
177
+ workspace_id=response.workspace_id,
178
+ is_latest=response.version.is_latest,
179
+ pulled_at=datetime.now(timezone.utc),
180
+ backup_path=backup_path,
181
+ web_url=response.web_url,
182
+ )
183
+ meta_path = shotgun_dir / "meta.json"
184
+ meta_path.write_text(meta.model_dump_json(indent=2))
185
+
186
+ # Track successful completion
187
+ duration = time.time() - start_time
188
+ track_event(
189
+ "spec_pull_completed",
190
+ {
191
+ "source": source.value,
192
+ "file_count": total_files,
193
+ "total_bytes": total_bytes,
194
+ "duration_seconds": round(duration, 2),
195
+ "had_backup": backup_path is not None,
196
+ },
197
+ )
198
+
199
+ return PullResult(
200
+ success=True,
201
+ spec_name=spec_name,
202
+ file_count=total_files,
203
+ backup_path=backup_path,
204
+ web_url=response.web_url,
205
+ )
206
+
207
+ except CancelledError:
208
+ # Already tracked in check_cancelled()
209
+ raise
210
+ except Exception as e:
211
+ track_event(
212
+ "spec_pull_failed",
213
+ {
214
+ "source": source.value,
215
+ "error_type": type(e).__name__,
216
+ "phase": current_phase.value,
217
+ },
218
+ )
219
+ raise
shotgun/cli/specify.py CHANGED
@@ -11,6 +11,8 @@ from shotgun.agents.specify import (
11
11
  create_specify_agent,
12
12
  run_specify_agent,
13
13
  )
14
+ from shotgun.cli.error_handler import print_agent_error
15
+ from shotgun.exceptions import ErrorNotPickedUpBySentry
14
16
  from shotgun.logging_config import get_logger
15
17
 
16
18
  app = typer.Typer(
@@ -44,26 +46,25 @@ def specify(
44
46
 
45
47
  logger.info("📝 Specification Requirement: %s", requirement)
46
48
 
47
- try:
48
- # Create agent dependencies
49
- agent_runtime_options = AgentRuntimeOptions(
50
- interactive_mode=not non_interactive
51
- )
49
+ # Create agent dependencies
50
+ agent_runtime_options = AgentRuntimeOptions(interactive_mode=not non_interactive)
52
51
 
53
- # Create the specify agent with deps and provider
54
- agent, deps = asyncio.run(create_specify_agent(agent_runtime_options, provider))
52
+ # Create the specify agent with deps and provider
53
+ agent, deps = asyncio.run(create_specify_agent(agent_runtime_options, provider))
55
54
 
56
- # Start specification process
57
- logger.info("📋 Starting specification generation...")
58
- result = asyncio.run(run_specify_agent(agent, requirement, deps))
55
+ # Start specification process with error handling
56
+ logger.info("📋 Starting specification generation...")
59
57
 
60
- # Display results
61
- logger.info("✅ Specification Complete!")
62
- logger.info("📋 Results:")
63
- logger.info("%s", result.output)
58
+ async def async_specify() -> None:
59
+ try:
60
+ result = await run_specify_agent(agent, requirement, deps)
61
+ logger.info("✅ Specification Complete!")
62
+ logger.info("📋 Results:")
63
+ logger.info("%s", result.output)
64
+ except ErrorNotPickedUpBySentry as e:
65
+ print_agent_error(e)
66
+ except Exception as e:
67
+ logger.exception("Unexpected error in specify command")
68
+ print(f"⚠️ An unexpected error occurred: {str(e)}")
64
69
 
65
- except Exception as e:
66
- logger.error("❌ Error during specification: %s", str(e))
67
- import traceback
68
-
69
- logger.debug("Full traceback:\n%s", traceback.format_exc())
70
+ asyncio.run(async_specify())
shotgun/cli/tasks.py CHANGED
@@ -11,7 +11,10 @@ from shotgun.agents.tasks import (
11
11
  create_tasks_agent,
12
12
  run_tasks_agent,
13
13
  )
14
+ from shotgun.cli.error_handler import print_agent_error
15
+ from shotgun.exceptions import ErrorNotPickedUpBySentry
14
16
  from shotgun.logging_config import get_logger
17
+ from shotgun.posthog_telemetry import track_event
15
18
 
16
19
  app = typer.Typer(name="tasks", help="Generate task lists with agentic approach")
17
20
  logger = get_logger(__name__)
@@ -42,37 +45,34 @@ def tasks(
42
45
 
43
46
  logger.info("📋 Task Creation Instruction: %s", instruction)
44
47
 
45
- try:
46
- # Track tasks command usage
47
- from shotgun.posthog_telemetry import track_event
48
-
49
- track_event(
50
- "tasks_command",
51
- {
52
- "non_interactive": non_interactive,
53
- "provider": provider.value if provider else "default",
54
- },
55
- )
56
-
57
- # Create agent dependencies
58
- agent_runtime_options = AgentRuntimeOptions(
59
- interactive_mode=not non_interactive
60
- )
61
-
62
- # Create the tasks agent with deps and provider
63
- agent, deps = asyncio.run(create_tasks_agent(agent_runtime_options, provider))
64
-
65
- # Start task creation process
66
- logger.info("🎯 Starting task creation...")
67
- result = asyncio.run(run_tasks_agent(agent, instruction, deps))
68
-
69
- # Display results
70
- logger.info("✅ Task Creation Complete!")
71
- logger.info("📋 Results:")
72
- logger.info("%s", result.output)
73
-
74
- except Exception as e:
75
- logger.error("❌ Error during task creation: %s", str(e))
76
- import traceback
77
-
78
- logger.debug("Full traceback:\n%s", traceback.format_exc())
48
+ # Track tasks command usage
49
+ track_event(
50
+ "tasks_command",
51
+ {
52
+ "non_interactive": non_interactive,
53
+ "provider": provider.value if provider else "default",
54
+ },
55
+ )
56
+
57
+ # Create agent dependencies
58
+ agent_runtime_options = AgentRuntimeOptions(interactive_mode=not non_interactive)
59
+
60
+ # Create the tasks agent with deps and provider
61
+ agent, deps = asyncio.run(create_tasks_agent(agent_runtime_options, provider))
62
+
63
+ # Start task creation process with error handling
64
+ logger.info("🎯 Starting task creation...")
65
+
66
+ async def async_tasks() -> None:
67
+ try:
68
+ result = await run_tasks_agent(agent, instruction, deps)
69
+ logger.info(" Task Creation Complete!")
70
+ logger.info("📋 Results:")
71
+ logger.info("%s", result.output)
72
+ except ErrorNotPickedUpBySentry as e:
73
+ print_agent_error(e)
74
+ except Exception as e:
75
+ logger.exception("Unexpected error in tasks command")
76
+ print(f"⚠️ An unexpected error occurred: {str(e)}")
77
+
78
+ asyncio.run(async_tasks())
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
  from typing import Any, cast
8
8
 
9
9
  import aiofiles
10
- import kuzu
10
+ import real_ladybug as kuzu
11
11
 
12
12
  from shotgun.logging_config import get_logger
13
13
 
@@ -6,11 +6,12 @@ import os
6
6
  import time
7
7
  import uuid
8
8
  from collections import defaultdict
9
+ from collections.abc import Callable
9
10
  from pathlib import Path
10
11
  from typing import Any
11
12
 
12
13
  import aiofiles
13
- import kuzu
14
+ import real_ladybug as kuzu
14
15
  from tree_sitter import Node, Parser, QueryCursor
15
16
 
16
17
  from shotgun.codebase.core.language_config import LANGUAGE_CONFIGS, get_language_config
@@ -198,11 +199,21 @@ class Ingestor:
198
199
  return True
199
200
  return False
200
201
 
201
- def flush_nodes(self) -> None:
202
- """Flush pending node insertions to the database."""
202
+ def flush_nodes(
203
+ self,
204
+ progress_callback: Callable[[int, int], None] | None = None,
205
+ ) -> None:
206
+ """Flush pending node insertions to the database.
207
+
208
+ Args:
209
+ progress_callback: Optional callback(current, total) for progress reporting
210
+ """
203
211
  if not self.node_buffer:
204
212
  return
205
213
 
214
+ total_nodes = len(self.node_buffer)
215
+ processed = 0
216
+
206
217
  # Group nodes by label
207
218
  nodes_by_label: dict[str, list[dict[str, Any]]] = defaultdict(list)
208
219
  for label, properties in self.node_buffer:
@@ -239,9 +250,18 @@ class Ingestor:
239
250
  params = dict(zip(prop_names, prop_values, strict=False))
240
251
  self.conn.execute(query, params)
241
252
 
253
+ # Report progress
254
+ processed += 1
255
+ if progress_callback and processed % 10 == 0:
256
+ progress_callback(processed, total_nodes)
257
+
242
258
  except Exception as e:
243
259
  logger.error(f"Failed to insert {label} nodes: {e}")
244
260
 
261
+ # Final progress report
262
+ if progress_callback:
263
+ progress_callback(total_nodes, total_nodes)
264
+
245
265
  # Log node counts by type
246
266
  node_type_counts: dict[str, int] = {}
247
267
  for label, _ in self.node_buffer:
@@ -280,11 +300,21 @@ class Ingestor:
280
300
 
281
301
  # Don't auto-flush relationships - wait for explicit flush_all() to ensure nodes exist first
282
302
 
283
- def flush_relationships(self) -> None:
284
- """Flush pending relationship insertions to the database."""
303
+ def flush_relationships(
304
+ self,
305
+ progress_callback: Callable[[int, int], None] | None = None,
306
+ ) -> None:
307
+ """Flush pending relationship insertions to the database.
308
+
309
+ Args:
310
+ progress_callback: Optional callback(current, total) for progress reporting
311
+ """
285
312
  if not self.relationship_buffer:
286
313
  return
287
314
 
315
+ total_rels = len(self.relationship_buffer)
316
+ processed = 0
317
+
288
318
  # Group relationships by type
289
319
  rels_by_type: dict[
290
320
  str, list[tuple[str, str, Any, str, str, str, Any, dict[str, Any] | None]]
@@ -299,7 +329,7 @@ class Ingestor:
299
329
  to_label,
300
330
  to_key,
301
331
  to_value,
302
- properties,
332
+ _properties,
303
333
  ) = rel_data
304
334
 
305
335
  # Determine actual table name
@@ -323,7 +353,7 @@ class Ingestor:
323
353
  to_label,
324
354
  to_key,
325
355
  to_value,
326
- properties,
356
+ _properties,
327
357
  ) = rel_data
328
358
 
329
359
  # Build MATCH and MERGE query (use MERGE to avoid duplicate relationships)
@@ -337,6 +367,11 @@ class Ingestor:
337
367
  try:
338
368
  self.conn.execute(query, params)
339
369
  success_count += 1
370
+
371
+ # Report progress
372
+ processed += 1
373
+ if progress_callback and processed % 10 == 0:
374
+ progress_callback(processed, total_rels)
340
375
  except Exception as e:
341
376
  logger.error(
342
377
  f"Failed to create single relationship {table_name}: {from_label}({from_value}) -> {to_label}({to_value})"
@@ -360,6 +395,10 @@ class Ingestor:
360
395
  # Don't swallow the exception - let it propagate
361
396
  raise
362
397
 
398
+ # Final progress report
399
+ if progress_callback:
400
+ progress_callback(total_rels, total_rels)
401
+
363
402
  # Log summary of flushed relationships
364
403
  logger.info(
365
404
  f"Flushed {len(self.relationship_buffer)} relationships: {relationship_counts}"
@@ -586,6 +625,9 @@ class SimpleGraphBuilder:
586
625
  self.ignore_dirs = self.ignore_dirs.union(set(exclude_patterns))
587
626
  self.progress_callback = progress_callback
588
627
 
628
+ # Generate unique session ID for correlating timing events in PostHog
629
+ self._index_session_id = str(uuid.uuid4())[:8]
630
+
589
631
  # Caches
590
632
  self.structural_elements: dict[Path, str | None] = {}
591
633
  self.ast_cache: dict[Path, tuple[Node, str]] = {}
@@ -621,25 +663,129 @@ class SimpleGraphBuilder:
621
663
  # Don't let progress callback errors crash the build
622
664
  logger.debug(f"Progress callback error: {e}")
623
665
 
666
+ def _log_timing(
667
+ self,
668
+ phase: str,
669
+ duration: float,
670
+ items: int,
671
+ extra_props: dict[str, Any] | None = None,
672
+ ) -> None:
673
+ """Log timing data to PostHog for analysis."""
674
+ from shotgun.posthog_telemetry import track_event
675
+
676
+ properties: dict[str, Any] = {
677
+ "session_id": self._index_session_id,
678
+ "phase": phase,
679
+ "duration_seconds": round(duration, 3),
680
+ "item_count": items,
681
+ }
682
+ if extra_props:
683
+ properties.update(extra_props)
684
+
685
+ track_event("codebase_index_phase_completed", properties)
686
+
687
+ def _log_summary(
688
+ self,
689
+ total_duration: float,
690
+ total_files: int,
691
+ total_nodes: int,
692
+ total_relationships: int,
693
+ ) -> None:
694
+ """Log indexing summary event to PostHog."""
695
+ from shotgun.posthog_telemetry import track_event
696
+
697
+ track_event(
698
+ "codebase_index_completed",
699
+ {
700
+ "session_id": self._index_session_id,
701
+ "total_duration_seconds": round(total_duration, 3),
702
+ "total_files": total_files,
703
+ "total_nodes": total_nodes,
704
+ "total_relationships": total_relationships,
705
+ },
706
+ )
707
+
624
708
  async def run(self) -> None:
625
709
  """Run the three-pass graph building process."""
626
710
  logger.info(f"Building graph for project: {self.project_name}")
627
711
 
628
712
  # Pass 1: Structure
629
713
  logger.info("Pass 1: Identifying packages and folders...")
714
+ t0 = time.time()
630
715
  self._identify_structure()
716
+ t1 = time.time()
717
+ self._log_timing("structure", t1 - t0, len(self.structural_elements))
631
718
 
632
719
  # Pass 2: Definitions
633
720
  logger.info("Pass 2: Processing files and extracting definitions...")
721
+ t2 = time.time()
634
722
  await self._process_files()
723
+ t3 = time.time()
724
+ self._log_timing(
725
+ "definitions",
726
+ t3 - t2,
727
+ len(self.ast_cache),
728
+ {"file_count": len(self.ast_cache)},
729
+ )
635
730
 
636
731
  # Pass 3: Relationships
637
732
  logger.info("Pass 3: Processing relationships (calls, imports)...")
733
+ t4 = time.time()
638
734
  self._process_relationships()
735
+ t5 = time.time()
736
+ self._log_timing("relationships", t5 - t4, len(self.ast_cache))
639
737
 
640
738
  # Flush all pending operations
641
739
  logger.info("Flushing all data to database...")
642
- self.ingestor.flush_all()
740
+ t6 = time.time()
741
+ node_count = len(self.ingestor.node_buffer)
742
+
743
+ # Create progress callback for flush_nodes
744
+ def node_progress(current: int, total: int) -> None:
745
+ self._report_progress(
746
+ "flush_nodes", "Flushing nodes to database", current, total
747
+ )
748
+
749
+ self.ingestor.flush_nodes(progress_callback=node_progress)
750
+ self._report_progress(
751
+ "flush_nodes", "Flushing nodes to database", node_count, node_count, True
752
+ )
753
+ t7 = time.time()
754
+ self._log_timing("flush_nodes", t7 - t6, node_count, {"node_count": node_count})
755
+
756
+ rel_count = len(self.ingestor.relationship_buffer)
757
+
758
+ # Create progress callback for flush_relationships
759
+ def rel_progress(current: int, total: int) -> None:
760
+ self._report_progress(
761
+ "flush_relationships",
762
+ "Flushing relationships to database",
763
+ current,
764
+ total,
765
+ )
766
+
767
+ self.ingestor.flush_relationships(progress_callback=rel_progress)
768
+ self._report_progress(
769
+ "flush_relationships",
770
+ "Flushing relationships to database",
771
+ rel_count,
772
+ rel_count,
773
+ True,
774
+ )
775
+ t8 = time.time()
776
+ self._log_timing(
777
+ "flush_relationships", t8 - t7, rel_count, {"relationship_count": rel_count}
778
+ )
779
+
780
+ # Track summary event with totals (no PII - only numeric metadata)
781
+ total_duration = t8 - t0
782
+ self._log_summary(
783
+ total_duration=total_duration,
784
+ total_files=len(self.ast_cache),
785
+ total_nodes=node_count,
786
+ total_relationships=rel_count,
787
+ )
788
+
643
789
  logger.info("Graph building complete!")
644
790
 
645
791
  def _identify_structure(self) -> None:
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from typing import Any, ClassVar
13
13
 
14
14
  import anyio
15
- import kuzu
15
+ import real_ladybug as kuzu
16
16
  from watchdog.events import FileSystemEvent, FileSystemEventHandler
17
17
  from watchdog.observers import Observer
18
18
 
@@ -29,6 +29,8 @@ class ProgressPhase(StrEnum):
29
29
  STRUCTURE = "structure" # Identifying packages and folders
30
30
  DEFINITIONS = "definitions" # Processing files and extracting definitions
31
31
  RELATIONSHIPS = "relationships" # Processing relationships (calls, imports)
32
+ FLUSH_NODES = "flush_nodes" # Flushing nodes to database
33
+ FLUSH_RELATIONSHIPS = "flush_relationships" # Flushing relationships to database
32
34
 
33
35
 
34
36
  class IndexProgress(BaseModel):