shotgun-sh 0.1.0.dev12__py3-none-any.whl → 0.1.0.dev14__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 +16 -3
- shotgun/agents/artifact_state.py +58 -0
- shotgun/agents/common.py +137 -88
- shotgun/agents/config/constants.py +18 -0
- shotgun/agents/config/manager.py +68 -16
- shotgun/agents/config/models.py +61 -0
- shotgun/agents/config/provider.py +11 -6
- shotgun/agents/history/compaction.py +85 -0
- shotgun/agents/history/constants.py +19 -0
- shotgun/agents/history/context_extraction.py +108 -0
- shotgun/agents/history/history_building.py +104 -0
- shotgun/agents/history/history_processors.py +354 -157
- shotgun/agents/history/message_utils.py +46 -0
- shotgun/agents/history/token_counting.py +429 -0
- shotgun/agents/history/token_estimation.py +138 -0
- shotgun/agents/models.py +131 -1
- shotgun/agents/plan.py +15 -37
- shotgun/agents/research.py +10 -45
- shotgun/agents/specify.py +97 -0
- shotgun/agents/tasks.py +7 -36
- shotgun/agents/tools/artifact_management.py +482 -0
- shotgun/agents/tools/file_management.py +31 -12
- shotgun/agents/tools/web_search/anthropic.py +78 -17
- shotgun/agents/tools/web_search/gemini.py +1 -1
- shotgun/agents/tools/web_search/openai.py +16 -2
- shotgun/artifacts/__init__.py +17 -0
- shotgun/artifacts/exceptions.py +89 -0
- shotgun/artifacts/manager.py +530 -0
- shotgun/artifacts/models.py +334 -0
- shotgun/artifacts/service.py +463 -0
- shotgun/artifacts/templates/__init__.py +10 -0
- shotgun/artifacts/templates/loader.py +252 -0
- shotgun/artifacts/templates/models.py +136 -0
- shotgun/artifacts/templates/plan/delivery_and_release_plan.yaml +66 -0
- shotgun/artifacts/templates/research/market_research.yaml +585 -0
- shotgun/artifacts/templates/research/sdk_comparison.yaml +257 -0
- shotgun/artifacts/templates/specify/prd.yaml +331 -0
- shotgun/artifacts/templates/specify/product_spec.yaml +301 -0
- shotgun/artifacts/utils.py +76 -0
- shotgun/cli/plan.py +1 -4
- shotgun/cli/specify.py +69 -0
- shotgun/cli/tasks.py +0 -4
- shotgun/codebase/core/nl_query.py +4 -4
- shotgun/logging_config.py +23 -7
- shotgun/main.py +7 -6
- shotgun/prompts/agents/partials/artifact_system.j2 +35 -0
- shotgun/prompts/agents/partials/codebase_understanding.j2 +1 -2
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
- shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
- shotgun/prompts/agents/partials/interactive_mode.j2 +10 -2
- shotgun/prompts/agents/plan.j2 +33 -32
- shotgun/prompts/agents/research.j2 +39 -29
- shotgun/prompts/agents/specify.j2 +32 -0
- shotgun/prompts/agents/state/artifact_templates_available.j2 +18 -0
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +3 -1
- shotgun/prompts/agents/state/existing_artifacts_available.j2 +23 -0
- shotgun/prompts/agents/state/system_state.j2 +9 -1
- shotgun/prompts/agents/tasks.j2 +27 -12
- shotgun/prompts/history/incremental_summarization.j2 +53 -0
- shotgun/sdk/artifact_models.py +186 -0
- shotgun/sdk/artifacts.py +448 -0
- shotgun/sdk/services.py +14 -0
- shotgun/tui/app.py +26 -7
- shotgun/tui/screens/chat.py +32 -5
- shotgun/tui/screens/directory_setup.py +113 -0
- shotgun/utils/file_system_utils.py +6 -1
- {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev14.dist-info}/METADATA +3 -2
- shotgun_sh-0.1.0.dev14.dist-info/RECORD +138 -0
- shotgun/prompts/user/research.j2 +0 -5
- shotgun_sh-0.1.0.dev12.dist-info/RECORD +0 -104
- {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev14.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev14.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev14.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Pydantic models for the artifact system."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
10
|
+
|
|
11
|
+
from shotgun.utils.file_system_utils import get_shotgun_base_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AgentMode(str, Enum):
|
|
15
|
+
"""Supported agent modes for artifacts."""
|
|
16
|
+
|
|
17
|
+
RESEARCH = "research"
|
|
18
|
+
PLAN = "plan"
|
|
19
|
+
TASKS = "tasks"
|
|
20
|
+
SPECIFY = "specify"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ArtifactSection(BaseModel):
|
|
24
|
+
"""A section within an artifact."""
|
|
25
|
+
|
|
26
|
+
number: int = Field(..., ge=1, description="Section number for ordering")
|
|
27
|
+
slug: str = Field(..., min_length=1, description="URL-friendly section identifier")
|
|
28
|
+
title: str = Field(..., min_length=1, description="Human-readable section title")
|
|
29
|
+
content: str = Field(default="", description="Markdown content of the section")
|
|
30
|
+
|
|
31
|
+
@field_validator("slug")
|
|
32
|
+
@classmethod
|
|
33
|
+
def validate_slug(cls, v: str) -> str:
|
|
34
|
+
"""Validate that slug contains only valid characters."""
|
|
35
|
+
if not re.match(r"^[a-z0-9._-]+$", v):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"Slug must contain only lowercase letters, numbers, hyphens, dots, and underscores"
|
|
38
|
+
)
|
|
39
|
+
if v.startswith("-") or v.endswith("-"):
|
|
40
|
+
raise ValueError("Slug cannot start or end with hyphen")
|
|
41
|
+
if v.startswith(".") or v.endswith("."):
|
|
42
|
+
raise ValueError("Slug cannot start or end with dot")
|
|
43
|
+
if v.startswith("_") or v.endswith("_"):
|
|
44
|
+
raise ValueError("Slug cannot start or end with underscore")
|
|
45
|
+
if "--" in v:
|
|
46
|
+
raise ValueError("Slug cannot contain consecutive hyphens")
|
|
47
|
+
if ".." in v:
|
|
48
|
+
raise ValueError("Slug cannot contain consecutive dots")
|
|
49
|
+
if "__" in v:
|
|
50
|
+
raise ValueError("Slug cannot contain consecutive underscores")
|
|
51
|
+
return v
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def filename(self) -> str:
|
|
55
|
+
"""Generate filename for this section."""
|
|
56
|
+
return f"{self.number:03d}-{self.slug}.md"
|
|
57
|
+
|
|
58
|
+
def __str__(self) -> str:
|
|
59
|
+
"""String representation for debugging."""
|
|
60
|
+
return f"Section({self.number:03d}-{self.slug})"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Artifact(BaseModel):
|
|
64
|
+
"""Main artifact model containing sections."""
|
|
65
|
+
|
|
66
|
+
artifact_id: str = Field(
|
|
67
|
+
..., min_length=1, description="Unique artifact identifier"
|
|
68
|
+
)
|
|
69
|
+
agent_mode: AgentMode = Field(
|
|
70
|
+
..., description="Agent mode this artifact belongs to"
|
|
71
|
+
)
|
|
72
|
+
name: str = Field(..., min_length=1, description="Human-readable artifact name")
|
|
73
|
+
sections: list[ArtifactSection] = Field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
@field_validator("artifact_id")
|
|
76
|
+
@classmethod
|
|
77
|
+
def validate_artifact_id(cls, v: str) -> str:
|
|
78
|
+
"""Validate that artifact_id is a valid slug."""
|
|
79
|
+
if not re.match(r"^[a-z0-9-]+$", v):
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"Artifact ID must contain only lowercase letters, numbers, and hyphens"
|
|
82
|
+
)
|
|
83
|
+
if v.startswith("-") or v.endswith("-"):
|
|
84
|
+
raise ValueError("Artifact ID cannot start or end with hyphen")
|
|
85
|
+
if "--" in v:
|
|
86
|
+
raise ValueError("Artifact ID cannot contain consecutive hyphens")
|
|
87
|
+
return v
|
|
88
|
+
|
|
89
|
+
@model_validator(mode="after")
|
|
90
|
+
def validate_section_numbers(self) -> "Artifact":
|
|
91
|
+
"""Ensure section numbers are unique and sequential."""
|
|
92
|
+
if not self.sections:
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
numbers = [section.number for section in self.sections]
|
|
96
|
+
if len(numbers) != len(set(numbers)):
|
|
97
|
+
raise ValueError("Section numbers must be unique")
|
|
98
|
+
|
|
99
|
+
# Check for reasonable numbering (starting from 1, no big gaps)
|
|
100
|
+
sorted_numbers = sorted(numbers)
|
|
101
|
+
if sorted_numbers[0] != 1:
|
|
102
|
+
raise ValueError("Section numbers should start from 1")
|
|
103
|
+
|
|
104
|
+
# Allow gaps but warn about unreasonable ones
|
|
105
|
+
max_gap = (
|
|
106
|
+
max(
|
|
107
|
+
sorted_numbers[i + 1] - sorted_numbers[i]
|
|
108
|
+
for i in range(len(sorted_numbers) - 1)
|
|
109
|
+
)
|
|
110
|
+
if len(sorted_numbers) > 1
|
|
111
|
+
else 0
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if max_gap > 10:
|
|
115
|
+
# This is just a warning, not an error
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def directory_path(self) -> str:
|
|
122
|
+
"""Get the relative directory path for this artifact."""
|
|
123
|
+
return f"{self.agent_mode.value}/{self.artifact_id}"
|
|
124
|
+
|
|
125
|
+
def get_section_by_number(self, number: int) -> ArtifactSection | None:
|
|
126
|
+
"""Get section by number."""
|
|
127
|
+
for section in self.sections:
|
|
128
|
+
if section.number == number:
|
|
129
|
+
return section
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def get_section_by_slug(self, slug: str) -> ArtifactSection | None:
|
|
133
|
+
"""Get section by slug."""
|
|
134
|
+
for section in self.sections:
|
|
135
|
+
if section.slug == slug:
|
|
136
|
+
return section
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def add_section(self, section: ArtifactSection) -> None:
|
|
140
|
+
"""Add a section to the artifact."""
|
|
141
|
+
# Check for conflicts
|
|
142
|
+
if self.get_section_by_number(section.number):
|
|
143
|
+
raise ValueError(f"Section number {section.number} already exists")
|
|
144
|
+
if self.get_section_by_slug(section.slug):
|
|
145
|
+
raise ValueError(f"Section slug '{section.slug}' already exists")
|
|
146
|
+
|
|
147
|
+
self.sections.append(section)
|
|
148
|
+
self.sections.sort(key=lambda s: s.number)
|
|
149
|
+
|
|
150
|
+
def remove_section(self, number: int) -> bool:
|
|
151
|
+
"""Remove a section by number. Returns True if section was removed."""
|
|
152
|
+
original_count = len(self.sections)
|
|
153
|
+
self.sections = [s for s in self.sections if s.number != number]
|
|
154
|
+
removed = len(self.sections) < original_count
|
|
155
|
+
return removed
|
|
156
|
+
|
|
157
|
+
def update_section(self, number: int, **kwargs: Any) -> bool:
|
|
158
|
+
"""Update a section's fields. Returns True if section was found and updated."""
|
|
159
|
+
section = self.get_section_by_number(number)
|
|
160
|
+
if not section:
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
# Handle special case of changing number
|
|
164
|
+
if "number" in kwargs and kwargs["number"] != section.number:
|
|
165
|
+
new_number = kwargs["number"]
|
|
166
|
+
if self.get_section_by_number(new_number):
|
|
167
|
+
raise ValueError(f"Section number {new_number} already exists")
|
|
168
|
+
|
|
169
|
+
# Update fields
|
|
170
|
+
for field, value in kwargs.items():
|
|
171
|
+
setattr(section, field, value)
|
|
172
|
+
|
|
173
|
+
# Re-sort if number changed
|
|
174
|
+
if "number" in kwargs:
|
|
175
|
+
self.sections.sort(key=lambda s: s.number)
|
|
176
|
+
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
def get_ordered_sections(self) -> list[ArtifactSection]:
|
|
180
|
+
"""Get sections ordered by number."""
|
|
181
|
+
return sorted(self.sections, key=lambda s: s.number)
|
|
182
|
+
|
|
183
|
+
def has_template(self, base_path: Path | None = None) -> bool:
|
|
184
|
+
"""Check if this artifact was created from a template."""
|
|
185
|
+
if base_path is None:
|
|
186
|
+
base_path = get_shotgun_base_path()
|
|
187
|
+
elif isinstance(base_path, str):
|
|
188
|
+
base_path = Path(base_path)
|
|
189
|
+
|
|
190
|
+
template_path = (
|
|
191
|
+
base_path / self.agent_mode.value / self.artifact_id / ".template.yaml"
|
|
192
|
+
)
|
|
193
|
+
return template_path.exists()
|
|
194
|
+
|
|
195
|
+
def get_template_id(self, base_path: Path | None = None) -> str | None:
|
|
196
|
+
"""Get the template ID from the template file."""
|
|
197
|
+
template_content = self.load_template_from_file(base_path)
|
|
198
|
+
if template_content and "template_id" in template_content:
|
|
199
|
+
template_id = template_content["template_id"]
|
|
200
|
+
return str(template_id) if template_id is not None else None
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
def load_template_from_file(
|
|
204
|
+
self, base_path: Path | None = None
|
|
205
|
+
) -> dict[str, Any] | None:
|
|
206
|
+
"""Load template content from the artifact's .template.yaml file.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
base_path: Base path for artifacts. Defaults to .shotgun in current directory.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Template content as dict or None if no template file exists.
|
|
213
|
+
"""
|
|
214
|
+
import yaml
|
|
215
|
+
|
|
216
|
+
if base_path is None:
|
|
217
|
+
base_path = get_shotgun_base_path()
|
|
218
|
+
elif isinstance(base_path, str):
|
|
219
|
+
base_path = Path(base_path)
|
|
220
|
+
|
|
221
|
+
template_path = (
|
|
222
|
+
base_path / self.agent_mode.value / self.artifact_id / ".template.yaml"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if not template_path.exists():
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
with open(template_path, encoding="utf-8") as f:
|
|
230
|
+
data = yaml.safe_load(f)
|
|
231
|
+
return data if isinstance(data, dict) else None
|
|
232
|
+
except Exception:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
def get_section_count(self) -> int:
|
|
236
|
+
"""Get section count based on current sections."""
|
|
237
|
+
return len(self.sections)
|
|
238
|
+
|
|
239
|
+
def get_total_content_length(self) -> int:
|
|
240
|
+
"""Calculate total content length from all sections."""
|
|
241
|
+
return sum(len(section.content) for section in self.sections)
|
|
242
|
+
|
|
243
|
+
def get_created_at(self, base_path: Path | None = None) -> datetime:
|
|
244
|
+
"""Get artifact creation time from filesystem.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
base_path: Base path for artifacts. Defaults to .shotgun in current directory.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Creation timestamp based on artifact directory creation time.
|
|
251
|
+
"""
|
|
252
|
+
if base_path is None:
|
|
253
|
+
base_path = get_shotgun_base_path()
|
|
254
|
+
elif isinstance(base_path, str):
|
|
255
|
+
base_path = Path(base_path)
|
|
256
|
+
|
|
257
|
+
artifact_path = base_path / self.agent_mode.value / self.artifact_id
|
|
258
|
+
if artifact_path.exists():
|
|
259
|
+
return datetime.fromtimestamp(artifact_path.stat().st_ctime)
|
|
260
|
+
return datetime.now()
|
|
261
|
+
|
|
262
|
+
def get_updated_at(self, base_path: Path | None = None) -> datetime:
|
|
263
|
+
"""Get artifact last updated time from filesystem.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
base_path: Base path for artifacts. Defaults to .shotgun in current directory.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Last modified timestamp based on most recently modified file in artifact directory.
|
|
270
|
+
"""
|
|
271
|
+
if base_path is None:
|
|
272
|
+
base_path = get_shotgun_base_path()
|
|
273
|
+
elif isinstance(base_path, str):
|
|
274
|
+
base_path = Path(base_path)
|
|
275
|
+
|
|
276
|
+
artifact_path = base_path / self.agent_mode.value / self.artifact_id
|
|
277
|
+
if not artifact_path.exists():
|
|
278
|
+
return datetime.now()
|
|
279
|
+
|
|
280
|
+
# Find the most recently modified file in the artifact directory
|
|
281
|
+
most_recent = artifact_path.stat().st_mtime
|
|
282
|
+
for file_path in artifact_path.rglob("*"):
|
|
283
|
+
if file_path.is_file():
|
|
284
|
+
file_mtime = file_path.stat().st_mtime
|
|
285
|
+
most_recent = max(most_recent, file_mtime)
|
|
286
|
+
|
|
287
|
+
return datetime.fromtimestamp(most_recent)
|
|
288
|
+
|
|
289
|
+
def __str__(self) -> str:
|
|
290
|
+
"""String representation for debugging."""
|
|
291
|
+
template_info = " (template)" if self.has_template() else ""
|
|
292
|
+
return f"Artifact({self.agent_mode.value}/{self.artifact_id}, {len(self.sections)} sections{template_info})"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class ArtifactSummary(BaseModel):
|
|
296
|
+
"""Summary information about an artifact without full content."""
|
|
297
|
+
|
|
298
|
+
artifact_id: str
|
|
299
|
+
agent_mode: AgentMode
|
|
300
|
+
name: str
|
|
301
|
+
section_count: int
|
|
302
|
+
created_at: datetime
|
|
303
|
+
updated_at: datetime
|
|
304
|
+
section_titles: list[str] = Field(default_factory=list)
|
|
305
|
+
template_id: str | None = Field(
|
|
306
|
+
default=None, description="ID of template used to create this artifact"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
@classmethod
|
|
310
|
+
def from_artifact(
|
|
311
|
+
cls, artifact: Artifact, base_path: Path | None = None
|
|
312
|
+
) -> "ArtifactSummary":
|
|
313
|
+
"""Create summary from full artifact.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
artifact: The artifact to create summary from
|
|
317
|
+
base_path: Base path for artifacts. Used for filesystem-based timestamps.
|
|
318
|
+
"""
|
|
319
|
+
return cls(
|
|
320
|
+
artifact_id=artifact.artifact_id,
|
|
321
|
+
agent_mode=artifact.agent_mode,
|
|
322
|
+
name=artifact.name,
|
|
323
|
+
section_count=artifact.get_section_count(),
|
|
324
|
+
created_at=artifact.get_created_at(base_path),
|
|
325
|
+
updated_at=artifact.get_updated_at(base_path),
|
|
326
|
+
section_titles=[
|
|
327
|
+
section.title for section in artifact.get_ordered_sections()
|
|
328
|
+
],
|
|
329
|
+
template_id=artifact.get_template_id(base_path),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def __str__(self) -> str:
|
|
333
|
+
"""String representation for debugging."""
|
|
334
|
+
return f"ArtifactSummary({self.agent_mode.value}/{self.artifact_id})"
|