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.

Files changed (73) hide show
  1. shotgun/agents/agent_manager.py +16 -3
  2. shotgun/agents/artifact_state.py +58 -0
  3. shotgun/agents/common.py +137 -88
  4. shotgun/agents/config/constants.py +18 -0
  5. shotgun/agents/config/manager.py +68 -16
  6. shotgun/agents/config/models.py +61 -0
  7. shotgun/agents/config/provider.py +11 -6
  8. shotgun/agents/history/compaction.py +85 -0
  9. shotgun/agents/history/constants.py +19 -0
  10. shotgun/agents/history/context_extraction.py +108 -0
  11. shotgun/agents/history/history_building.py +104 -0
  12. shotgun/agents/history/history_processors.py +354 -157
  13. shotgun/agents/history/message_utils.py +46 -0
  14. shotgun/agents/history/token_counting.py +429 -0
  15. shotgun/agents/history/token_estimation.py +138 -0
  16. shotgun/agents/models.py +131 -1
  17. shotgun/agents/plan.py +15 -37
  18. shotgun/agents/research.py +10 -45
  19. shotgun/agents/specify.py +97 -0
  20. shotgun/agents/tasks.py +7 -36
  21. shotgun/agents/tools/artifact_management.py +482 -0
  22. shotgun/agents/tools/file_management.py +31 -12
  23. shotgun/agents/tools/web_search/anthropic.py +78 -17
  24. shotgun/agents/tools/web_search/gemini.py +1 -1
  25. shotgun/agents/tools/web_search/openai.py +16 -2
  26. shotgun/artifacts/__init__.py +17 -0
  27. shotgun/artifacts/exceptions.py +89 -0
  28. shotgun/artifacts/manager.py +530 -0
  29. shotgun/artifacts/models.py +334 -0
  30. shotgun/artifacts/service.py +463 -0
  31. shotgun/artifacts/templates/__init__.py +10 -0
  32. shotgun/artifacts/templates/loader.py +252 -0
  33. shotgun/artifacts/templates/models.py +136 -0
  34. shotgun/artifacts/templates/plan/delivery_and_release_plan.yaml +66 -0
  35. shotgun/artifacts/templates/research/market_research.yaml +585 -0
  36. shotgun/artifacts/templates/research/sdk_comparison.yaml +257 -0
  37. shotgun/artifacts/templates/specify/prd.yaml +331 -0
  38. shotgun/artifacts/templates/specify/product_spec.yaml +301 -0
  39. shotgun/artifacts/utils.py +76 -0
  40. shotgun/cli/plan.py +1 -4
  41. shotgun/cli/specify.py +69 -0
  42. shotgun/cli/tasks.py +0 -4
  43. shotgun/codebase/core/nl_query.py +4 -4
  44. shotgun/logging_config.py +23 -7
  45. shotgun/main.py +7 -6
  46. shotgun/prompts/agents/partials/artifact_system.j2 +35 -0
  47. shotgun/prompts/agents/partials/codebase_understanding.j2 +1 -2
  48. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
  49. shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
  50. shotgun/prompts/agents/partials/interactive_mode.j2 +10 -2
  51. shotgun/prompts/agents/plan.j2 +33 -32
  52. shotgun/prompts/agents/research.j2 +39 -29
  53. shotgun/prompts/agents/specify.j2 +32 -0
  54. shotgun/prompts/agents/state/artifact_templates_available.j2 +18 -0
  55. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +3 -1
  56. shotgun/prompts/agents/state/existing_artifacts_available.j2 +23 -0
  57. shotgun/prompts/agents/state/system_state.j2 +9 -1
  58. shotgun/prompts/agents/tasks.j2 +27 -12
  59. shotgun/prompts/history/incremental_summarization.j2 +53 -0
  60. shotgun/sdk/artifact_models.py +186 -0
  61. shotgun/sdk/artifacts.py +448 -0
  62. shotgun/sdk/services.py +14 -0
  63. shotgun/tui/app.py +26 -7
  64. shotgun/tui/screens/chat.py +32 -5
  65. shotgun/tui/screens/directory_setup.py +113 -0
  66. shotgun/utils/file_system_utils.py +6 -1
  67. {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev14.dist-info}/METADATA +3 -2
  68. shotgun_sh-0.1.0.dev14.dist-info/RECORD +138 -0
  69. shotgun/prompts/user/research.j2 +0 -5
  70. shotgun_sh-0.1.0.dev12.dist-info/RECORD +0 -104
  71. {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev14.dist-info}/WHEEL +0 -0
  72. {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev14.dist-info}/entry_points.txt +0 -0
  73. {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev14.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,530 @@
1
+ """File system manager for artifacts."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ from shotgun.logging_config import setup_logger
10
+ from shotgun.utils.file_system_utils import get_shotgun_base_path
11
+
12
+ from .exceptions import (
13
+ ArtifactFileSystemError,
14
+ ArtifactNotFoundError,
15
+ InvalidArtifactPathError,
16
+ SectionNotFoundError,
17
+ )
18
+ from .models import AgentMode, Artifact, ArtifactSection
19
+ from .utils import generate_artifact_name
20
+
21
+ logger = setup_logger(__name__)
22
+
23
+ TEMPLATE_FILENAME = ".template.yaml"
24
+
25
+
26
+ class ArtifactManager:
27
+ """Manages file system operations for artifacts within .shotgun directory."""
28
+
29
+ def __init__(self, base_path: Path | None = None) -> None:
30
+ """Initialize artifact manager.
31
+
32
+ Args:
33
+ base_path: Base path for artifacts. Defaults to .shotgun in current directory.
34
+ """
35
+ if base_path is None:
36
+ base_path = get_shotgun_base_path()
37
+ elif isinstance(base_path, str):
38
+ base_path = Path(base_path)
39
+
40
+ self.base_path = base_path
41
+ logger.debug("Initialized ArtifactManager with base_path: %s", base_path)
42
+
43
+ def _get_artifact_path(self, agent_mode: AgentMode, artifact_id: str) -> Path:
44
+ """Get the full path to an artifact directory."""
45
+ return self.base_path / agent_mode.value / artifact_id
46
+
47
+ def _validate_artifact_path(self, path: Path) -> None:
48
+ """Validate that artifact path is within allowed directories."""
49
+ try:
50
+ resolved_path = path.resolve()
51
+ base_resolved = self.base_path.resolve()
52
+ resolved_path.relative_to(base_resolved)
53
+ except ValueError as e:
54
+ raise InvalidArtifactPathError(
55
+ str(path), "Path is outside .shotgun directory"
56
+ ) from e
57
+
58
+ def _get_template_path(self, artifact_path: Path) -> Path:
59
+ """Get path to template file for an artifact."""
60
+ return artifact_path / TEMPLATE_FILENAME
61
+
62
+ def _save_template(
63
+ self, artifact_path: Path, template_content: dict[str, Any]
64
+ ) -> None:
65
+ """Save template content to artifact directory."""
66
+ template_path = self._get_template_path(artifact_path)
67
+ try:
68
+ with open(template_path, "w", encoding="utf-8") as f:
69
+ yaml.dump(template_content, f, indent=2, default_flow_style=False)
70
+ logger.debug("Saved template file: %s", template_path)
71
+ except Exception as e:
72
+ raise ArtifactFileSystemError(
73
+ "save template", str(template_path), str(e)
74
+ ) from e
75
+
76
+ def _load_template(self, artifact_path: Path) -> dict[str, Any] | None:
77
+ """Load template content from artifact directory."""
78
+ template_path = self._get_template_path(artifact_path)
79
+ if not template_path.exists():
80
+ return None
81
+
82
+ try:
83
+ with open(template_path, encoding="utf-8") as f:
84
+ data = yaml.safe_load(f)
85
+ return data if isinstance(data, dict) else None
86
+ except Exception as e:
87
+ logger.warning("Failed to load template from %s: %s", template_path, str(e))
88
+ return None
89
+
90
+ def artifact_exists(self, agent_mode: AgentMode, artifact_id: str) -> bool:
91
+ """Check if an artifact exists."""
92
+ artifact_path = self._get_artifact_path(agent_mode, artifact_id)
93
+ return artifact_path.exists() and artifact_path.is_dir()
94
+
95
+ def create_artifact(self, artifact: Artifact) -> None:
96
+ """Create a new artifact on the file system."""
97
+ artifact_path = self._get_artifact_path(
98
+ artifact.agent_mode, artifact.artifact_id
99
+ )
100
+ self._validate_artifact_path(artifact_path)
101
+
102
+ if artifact_path.exists():
103
+ raise ArtifactFileSystemError(
104
+ "create artifact",
105
+ str(artifact_path),
106
+ "Directory already exists",
107
+ )
108
+
109
+ try:
110
+ # Create artifact directory
111
+ artifact_path.mkdir(parents=True, exist_ok=False)
112
+ logger.debug("Created artifact directory: %s", artifact_path)
113
+
114
+ # Save sections
115
+ for section in artifact.sections:
116
+ self._write_section_file(artifact_path, section)
117
+
118
+ # Template saving is now handled by the service layer via save_template_file method
119
+
120
+ except Exception as e:
121
+ # Clean up on failure
122
+ if artifact_path.exists():
123
+ try:
124
+ self._remove_directory(artifact_path)
125
+ except Exception as cleanup_error:
126
+ logger.debug(
127
+ "Failed to cleanup directory %s: %s",
128
+ artifact_path,
129
+ cleanup_error,
130
+ )
131
+ raise ArtifactFileSystemError(
132
+ "create artifact", str(artifact_path), str(e)
133
+ ) from e
134
+
135
+ def load_artifact(
136
+ self, agent_mode: AgentMode, artifact_id: str, name: str | None = None
137
+ ) -> Artifact:
138
+ """Load an artifact from the file system."""
139
+ artifact_path = self._get_artifact_path(agent_mode, artifact_id)
140
+
141
+ if not artifact_path.exists():
142
+ raise ArtifactNotFoundError(artifact_id, agent_mode.value)
143
+
144
+ try:
145
+ # Load sections
146
+ sections: list[ArtifactSection] = []
147
+ section_files = list(artifact_path.glob("*.md"))
148
+
149
+ for section_file in section_files:
150
+ section = self._load_section_from_file(section_file)
151
+ if section:
152
+ sections.append(section)
153
+
154
+ # Sort sections by number
155
+ sections.sort(key=lambda s: s.number)
156
+
157
+ # Generate a default name if none provided
158
+ if not name:
159
+ name = generate_artifact_name(artifact_id)
160
+
161
+ artifact = Artifact(
162
+ artifact_id=artifact_id,
163
+ agent_mode=agent_mode,
164
+ name=name,
165
+ sections=sections,
166
+ )
167
+
168
+ return artifact
169
+
170
+ except ArtifactNotFoundError:
171
+ raise
172
+ except Exception as e:
173
+ raise ArtifactFileSystemError(
174
+ "load artifact", str(artifact_path), str(e)
175
+ ) from e
176
+
177
+ def save_artifact(self, artifact: Artifact) -> None:
178
+ """Save an existing artifact to the file system."""
179
+ artifact_path = self._get_artifact_path(
180
+ artifact.agent_mode, artifact.artifact_id
181
+ )
182
+
183
+ if not artifact_path.exists():
184
+ raise ArtifactNotFoundError(artifact.artifact_id, artifact.agent_mode.value)
185
+
186
+ try:
187
+ # Get existing section files
188
+ existing_files = {f.name for f in artifact_path.glob("*.md")}
189
+
190
+ # Write current sections
191
+ current_files = set()
192
+ for section in artifact.sections:
193
+ filename = section.filename
194
+ self._write_section_file(artifact_path, section)
195
+ current_files.add(filename)
196
+
197
+ # Remove orphaned section files
198
+ orphaned_files = existing_files - current_files
199
+ for filename in orphaned_files:
200
+ file_path = artifact_path / filename
201
+ try:
202
+ file_path.unlink()
203
+ logger.debug("Removed orphaned section file: %s", file_path)
204
+ except Exception as e:
205
+ logger.warning(
206
+ "Failed to remove orphaned file %s: %s", file_path, e
207
+ )
208
+
209
+ # No metadata to save - all artifact state is in sections and template files
210
+
211
+ except ArtifactNotFoundError:
212
+ raise
213
+ except Exception as e:
214
+ raise ArtifactFileSystemError(
215
+ "save artifact", str(artifact_path), str(e)
216
+ ) from e
217
+
218
+ def delete_artifact(self, agent_mode: AgentMode, artifact_id: str) -> None:
219
+ """Delete an artifact from the file system."""
220
+ artifact_path = self._get_artifact_path(agent_mode, artifact_id)
221
+
222
+ if not artifact_path.exists():
223
+ raise ArtifactNotFoundError(artifact_id, agent_mode.value)
224
+
225
+ try:
226
+ self._remove_directory(artifact_path)
227
+ logger.debug("Deleted artifact directory: %s", artifact_path)
228
+ except Exception as e:
229
+ raise ArtifactFileSystemError(
230
+ "delete artifact", str(artifact_path), str(e)
231
+ ) from e
232
+
233
+ def list_artifacts(
234
+ self, agent_mode: AgentMode | None = None
235
+ ) -> list[dict[str, Any]]:
236
+ """List all artifacts, optionally filtered by agent mode."""
237
+ artifacts = []
238
+
239
+ try:
240
+ if agent_mode:
241
+ agent_dirs = [self.base_path / agent_mode.value]
242
+ else:
243
+ agent_dirs = [
244
+ self.base_path / mode.value
245
+ for mode in AgentMode
246
+ if (self.base_path / mode.value).exists()
247
+ ]
248
+
249
+ for agent_dir in agent_dirs:
250
+ if not agent_dir.exists():
251
+ continue
252
+
253
+ mode = AgentMode(agent_dir.name)
254
+ for artifact_dir in agent_dir.iterdir():
255
+ if artifact_dir.is_dir() and not artifact_dir.name.startswith("."):
256
+ try:
257
+ section_titles = self._get_section_titles(artifact_dir)
258
+
259
+ artifacts.append(
260
+ {
261
+ "artifact_id": artifact_dir.name,
262
+ "agent_mode": mode,
263
+ "section_count": len(section_titles),
264
+ "section_titles": section_titles,
265
+ "created_at": self._get_artifact_created_at(
266
+ artifact_dir
267
+ ),
268
+ "updated_at": self._get_artifact_updated_at(
269
+ artifact_dir
270
+ ),
271
+ "description": "", # No longer stored
272
+ }
273
+ )
274
+ except Exception as e:
275
+ logger.warning(
276
+ "Failed to load artifact info from %s: %s",
277
+ artifact_dir,
278
+ e,
279
+ )
280
+ continue
281
+
282
+ except Exception as e:
283
+ raise ArtifactFileSystemError(
284
+ "list artifacts", str(self.base_path), str(e)
285
+ ) from e
286
+
287
+ return artifacts
288
+
289
+ def _write_section_file(
290
+ self, artifact_path: Path, section: ArtifactSection
291
+ ) -> None:
292
+ """Write a section to its markdown file."""
293
+ section_path = artifact_path / section.filename
294
+ try:
295
+ content = f"# {section.title}\n\n{section.content}"
296
+ section_path.write_text(content, encoding="utf-8")
297
+ logger.debug("Wrote section file: %s", section_path)
298
+ except Exception as e:
299
+ raise ArtifactFileSystemError(
300
+ "write section", str(section_path), str(e)
301
+ ) from e
302
+
303
+ def _load_section_from_file(self, section_file: Path) -> ArtifactSection | None:
304
+ """Load a section from a markdown file."""
305
+ try:
306
+ # Parse filename for number and slug
307
+ filename = section_file.name
308
+ if not filename.endswith(".md"):
309
+ return None
310
+
311
+ name_part = filename[:-3] # Remove .md
312
+ if not name_part or "-" not in name_part:
313
+ return None
314
+
315
+ try:
316
+ number_str, slug = name_part.split("-", 1)
317
+ number = int(number_str)
318
+ except (ValueError, IndexError):
319
+ logger.warning("Invalid section filename format: %s", filename)
320
+ return None
321
+
322
+ # Read content
323
+ content = section_file.read_text(encoding="utf-8")
324
+
325
+ # Extract title from first heading if present
326
+ lines = content.split("\n")
327
+ title = slug.replace("-", " ").title() # Default title
328
+ content_lines = lines
329
+
330
+ if lines and lines[0].startswith("# "):
331
+ title = lines[0][2:].strip()
332
+ content_lines = (
333
+ lines[2:] if len(lines) > 1 and not lines[1].strip() else lines[1:]
334
+ )
335
+
336
+ content_text = "\n".join(content_lines).strip()
337
+
338
+ return ArtifactSection(
339
+ number=number,
340
+ slug=slug,
341
+ title=title,
342
+ content=content_text,
343
+ )
344
+
345
+ except Exception as e:
346
+ logger.warning("Failed to load section from %s: %s", section_file, e)
347
+ return None
348
+
349
+ def _get_section_titles(self, artifact_path: Path) -> list[str]:
350
+ """Get list of section titles from artifact directory."""
351
+ titles = []
352
+ section_files = sorted(artifact_path.glob("*.md"))
353
+
354
+ for section_file in section_files:
355
+ section = self._load_section_from_file(section_file)
356
+ if section:
357
+ titles.append(section.title)
358
+
359
+ return titles
360
+
361
+ def _ensure_artifact_exists(self, agent_mode: AgentMode, artifact_id: str) -> Path:
362
+ """Ensure artifact exists and return its path.
363
+
364
+ Args:
365
+ agent_mode: Agent mode
366
+ artifact_id: Artifact ID
367
+
368
+ Returns:
369
+ Path to the artifact directory
370
+
371
+ Raises:
372
+ ArtifactNotFoundError: If artifact doesn't exist
373
+ """
374
+ artifact_path = self._get_artifact_path(agent_mode, artifact_id)
375
+ if not artifact_path.exists():
376
+ raise ArtifactNotFoundError(artifact_id, agent_mode.value)
377
+ return artifact_path
378
+
379
+ def _find_section_file_by_number(
380
+ self, artifact_path: Path, section_number: int
381
+ ) -> Path:
382
+ """Find section file by number.
383
+
384
+ Args:
385
+ artifact_path: Path to artifact directory
386
+ section_number: Section number to find
387
+
388
+ Returns:
389
+ Path to the section file
390
+
391
+ Raises:
392
+ SectionNotFoundError: If section file doesn't exist
393
+ """
394
+ for file in artifact_path.glob("*.md"):
395
+ filename = file.name
396
+ if filename.startswith(f"{section_number:03d}-"):
397
+ return file
398
+
399
+ raise SectionNotFoundError(section_number, artifact_path.name)
400
+
401
+ def _remove_directory(self, path: Path) -> None:
402
+ """Recursively remove a directory."""
403
+ if not path.exists():
404
+ return
405
+
406
+ for item in path.iterdir():
407
+ if item.is_dir():
408
+ self._remove_directory(item)
409
+ else:
410
+ item.unlink()
411
+
412
+ path.rmdir()
413
+
414
+ def _get_artifact_created_at(self, artifact_path: Path) -> datetime:
415
+ """Get artifact creation time from filesystem."""
416
+ if artifact_path.exists():
417
+ return datetime.fromtimestamp(artifact_path.stat().st_ctime)
418
+ return datetime.now()
419
+
420
+ def _get_artifact_updated_at(self, artifact_path: Path) -> datetime:
421
+ """Get artifact last updated time from filesystem."""
422
+ if not artifact_path.exists():
423
+ return datetime.now()
424
+
425
+ # Find the most recently modified file in the artifact directory
426
+ most_recent = artifact_path.stat().st_mtime
427
+ for file_path in artifact_path.rglob("*"):
428
+ if file_path.is_file():
429
+ file_mtime = file_path.stat().st_mtime
430
+ most_recent = max(most_recent, file_mtime)
431
+
432
+ return datetime.fromtimestamp(most_recent)
433
+
434
+ def save_template_file(
435
+ self, agent_mode: AgentMode, artifact_id: str, template_content: dict[str, Any]
436
+ ) -> None:
437
+ """Save template content to an artifact's .template.yaml file.
438
+
439
+ Args:
440
+ agent_mode: Agent mode
441
+ artifact_id: Artifact ID
442
+ template_content: Template content as dictionary
443
+
444
+ Raises:
445
+ ArtifactNotFoundError: If artifact doesn't exist
446
+ ArtifactFileSystemError: If template file cannot be saved
447
+ """
448
+ artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
449
+ self._save_template(artifact_path, template_content)
450
+
451
+ def write_section(
452
+ self, agent_mode: AgentMode, artifact_id: str, section: ArtifactSection
453
+ ) -> None:
454
+ """Write a single section to its file without affecting other sections.
455
+
456
+ Args:
457
+ agent_mode: Agent mode
458
+ artifact_id: Artifact ID
459
+ section: Section to write
460
+
461
+ Raises:
462
+ ArtifactNotFoundError: If artifact doesn't exist
463
+ ArtifactFileSystemError: If section file cannot be written
464
+ """
465
+ artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
466
+ self._write_section_file(artifact_path, section)
467
+
468
+ def update_section_file(
469
+ self, agent_mode: AgentMode, artifact_id: str, section: ArtifactSection
470
+ ) -> None:
471
+ """Update a single section file without affecting other sections.
472
+
473
+ Args:
474
+ agent_mode: Agent mode
475
+ artifact_id: Artifact ID
476
+ section: Updated section data
477
+
478
+ Raises:
479
+ ArtifactNotFoundError: If artifact doesn't exist
480
+ SectionNotFoundError: If section file doesn't exist
481
+ ArtifactFileSystemError: If section file cannot be updated
482
+ """
483
+ artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
484
+
485
+ # Verify section file exists
486
+ section_path = artifact_path / section.filename
487
+ if not section_path.exists():
488
+ raise SectionNotFoundError(section.number, artifact_id)
489
+
490
+ self._write_section_file(artifact_path, section)
491
+
492
+ def delete_section_file(
493
+ self, agent_mode: AgentMode, artifact_id: str, section_number: int
494
+ ) -> None:
495
+ """Delete a single section file without affecting other sections.
496
+
497
+ Args:
498
+ agent_mode: Agent mode
499
+ artifact_id: Artifact ID
500
+ section_number: Section number to delete
501
+
502
+ Raises:
503
+ ArtifactNotFoundError: If artifact doesn't exist
504
+ SectionNotFoundError: If section file doesn't exist
505
+ ArtifactFileSystemError: If section file cannot be deleted
506
+ """
507
+ artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
508
+ section_file = self._find_section_file_by_number(artifact_path, section_number)
509
+
510
+ try:
511
+ section_file.unlink()
512
+ logger.debug("Deleted section file: %s", section_file)
513
+ except Exception as e:
514
+ raise ArtifactFileSystemError(
515
+ "delete section", str(section_file), str(e)
516
+ ) from e
517
+
518
+ def read_section(
519
+ self, agent_mode: AgentMode, artifact_id: str, section_number: int
520
+ ) -> str:
521
+ """Read content of a specific section."""
522
+ artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
523
+ section_file = self._find_section_file_by_number(artifact_path, section_number)
524
+
525
+ try:
526
+ return section_file.read_text(encoding="utf-8")
527
+ except Exception as e:
528
+ raise ArtifactFileSystemError(
529
+ "read section", str(section_file), str(e)
530
+ ) from e