shotgun-sh 0.2.23.dev1__py3-none-any.whl → 0.2.29.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/agent_manager.py +3 -3
- shotgun/agents/common.py +1 -1
- shotgun/agents/config/manager.py +36 -21
- shotgun/agents/config/models.py +30 -0
- shotgun/agents/config/provider.py +27 -14
- shotgun/agents/context_analyzer/analyzer.py +6 -2
- shotgun/agents/conversation/__init__.py +18 -0
- shotgun/agents/conversation/filters.py +164 -0
- shotgun/agents/conversation/history/chunking.py +278 -0
- shotgun/agents/{history → conversation/history}/compaction.py +27 -1
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
- shotgun/agents/tools/web_search/openai.py +1 -1
- shotgun/cli/clear.py +1 -1
- shotgun/cli/compact.py +5 -3
- shotgun/cli/context.py +1 -1
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +130 -0
- shotgun/cli/spec/models.py +30 -0
- shotgun/cli/spec/pull_service.py +165 -0
- shotgun/codebase/core/ingestor.py +153 -7
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +5 -3
- shotgun/main.py +2 -0
- shotgun/posthog_telemetry.py +1 -1
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -3
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/research.j2 +0 -3
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/shotgun_web/__init__.py +67 -1
- shotgun/shotgun_web/client.py +42 -1
- shotgun/shotgun_web/constants.py +46 -0
- shotgun/shotgun_web/exceptions.py +29 -0
- shotgun/shotgun_web/models.py +390 -0
- shotgun/shotgun_web/shared_specs/__init__.py +32 -0
- shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
- shotgun/shotgun_web/shared_specs/hasher.py +83 -0
- shotgun/shotgun_web/shared_specs/models.py +71 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +291 -0
- shotgun/shotgun_web/shared_specs/utils.py +34 -0
- shotgun/shotgun_web/specs_client.py +703 -0
- shotgun/shotgun_web/supabase_client.py +31 -0
- shotgun/tui/app.py +39 -0
- shotgun/tui/containers.py +1 -1
- shotgun/tui/layout.py +5 -0
- shotgun/tui/screens/chat/chat_screen.py +212 -16
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +147 -19
- shotgun/tui/screens/chat_screen/command_providers.py +10 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +0 -36
- shotgun/tui/screens/confirmation_dialog.py +40 -0
- shotgun/tui/screens/model_picker.py +7 -1
- shotgun/tui/screens/onboarding.py +149 -0
- shotgun/tui/screens/pipx_migration.py +46 -0
- shotgun/tui/screens/provider_config.py +41 -0
- shotgun/tui/screens/shared_specs/__init__.py +21 -0
- shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
- shotgun/tui/screens/shared_specs/models.py +56 -0
- shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
- shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
- shotgun/tui/screens/shotgun_auth.py +60 -6
- shotgun/tui/screens/spec_pull.py +286 -0
- shotgun/tui/screens/welcome.py +91 -0
- shotgun/tui/services/conversation_service.py +5 -2
- shotgun/tui/widgets/widget_coordinator.py +1 -1
- {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/METADATA +1 -1
- {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/RECORD +86 -59
- {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/WHEEL +1 -1
- /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
- /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
- /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
- {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.23.dev1.dist-info → shotgun_sh-0.2.29.dev2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Spec management commands for shotgun CLI."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskID, TextColumn
|
|
9
|
+
|
|
10
|
+
from shotgun.logging_config import get_logger
|
|
11
|
+
from shotgun.shotgun_web.exceptions import (
|
|
12
|
+
ForbiddenError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
UnauthorizedError,
|
|
15
|
+
)
|
|
16
|
+
from shotgun.tui import app as tui_app
|
|
17
|
+
from shotgun.utils.file_system_utils import get_shotgun_base_path
|
|
18
|
+
|
|
19
|
+
from .pull_service import CancelledError, PullProgress, SpecPullService
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="spec",
|
|
23
|
+
help="Manage shared specifications",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
)
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def pull(
|
|
32
|
+
version_id: Annotated[str, typer.Argument(help="Version ID to pull")],
|
|
33
|
+
no_tui: Annotated[
|
|
34
|
+
bool,
|
|
35
|
+
typer.Option("--no-tui", help="Run in CLI-only mode (requires existing auth)"),
|
|
36
|
+
] = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Pull a spec version from the cloud to local .shotgun/ directory.
|
|
39
|
+
|
|
40
|
+
Downloads all files for the specified version and writes them to the
|
|
41
|
+
local .shotgun/ directory. If the directory already has content, it
|
|
42
|
+
will be backed up to ~/.shotgun-sh/backups/ before being replaced.
|
|
43
|
+
|
|
44
|
+
By default, launches the TUI which handles authentication and shows
|
|
45
|
+
the pull progress. Use --no-tui for scripted/headless use (requires
|
|
46
|
+
existing authentication).
|
|
47
|
+
|
|
48
|
+
Example:
|
|
49
|
+
shotgun spec pull 2532e1c7-7068-4d23-9379-58ea439c592f
|
|
50
|
+
"""
|
|
51
|
+
if no_tui:
|
|
52
|
+
# CLI-only mode: do pull directly (requires existing auth)
|
|
53
|
+
success = asyncio.run(_async_pull(version_id))
|
|
54
|
+
if not success:
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
else:
|
|
57
|
+
# TUI mode: launch TUI which handles auth and pull
|
|
58
|
+
tui_app.run(pull_version_id=version_id)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _async_pull(version_id: str) -> bool:
|
|
62
|
+
"""Async implementation of spec pull command.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
True if pull was successful, False otherwise.
|
|
66
|
+
"""
|
|
67
|
+
shotgun_dir = get_shotgun_base_path()
|
|
68
|
+
service = SpecPullService()
|
|
69
|
+
|
|
70
|
+
# Track current progress state for rich display
|
|
71
|
+
current_task_id: TaskID | None = None
|
|
72
|
+
progress_ctx: Progress | None = None
|
|
73
|
+
|
|
74
|
+
def on_progress(p: PullProgress) -> None:
|
|
75
|
+
nonlocal current_task_id, progress_ctx
|
|
76
|
+
# For CLI, we just update the description - progress bar handled by result
|
|
77
|
+
if progress_ctx and current_task_id is not None:
|
|
78
|
+
progress_ctx.update(current_task_id, description=p.phase)
|
|
79
|
+
if p.total_files and p.file_index is not None:
|
|
80
|
+
pct = ((p.file_index + 1) / p.total_files) * 100
|
|
81
|
+
progress_ctx.update(current_task_id, completed=pct)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
with Progress(
|
|
85
|
+
SpinnerColumn(),
|
|
86
|
+
TextColumn("[progress.description]{task.description}"),
|
|
87
|
+
BarColumn(),
|
|
88
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
89
|
+
) as progress:
|
|
90
|
+
progress_ctx = progress
|
|
91
|
+
current_task_id = progress.add_task("Starting...", total=100)
|
|
92
|
+
|
|
93
|
+
result = await service.pull_version(
|
|
94
|
+
version_id=version_id,
|
|
95
|
+
shotgun_dir=shotgun_dir,
|
|
96
|
+
on_progress=on_progress,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if result.success:
|
|
100
|
+
console.print()
|
|
101
|
+
console.print(f"[green]Successfully pulled '{result.spec_name}'[/green]")
|
|
102
|
+
console.print(f" [dim]Files downloaded:[/dim] {result.file_count}")
|
|
103
|
+
if result.backup_path:
|
|
104
|
+
console.print(f" [dim]Previous backup:[/dim] {result.backup_path}")
|
|
105
|
+
if result.web_url:
|
|
106
|
+
console.print(f" [blue]View in browser:[/blue] {result.web_url}")
|
|
107
|
+
return True
|
|
108
|
+
else:
|
|
109
|
+
console.print(f"[red]Error: {result.error}[/red]")
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
except UnauthorizedError:
|
|
113
|
+
console.print(
|
|
114
|
+
"[red]Not authenticated. Please re-run the command to login.[/red]"
|
|
115
|
+
)
|
|
116
|
+
raise typer.Exit(1) from None
|
|
117
|
+
except NotFoundError:
|
|
118
|
+
console.print(f"[red]Version not found: {version_id}[/red]")
|
|
119
|
+
console.print("[dim]Check the version ID and try again.[/dim]")
|
|
120
|
+
raise typer.Exit(1) from None
|
|
121
|
+
except ForbiddenError:
|
|
122
|
+
console.print("[red]You don't have access to this spec.[/red]")
|
|
123
|
+
raise typer.Exit(1) from None
|
|
124
|
+
except CancelledError:
|
|
125
|
+
console.print("[yellow]Pull cancelled.[/yellow]")
|
|
126
|
+
raise typer.Exit(1) from None
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.exception("Unexpected error in spec pull")
|
|
129
|
+
console.print(f"[red]Unexpected error: {e}[/red]")
|
|
130
|
+
raise typer.Exit(1) from None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Pydantic models for spec CLI commands."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SpecMeta(BaseModel):
|
|
9
|
+
"""Metadata stored in .shotgun/meta.json after pulling a spec.
|
|
10
|
+
|
|
11
|
+
This file tracks the source of the local spec files and is used
|
|
12
|
+
by the TUI to display version information and enable future sync operations.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
version_id: str = Field(description="Pulled version UUID")
|
|
16
|
+
spec_id: str = Field(description="Spec UUID")
|
|
17
|
+
spec_name: str = Field(description="Spec name at time of pull")
|
|
18
|
+
workspace_id: str = Field(description="Workspace UUID")
|
|
19
|
+
is_latest: bool = Field(
|
|
20
|
+
description="Whether this was the latest version when pulled"
|
|
21
|
+
)
|
|
22
|
+
pulled_at: datetime = Field(description="Timestamp when spec was pulled (UTC)")
|
|
23
|
+
backup_path: str | None = Field(
|
|
24
|
+
default=None,
|
|
25
|
+
description="Path where previous .shotgun/ files were backed up",
|
|
26
|
+
)
|
|
27
|
+
web_url: str | None = Field(
|
|
28
|
+
default=None,
|
|
29
|
+
description="URL to view this version in the web UI",
|
|
30
|
+
)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Shared spec pull service for CLI and TUI."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from shotgun.logging_config import get_logger
|
|
9
|
+
from shotgun.shotgun_web.specs_client import SpecsClient
|
|
10
|
+
from shotgun.shotgun_web.supabase_client import download_file_from_url
|
|
11
|
+
|
|
12
|
+
from .backup import clear_shotgun_dir, create_backup
|
|
13
|
+
from .models import SpecMeta
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class PullProgress:
|
|
20
|
+
"""Progress update during spec pull."""
|
|
21
|
+
|
|
22
|
+
phase: str
|
|
23
|
+
file_index: int | None = None
|
|
24
|
+
total_files: int | None = None
|
|
25
|
+
current_file: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class PullResult:
|
|
30
|
+
"""Result of a spec pull operation."""
|
|
31
|
+
|
|
32
|
+
success: bool
|
|
33
|
+
spec_name: str | None = None
|
|
34
|
+
file_count: int = 0
|
|
35
|
+
backup_path: str | None = None
|
|
36
|
+
web_url: str | None = None
|
|
37
|
+
error: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CancelledError(Exception):
|
|
41
|
+
"""Raised when pull is cancelled."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SpecPullService:
|
|
45
|
+
"""Service for pulling spec versions from cloud."""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._client = SpecsClient()
|
|
49
|
+
|
|
50
|
+
async def pull_version(
|
|
51
|
+
self,
|
|
52
|
+
version_id: str,
|
|
53
|
+
shotgun_dir: Path,
|
|
54
|
+
on_progress: Callable[[PullProgress], None] | None = None,
|
|
55
|
+
is_cancelled: Callable[[], bool] | None = None,
|
|
56
|
+
) -> PullResult:
|
|
57
|
+
"""Pull a spec version to the local directory.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
version_id: The version UUID to pull
|
|
61
|
+
shotgun_dir: Target directory (typically .shotgun/)
|
|
62
|
+
on_progress: Optional callback for progress updates
|
|
63
|
+
is_cancelled: Optional callback to check if cancelled
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
PullResult with success status and details
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def report(
|
|
70
|
+
phase: str,
|
|
71
|
+
file_index: int | None = None,
|
|
72
|
+
total_files: int | None = None,
|
|
73
|
+
current_file: str | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
if on_progress:
|
|
76
|
+
on_progress(
|
|
77
|
+
PullProgress(
|
|
78
|
+
phase=phase,
|
|
79
|
+
file_index=file_index,
|
|
80
|
+
total_files=total_files,
|
|
81
|
+
current_file=current_file,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def check_cancelled() -> None:
|
|
86
|
+
if is_cancelled and is_cancelled():
|
|
87
|
+
raise CancelledError()
|
|
88
|
+
|
|
89
|
+
# Phase 1: Fetch version metadata
|
|
90
|
+
report("Fetching version info...")
|
|
91
|
+
check_cancelled()
|
|
92
|
+
|
|
93
|
+
response = await self._client.get_version_with_files(version_id)
|
|
94
|
+
spec_name = response.spec_name
|
|
95
|
+
files = response.files
|
|
96
|
+
|
|
97
|
+
if not files:
|
|
98
|
+
return PullResult(
|
|
99
|
+
success=False,
|
|
100
|
+
spec_name=spec_name,
|
|
101
|
+
error="No files in this version.",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Phase 2: Backup existing content
|
|
105
|
+
backup_path: str | None = None
|
|
106
|
+
if shotgun_dir.exists():
|
|
107
|
+
report("Backing up existing files...")
|
|
108
|
+
check_cancelled()
|
|
109
|
+
|
|
110
|
+
backup_path = await create_backup(shotgun_dir)
|
|
111
|
+
if backup_path:
|
|
112
|
+
clear_shotgun_dir(shotgun_dir)
|
|
113
|
+
|
|
114
|
+
# Ensure directory exists
|
|
115
|
+
shotgun_dir.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
# Phase 3: Download files
|
|
118
|
+
total_files = len(files)
|
|
119
|
+
for idx, file_info in enumerate(files):
|
|
120
|
+
check_cancelled()
|
|
121
|
+
|
|
122
|
+
report(
|
|
123
|
+
f"Downloading files ({idx + 1}/{total_files})...",
|
|
124
|
+
file_index=idx,
|
|
125
|
+
total_files=total_files,
|
|
126
|
+
current_file=file_info.relative_path,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if not file_info.download_url:
|
|
130
|
+
logger.warning(
|
|
131
|
+
"Skipping file without download URL: %s",
|
|
132
|
+
file_info.relative_path,
|
|
133
|
+
)
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
content = await download_file_from_url(file_info.download_url)
|
|
137
|
+
|
|
138
|
+
local_path = shotgun_dir / file_info.relative_path
|
|
139
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
local_path.write_bytes(content)
|
|
141
|
+
|
|
142
|
+
# Phase 4: Write meta.json
|
|
143
|
+
report("Finalizing...")
|
|
144
|
+
check_cancelled()
|
|
145
|
+
|
|
146
|
+
meta = SpecMeta(
|
|
147
|
+
version_id=response.version.id,
|
|
148
|
+
spec_id=response.spec_id,
|
|
149
|
+
spec_name=response.spec_name,
|
|
150
|
+
workspace_id=response.workspace_id,
|
|
151
|
+
is_latest=response.version.is_latest,
|
|
152
|
+
pulled_at=datetime.now(timezone.utc),
|
|
153
|
+
backup_path=backup_path,
|
|
154
|
+
web_url=response.web_url,
|
|
155
|
+
)
|
|
156
|
+
meta_path = shotgun_dir / "meta.json"
|
|
157
|
+
meta_path.write_text(meta.model_dump_json(indent=2))
|
|
158
|
+
|
|
159
|
+
return PullResult(
|
|
160
|
+
success=True,
|
|
161
|
+
spec_name=spec_name,
|
|
162
|
+
file_count=total_files,
|
|
163
|
+
backup_path=backup_path,
|
|
164
|
+
web_url=response.web_url,
|
|
165
|
+
)
|
|
@@ -6,6 +6,7 @@ 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
|
|
|
@@ -198,11 +199,21 @@ class Ingestor:
|
|
|
198
199
|
return True
|
|
199
200
|
return False
|
|
200
201
|
|
|
201
|
-
def flush_nodes(
|
|
202
|
-
|
|
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(
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
shotgun/codebase/models.py
CHANGED
|
@@ -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):
|
shotgun/exceptions.py
CHANGED
|
@@ -153,8 +153,9 @@ class BudgetExceededException(ShotgunAccountException):
|
|
|
153
153
|
return (
|
|
154
154
|
"⚠️ **Your Shotgun Account budget has been exceeded!**\n\n"
|
|
155
155
|
"Your account has reached its spending limit and cannot process more requests.\n\n"
|
|
156
|
-
"**
|
|
157
|
-
"
|
|
156
|
+
"**Action Required:** Top up your account to continue using Shotgun.\n\n"
|
|
157
|
+
"👉 **[Top Up Now at https://app.shotgun.sh/dashboard](https://app.shotgun.sh/dashboard)**\n\n"
|
|
158
|
+
"**Need help?** Contact us if you have questions about your budget.\n\n"
|
|
158
159
|
f"_Error details: {str(self)}_"
|
|
159
160
|
)
|
|
160
161
|
|
|
@@ -163,8 +164,9 @@ class BudgetExceededException(ShotgunAccountException):
|
|
|
163
164
|
return (
|
|
164
165
|
"⚠️ Your Shotgun Account budget has been exceeded!\n\n"
|
|
165
166
|
"Your account has reached its spending limit and cannot process more requests.\n\n"
|
|
167
|
+
"Action Required: Top up your account to continue using Shotgun.\n\n"
|
|
168
|
+
"→ Top Up Now: https://app.shotgun.sh/dashboard\n\n"
|
|
166
169
|
f"Need help? Contact: {SHOTGUN_CONTACT_EMAIL}\n\n"
|
|
167
|
-
"• Self-service budget increases are coming soon!\n\n"
|
|
168
170
|
f"Error details: {str(self)}"
|
|
169
171
|
)
|
|
170
172
|
|
shotgun/main.py
CHANGED
|
@@ -32,6 +32,7 @@ from shotgun.cli import (
|
|
|
32
32
|
feedback,
|
|
33
33
|
plan,
|
|
34
34
|
research,
|
|
35
|
+
spec,
|
|
35
36
|
specify,
|
|
36
37
|
tasks,
|
|
37
38
|
update,
|
|
@@ -95,6 +96,7 @@ app.add_typer(tasks.app, name="tasks", help="Generate task lists with agentic ap
|
|
|
95
96
|
app.add_typer(export.app, name="export", help="Export artifacts to various formats")
|
|
96
97
|
app.add_typer(update.app, name="update", help="Check for and install updates")
|
|
97
98
|
app.add_typer(feedback.app, name="feedback", help="Send us feedback")
|
|
99
|
+
app.add_typer(spec.app, name="spec", help="Manage shared specifications")
|
|
98
100
|
|
|
99
101
|
|
|
100
102
|
def version_callback(value: bool) -> None:
|
shotgun/posthog_telemetry.py
CHANGED
|
@@ -8,7 +8,7 @@ from pydantic import BaseModel
|
|
|
8
8
|
|
|
9
9
|
from shotgun import __version__
|
|
10
10
|
from shotgun.agents.config import get_config_manager
|
|
11
|
-
from shotgun.agents.
|
|
11
|
+
from shotgun.agents.conversation import ConversationManager
|
|
12
12
|
from shotgun.logging_config import get_early_logger
|
|
13
13
|
from shotgun.settings import settings
|
|
14
14
|
|
|
@@ -7,11 +7,11 @@ Your extensive expertise spans, among other things:
|
|
|
7
7
|
## KEY RULES
|
|
8
8
|
|
|
9
9
|
{% if interactive_mode %}
|
|
10
|
-
0.
|
|
10
|
+
0. Ask CLARIFYING QUESTIONS using structured output for complex or multi-step tasks when the request lacks sufficient detail.
|
|
11
11
|
- Return your response with the clarifying_questions field populated
|
|
12
|
-
-
|
|
12
|
+
- For simple, straightforward requests, make reasonable assumptions and proceed.
|
|
13
|
+
- Only ask the most critical questions to avoid overwhelming the user.
|
|
13
14
|
- Questions should be clear, specific, and answerable
|
|
14
|
-
- Do not ask too many questions that might overwhelm the user; prioritize the most important ones.
|
|
15
15
|
{% endif %}
|
|
16
16
|
1. Above all, prefer using tools to do the work and NEVER respond with text.
|
|
17
17
|
2. IMPORTANT: Always ask for review and go ahead to move forward after using write_file().
|
|
@@ -19,10 +19,10 @@ You must return responses using this structured format:
|
|
|
19
19
|
|
|
20
20
|
## When to Use Clarifying Questions
|
|
21
21
|
|
|
22
|
-
- BEFORE GETTING TO WORK:
|
|
22
|
+
- BEFORE GETTING TO WORK: For complex or multi-step tasks where the request is ambiguous or lacks sufficient detail, use clarifying_questions to ask what they want
|
|
23
23
|
- DURING WORK: After using write_file(), you can suggest that the user review it and ask any clarifying questions with clarifying_questions
|
|
24
|
-
-
|
|
25
|
-
-
|
|
24
|
+
- For simple, straightforward requests, make reasonable assumptions and proceed
|
|
25
|
+
- Only ask critical questions that significantly impact the outcome
|
|
26
26
|
|
|
27
27
|
## Important Notes
|
|
28
28
|
|
|
@@ -38,9 +38,6 @@ For research tasks:
|
|
|
38
38
|
|
|
39
39
|
## RESEARCH PRINCIPLES
|
|
40
40
|
|
|
41
|
-
{% if interactive_mode -%}
|
|
42
|
-
- CRITICAL: BEFORE RUNNING ANY SEARCH TOOL, ASK THE USER FOR APPROVAL using clarifying questions. Include what you plan to search for and ask if they want you to proceed.
|
|
43
|
-
{% endif -%}
|
|
44
41
|
- Build upon existing research rather than starting from scratch
|
|
45
42
|
- Focus on practical, actionable information over theoretical concepts
|
|
46
43
|
- Include specific examples, tools, and implementation details
|