htmlgraph 0.26.5__py3-none-any.whl → 0.26.7__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.
- htmlgraph/.htmlgraph/.session-warning-state.json +1 -1
- htmlgraph/__init__.py +1 -1
- htmlgraph/api/main.py +50 -10
- htmlgraph/api/templates/dashboard-redesign.html +608 -54
- htmlgraph/api/templates/partials/activity-feed.html +21 -0
- htmlgraph/api/templates/partials/features.html +81 -12
- htmlgraph/api/templates/partials/orchestration.html +35 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +939 -0
- htmlgraph/cli/base.py +660 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +856 -0
- htmlgraph/cli/main.py +143 -0
- htmlgraph/cli/models.py +462 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +398 -0
- htmlgraph/cli/work/__init__.py +159 -0
- htmlgraph/cli/work/features.py +567 -0
- htmlgraph/cli/work/orchestration.py +675 -0
- htmlgraph/cli/work/sessions.py +465 -0
- htmlgraph/cli/work/tracks.py +485 -0
- htmlgraph/dashboard.html +6414 -634
- htmlgraph/db/schema.py +8 -3
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +20 -13
- htmlgraph/docs/README.md +2 -3
- htmlgraph/hooks/event_tracker.py +355 -26
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/orchestrator.py +137 -71
- htmlgraph/hooks/orchestrator_reflector.py +23 -0
- htmlgraph/hooks/pretooluse.py +29 -6
- htmlgraph/hooks/session_handler.py +28 -0
- htmlgraph/hooks/session_summary.py +391 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +71 -12
- htmlgraph/hooks/validator.py +192 -79
- htmlgraph/operations/__init__.py +18 -0
- htmlgraph/operations/initialization.py +596 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/orchestration/__init__.py +16 -1
- htmlgraph/orchestration/claude_launcher.py +185 -0
- htmlgraph/orchestration/command_builder.py +71 -0
- htmlgraph/orchestration/headless_spawner.py +72 -1332
- htmlgraph/orchestration/plugin_manager.py +136 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +170 -0
- htmlgraph/orchestration/spawners/codex.py +442 -0
- htmlgraph/orchestration/spawners/copilot.py +299 -0
- htmlgraph/orchestration/spawners/gemini.py +478 -0
- htmlgraph/orchestration/subprocess_runner.py +33 -0
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +620 -55
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +45 -12
- htmlgraph/transcript.py +16 -4
- htmlgraph-0.26.7.data/data/htmlgraph/dashboard.html +6592 -0
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/METADATA +1 -1
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/RECORD +68 -34
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -7256
- htmlgraph-0.26.5.data/data/htmlgraph/dashboard.html +0 -812
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
"""HtmlGraph CLI - Analytics and reporting commands.
|
|
2
|
+
|
|
3
|
+
Commands for analytics and reporting:
|
|
4
|
+
- analytics: Project-wide analytics
|
|
5
|
+
- cigs: Cost dashboard and attribution
|
|
6
|
+
- transcripts: Transcript management
|
|
7
|
+
- sync-docs: Documentation synchronization
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import webbrowser
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
from rich import box
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
|
|
24
|
+
from htmlgraph.cli.base import BaseCommand, CommandError, CommandResult
|
|
25
|
+
from htmlgraph.cli.constants import DEFAULT_GRAPH_DIR
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from argparse import _SubParsersAction
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ============================================================================
|
|
34
|
+
# Command Registration
|
|
35
|
+
# ============================================================================
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register_commands(subparsers: _SubParsersAction) -> None:
|
|
39
|
+
"""Register analytics and reporting commands with the argument parser.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
subparsers: Subparser action from ArgumentParser.add_subparsers()
|
|
43
|
+
"""
|
|
44
|
+
# Analytics command
|
|
45
|
+
analytics_parser = subparsers.add_parser(
|
|
46
|
+
"analytics", help="Project-wide analytics and insights"
|
|
47
|
+
)
|
|
48
|
+
analytics_parser.add_argument(
|
|
49
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
50
|
+
)
|
|
51
|
+
analytics_parser.add_argument("--session-id", help="Analyze specific session")
|
|
52
|
+
analytics_parser.add_argument(
|
|
53
|
+
"--recent", type=int, metavar="N", help="Analyze recent N sessions"
|
|
54
|
+
)
|
|
55
|
+
analytics_parser.add_argument(
|
|
56
|
+
"--agent", default="cli", help="Agent name for SDK initialization"
|
|
57
|
+
)
|
|
58
|
+
analytics_parser.add_argument(
|
|
59
|
+
"--quiet", "-q", action="store_true", help="Suppress progress indicators"
|
|
60
|
+
)
|
|
61
|
+
analytics_parser.set_defaults(func=AnalyticsCommand.from_args)
|
|
62
|
+
|
|
63
|
+
# CIGS commands
|
|
64
|
+
_register_cigs_commands(subparsers)
|
|
65
|
+
|
|
66
|
+
# Transcript commands
|
|
67
|
+
_register_transcript_commands(subparsers)
|
|
68
|
+
|
|
69
|
+
# Sync docs command
|
|
70
|
+
_register_sync_docs_command(subparsers)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _register_cigs_commands(subparsers: _SubParsersAction) -> None:
|
|
74
|
+
"""Register CIGS (Cost Intelligence & Governance System) commands."""
|
|
75
|
+
cigs_parser = subparsers.add_parser("cigs", help="Cost dashboard and attribution")
|
|
76
|
+
cigs_subparsers = cigs_parser.add_subparsers(
|
|
77
|
+
dest="cigs_command", help="CIGS command"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# cigs cost-dashboard
|
|
81
|
+
cost_dashboard = cigs_subparsers.add_parser(
|
|
82
|
+
"cost-dashboard", help="Display cost summary dashboard"
|
|
83
|
+
)
|
|
84
|
+
cost_dashboard.add_argument(
|
|
85
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
86
|
+
)
|
|
87
|
+
cost_dashboard.add_argument(
|
|
88
|
+
"--save", action="store_true", help="Save to .htmlgraph/cost-dashboard.html"
|
|
89
|
+
)
|
|
90
|
+
cost_dashboard.add_argument(
|
|
91
|
+
"--open", action="store_true", help="Open in browser after generation"
|
|
92
|
+
)
|
|
93
|
+
cost_dashboard.add_argument(
|
|
94
|
+
"--json", action="store_true", help="Output JSON instead of HTML"
|
|
95
|
+
)
|
|
96
|
+
cost_dashboard.add_argument("--output", help="Custom output path")
|
|
97
|
+
cost_dashboard.set_defaults(func=CostDashboardCommand.from_args)
|
|
98
|
+
|
|
99
|
+
# cigs status
|
|
100
|
+
cigs_status = cigs_subparsers.add_parser("status", help="Show CIGS status")
|
|
101
|
+
cigs_status.add_argument(
|
|
102
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
103
|
+
)
|
|
104
|
+
cigs_status.set_defaults(func=CigsStatusCommand.from_args)
|
|
105
|
+
|
|
106
|
+
# cigs summary
|
|
107
|
+
cigs_summary = cigs_subparsers.add_parser("summary", help="Show cost summary")
|
|
108
|
+
cigs_summary.add_argument(
|
|
109
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
110
|
+
)
|
|
111
|
+
cigs_summary.add_argument("--session-id", help="Specific session ID")
|
|
112
|
+
cigs_summary.set_defaults(func=CigsSummaryCommand.from_args)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _register_transcript_commands(subparsers: _SubParsersAction) -> None:
|
|
116
|
+
"""Register transcript management commands."""
|
|
117
|
+
transcript_parser = subparsers.add_parser(
|
|
118
|
+
"transcript", help="Transcript management"
|
|
119
|
+
)
|
|
120
|
+
transcript_subparsers = transcript_parser.add_subparsers(
|
|
121
|
+
dest="transcript_command", help="Transcript command"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# transcript list
|
|
125
|
+
transcript_list = transcript_subparsers.add_parser("list", help="List transcripts")
|
|
126
|
+
transcript_list.add_argument(
|
|
127
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
128
|
+
)
|
|
129
|
+
transcript_list.add_argument("--format", choices=["text", "json"], default="text")
|
|
130
|
+
transcript_list.add_argument("--limit", type=int, default=20)
|
|
131
|
+
transcript_list.add_argument("--project", help="Filter by project path")
|
|
132
|
+
transcript_list.set_defaults(func=TranscriptListCommand.from_args)
|
|
133
|
+
|
|
134
|
+
# transcript import
|
|
135
|
+
transcript_import = transcript_subparsers.add_parser(
|
|
136
|
+
"import", help="Import transcript"
|
|
137
|
+
)
|
|
138
|
+
transcript_import.add_argument("session_id", help="Transcript session ID to import")
|
|
139
|
+
transcript_import.add_argument(
|
|
140
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
141
|
+
)
|
|
142
|
+
transcript_import.add_argument("--to-session", help="Target HtmlGraph session ID")
|
|
143
|
+
transcript_import.add_argument("--agent", default="claude-code", help="Agent name")
|
|
144
|
+
transcript_import.add_argument(
|
|
145
|
+
"--overwrite", action="store_true", help="Overwrite existing events"
|
|
146
|
+
)
|
|
147
|
+
transcript_import.add_argument("--link-feature", help="Link to feature ID")
|
|
148
|
+
transcript_import.add_argument("--format", choices=["text", "json"], default="text")
|
|
149
|
+
transcript_import.set_defaults(func=TranscriptImportCommand.from_args)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _register_sync_docs_command(subparsers: _SubParsersAction) -> None:
|
|
153
|
+
"""Register documentation synchronization command."""
|
|
154
|
+
sync_docs = subparsers.add_parser(
|
|
155
|
+
"sync-docs", help="Synchronize AI agent memory files"
|
|
156
|
+
)
|
|
157
|
+
sync_docs.add_argument(
|
|
158
|
+
"--project-root", help="Project root directory (default: current directory)"
|
|
159
|
+
)
|
|
160
|
+
sync_docs.add_argument(
|
|
161
|
+
"--check", action="store_true", help="Check synchronization status"
|
|
162
|
+
)
|
|
163
|
+
sync_docs.add_argument(
|
|
164
|
+
"--generate",
|
|
165
|
+
choices=["claude", "gemini"],
|
|
166
|
+
help="Generate specific platform file",
|
|
167
|
+
)
|
|
168
|
+
sync_docs.add_argument(
|
|
169
|
+
"--force", action="store_true", help="Force overwrite existing files"
|
|
170
|
+
)
|
|
171
|
+
sync_docs.set_defaults(func=SyncDocsCommand.from_args)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ============================================================================
|
|
175
|
+
# Pydantic Models for Cost Analytics
|
|
176
|
+
# ============================================================================
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ToolCostData(BaseModel):
|
|
180
|
+
"""Cost data for a specific tool."""
|
|
181
|
+
|
|
182
|
+
count: int = Field(ge=0)
|
|
183
|
+
total_tokens: int = Field(ge=0)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class CategoryCostData(BaseModel):
|
|
187
|
+
"""Cost data for a category (delegation/direct)."""
|
|
188
|
+
|
|
189
|
+
count: int = Field(ge=0)
|
|
190
|
+
total_tokens: int = Field(ge=0)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class CostSummary(BaseModel):
|
|
194
|
+
"""Complete cost analysis summary."""
|
|
195
|
+
|
|
196
|
+
total_cost_tokens: int = Field(ge=0)
|
|
197
|
+
total_events: int = Field(ge=0)
|
|
198
|
+
tool_costs: dict[str, ToolCostData] = Field(default_factory=dict)
|
|
199
|
+
session_costs: dict[str, ToolCostData] = Field(default_factory=dict)
|
|
200
|
+
delegation_count: int = Field(ge=0)
|
|
201
|
+
direct_execution_count: int = Field(ge=0)
|
|
202
|
+
cost_by_category: dict[str, CategoryCostData] = Field(default_factory=dict)
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def avg_cost_per_event(self) -> float:
|
|
206
|
+
"""Average token cost per event."""
|
|
207
|
+
return (
|
|
208
|
+
self.total_cost_tokens / self.total_events if self.total_events > 0 else 0
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def delegation_percentage(self) -> float:
|
|
213
|
+
"""Percentage of events that were delegated."""
|
|
214
|
+
return (
|
|
215
|
+
self.delegation_count / self.total_events * 100
|
|
216
|
+
if self.total_events > 0
|
|
217
|
+
else 0
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def estimated_cost_usd(self) -> float:
|
|
222
|
+
"""Estimated cost in USD (rough approximation)."""
|
|
223
|
+
return self.total_cost_tokens / 1_000_000 * 5
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ============================================================================
|
|
227
|
+
# Command Implementations
|
|
228
|
+
# ============================================================================
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class AnalyticsCommand(BaseCommand):
|
|
232
|
+
"""Project-wide analytics and insights."""
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self, *, session_id: str | None, recent: int | None, agent: str, quiet: bool
|
|
236
|
+
) -> None:
|
|
237
|
+
super().__init__()
|
|
238
|
+
self.session_id = session_id
|
|
239
|
+
self.recent = recent
|
|
240
|
+
self.agent = agent
|
|
241
|
+
self.quiet = quiet
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def from_args(cls, args: argparse.Namespace) -> AnalyticsCommand:
|
|
245
|
+
return cls(
|
|
246
|
+
session_id=getattr(args, "session_id", None),
|
|
247
|
+
recent=getattr(args, "recent", None),
|
|
248
|
+
agent=getattr(args, "agent", "cli"),
|
|
249
|
+
quiet=getattr(args, "quiet", False),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def execute(self) -> CommandResult:
|
|
253
|
+
"""Execute analytics analysis using analytics/cli.py implementation."""
|
|
254
|
+
from htmlgraph.analytics.cli import cmd_analytics
|
|
255
|
+
|
|
256
|
+
args = argparse.Namespace(
|
|
257
|
+
graph_dir=self.graph_dir,
|
|
258
|
+
session_id=self.session_id,
|
|
259
|
+
recent=self.recent,
|
|
260
|
+
agent=self.agent,
|
|
261
|
+
quiet=self.quiet,
|
|
262
|
+
)
|
|
263
|
+
exit_code = cmd_analytics(args)
|
|
264
|
+
if exit_code != 0:
|
|
265
|
+
raise CommandError("Analytics command failed", exit_code=exit_code)
|
|
266
|
+
return CommandResult(text="Analytics complete")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class CostDashboardCommand(BaseCommand):
|
|
270
|
+
"""Display cost summary dashboard."""
|
|
271
|
+
|
|
272
|
+
def __init__(
|
|
273
|
+
self,
|
|
274
|
+
*,
|
|
275
|
+
save: bool,
|
|
276
|
+
open_browser: bool,
|
|
277
|
+
json_output: bool,
|
|
278
|
+
output_path: str | None,
|
|
279
|
+
) -> None:
|
|
280
|
+
super().__init__()
|
|
281
|
+
self.save = save
|
|
282
|
+
self.open_browser = open_browser
|
|
283
|
+
self.json_output = json_output
|
|
284
|
+
self.output_path = output_path
|
|
285
|
+
|
|
286
|
+
@classmethod
|
|
287
|
+
def from_args(cls, args: argparse.Namespace) -> CostDashboardCommand:
|
|
288
|
+
return cls(
|
|
289
|
+
save=args.save,
|
|
290
|
+
open_browser=getattr(args, "open", False),
|
|
291
|
+
json_output=getattr(args, "json", False),
|
|
292
|
+
output_path=getattr(args, "output", None),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def execute(self) -> CommandResult:
|
|
296
|
+
"""Generate and display cost dashboard."""
|
|
297
|
+
if not self.graph_dir:
|
|
298
|
+
raise CommandError("Graph directory not specified")
|
|
299
|
+
graph_dir = Path(self.graph_dir)
|
|
300
|
+
|
|
301
|
+
# Get events from database
|
|
302
|
+
with console.status(
|
|
303
|
+
"[blue]Analyzing HtmlGraph events...[/blue]", spinner="dots"
|
|
304
|
+
):
|
|
305
|
+
try:
|
|
306
|
+
from htmlgraph.operations.events import query_events
|
|
307
|
+
|
|
308
|
+
result = query_events(graph_dir=graph_dir, limit=None)
|
|
309
|
+
events = result.events if hasattr(result, "events") else []
|
|
310
|
+
|
|
311
|
+
if not events:
|
|
312
|
+
console.print(
|
|
313
|
+
"[yellow]No events found. Run some work to generate analytics![/yellow]"
|
|
314
|
+
)
|
|
315
|
+
return CommandResult(text="No events to analyze")
|
|
316
|
+
|
|
317
|
+
# Calculate costs
|
|
318
|
+
cost_summary = self._analyze_event_costs(events)
|
|
319
|
+
|
|
320
|
+
except Exception as e:
|
|
321
|
+
console.print(f"[red]Error analyzing events: {e}[/red]")
|
|
322
|
+
raise CommandError(f"Failed to analyze events: {e}")
|
|
323
|
+
|
|
324
|
+
# Generate output
|
|
325
|
+
if self.json_output:
|
|
326
|
+
self._output_json(cost_summary)
|
|
327
|
+
else:
|
|
328
|
+
if self.save or self.output_path:
|
|
329
|
+
html_file = self._save_html_dashboard(cost_summary, graph_dir)
|
|
330
|
+
if self.open_browser:
|
|
331
|
+
webbrowser.open(f"file://{html_file.absolute()}")
|
|
332
|
+
console.print("[blue]Opening dashboard in browser...[/blue]")
|
|
333
|
+
else:
|
|
334
|
+
self._display_console_summary(cost_summary)
|
|
335
|
+
|
|
336
|
+
# Print recommendations
|
|
337
|
+
self._print_recommendations(cost_summary)
|
|
338
|
+
|
|
339
|
+
return CommandResult(text="Cost dashboard generated")
|
|
340
|
+
|
|
341
|
+
def _analyze_event_costs(self, events: list[dict]) -> CostSummary:
|
|
342
|
+
"""Analyze events and calculate cost attribution."""
|
|
343
|
+
summary = CostSummary(
|
|
344
|
+
total_events=len(events),
|
|
345
|
+
total_cost_tokens=0,
|
|
346
|
+
delegation_count=0,
|
|
347
|
+
direct_execution_count=0,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
for event in events:
|
|
351
|
+
try:
|
|
352
|
+
tool = event.get("tool", "unknown")
|
|
353
|
+
session_id = event.get("session_id", "unknown")
|
|
354
|
+
cost = (
|
|
355
|
+
event.get("predicted_tokens", 0)
|
|
356
|
+
or event.get("actual_tokens", 0)
|
|
357
|
+
or 2000
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Track by tool
|
|
361
|
+
if tool not in summary.tool_costs:
|
|
362
|
+
summary.tool_costs[tool] = ToolCostData(count=0, total_tokens=0)
|
|
363
|
+
summary.tool_costs[tool].count += 1
|
|
364
|
+
summary.tool_costs[tool].total_tokens += cost
|
|
365
|
+
|
|
366
|
+
# Track by session
|
|
367
|
+
if session_id not in summary.session_costs:
|
|
368
|
+
summary.session_costs[session_id] = ToolCostData(
|
|
369
|
+
count=0, total_tokens=0
|
|
370
|
+
)
|
|
371
|
+
summary.session_costs[session_id].count += 1
|
|
372
|
+
summary.session_costs[session_id].total_tokens += cost
|
|
373
|
+
|
|
374
|
+
# Track delegation vs direct
|
|
375
|
+
delegation_tools = [
|
|
376
|
+
"Task",
|
|
377
|
+
"spawn_gemini",
|
|
378
|
+
"spawn_codex",
|
|
379
|
+
"spawn_copilot",
|
|
380
|
+
]
|
|
381
|
+
if tool in delegation_tools:
|
|
382
|
+
summary.delegation_count += 1
|
|
383
|
+
category = "delegation"
|
|
384
|
+
else:
|
|
385
|
+
summary.direct_execution_count += 1
|
|
386
|
+
category = "direct"
|
|
387
|
+
|
|
388
|
+
if category not in summary.cost_by_category:
|
|
389
|
+
summary.cost_by_category[category] = CategoryCostData(
|
|
390
|
+
count=0, total_tokens=0
|
|
391
|
+
)
|
|
392
|
+
summary.cost_by_category[category].count += 1
|
|
393
|
+
summary.cost_by_category[category].total_tokens += cost
|
|
394
|
+
|
|
395
|
+
summary.total_cost_tokens += cost
|
|
396
|
+
|
|
397
|
+
except Exception:
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
return summary
|
|
401
|
+
|
|
402
|
+
def _output_json(self, summary: CostSummary) -> None:
|
|
403
|
+
"""Output cost data as JSON."""
|
|
404
|
+
output_file = (
|
|
405
|
+
Path(self.output_path) if self.output_path else Path("cost-summary.json")
|
|
406
|
+
)
|
|
407
|
+
output_file.write_text(summary.model_dump_json(indent=2))
|
|
408
|
+
console.print(f"[green]✓ JSON output saved to: {output_file}[/green]")
|
|
409
|
+
|
|
410
|
+
def _save_html_dashboard(self, summary: CostSummary, graph_dir: Path) -> Path:
|
|
411
|
+
"""Save HTML dashboard to file."""
|
|
412
|
+
from htmlgraph.cli.templates.cost_dashboard import generate_html
|
|
413
|
+
|
|
414
|
+
html_content = generate_html(summary)
|
|
415
|
+
output_file = (
|
|
416
|
+
Path(self.output_path)
|
|
417
|
+
if self.output_path
|
|
418
|
+
else graph_dir / "cost-dashboard.html"
|
|
419
|
+
)
|
|
420
|
+
output_file.write_text(html_content)
|
|
421
|
+
console.print(f"[green]✓ Dashboard saved to: {output_file}[/green]")
|
|
422
|
+
return output_file
|
|
423
|
+
|
|
424
|
+
def _display_console_summary(self, summary: CostSummary) -> None:
|
|
425
|
+
"""Display cost summary in console."""
|
|
426
|
+
from htmlgraph.cli.base import TableBuilder
|
|
427
|
+
|
|
428
|
+
console.print("\n[bold cyan]Cost Dashboard Summary[/bold cyan]\n")
|
|
429
|
+
|
|
430
|
+
# Summary table
|
|
431
|
+
summary_builder = TableBuilder.create_list_table(title=None)
|
|
432
|
+
summary_builder.add_column("Metric", style="cyan")
|
|
433
|
+
summary_builder.add_column("Value", style="green")
|
|
434
|
+
|
|
435
|
+
summary_builder.add_row("Total Events", str(summary.total_events))
|
|
436
|
+
summary_builder.add_row("Total Cost", f"{summary.total_cost_tokens:,} tokens")
|
|
437
|
+
summary_builder.add_row(
|
|
438
|
+
"Average Cost", f"{summary.avg_cost_per_event:,.0f} tokens/event"
|
|
439
|
+
)
|
|
440
|
+
summary_builder.add_row("Estimated USD", f"${summary.estimated_cost_usd:.2f}")
|
|
441
|
+
summary_builder.add_row("Delegation Count", str(summary.delegation_count))
|
|
442
|
+
summary_builder.add_row(
|
|
443
|
+
"Delegation Rate", f"{summary.delegation_percentage:.1f}%"
|
|
444
|
+
)
|
|
445
|
+
summary_builder.add_row(
|
|
446
|
+
"Direct Executions", str(summary.direct_execution_count)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
console.print(summary_builder.table)
|
|
450
|
+
|
|
451
|
+
# Top tools table
|
|
452
|
+
if summary.tool_costs:
|
|
453
|
+
console.print("\n[bold cyan]Top Cost Drivers (by Tool)[/bold cyan]\n")
|
|
454
|
+
tools_builder = TableBuilder.create_list_table(title=None)
|
|
455
|
+
tools_builder.add_column("Tool", style="cyan")
|
|
456
|
+
tools_builder.add_numeric_column("Count", style="green")
|
|
457
|
+
tools_builder.add_numeric_column("Tokens", style="yellow")
|
|
458
|
+
tools_builder.add_numeric_column("% Total", style="magenta")
|
|
459
|
+
|
|
460
|
+
sorted_tools = sorted(
|
|
461
|
+
summary.tool_costs.items(),
|
|
462
|
+
key=lambda x: x[1].total_tokens,
|
|
463
|
+
reverse=True,
|
|
464
|
+
)
|
|
465
|
+
for tool, data in sorted_tools[:10]:
|
|
466
|
+
pct = data.total_tokens / summary.total_cost_tokens * 100
|
|
467
|
+
tools_builder.add_row(
|
|
468
|
+
tool, str(data.count), f"{data.total_tokens:,}", f"{pct:.1f}%"
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
console.print(tools_builder.table)
|
|
472
|
+
|
|
473
|
+
def _print_recommendations(self, summary: CostSummary) -> None:
|
|
474
|
+
"""Print cost optimization recommendations."""
|
|
475
|
+
console.print("\n[bold cyan]Recommendations[/bold cyan]\n")
|
|
476
|
+
|
|
477
|
+
recommendations = []
|
|
478
|
+
|
|
479
|
+
if summary.delegation_percentage < 50:
|
|
480
|
+
recommendations.append(
|
|
481
|
+
"[yellow]→ Increase delegation usage[/yellow] - Consider using Task() and spawn_* for more operations"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
if summary.tool_costs:
|
|
485
|
+
top_tool, top_data = max(
|
|
486
|
+
summary.tool_costs.items(), key=lambda x: x[1].total_tokens
|
|
487
|
+
)
|
|
488
|
+
top_pct = top_data.total_tokens / summary.total_cost_tokens * 100
|
|
489
|
+
if top_pct > 40:
|
|
490
|
+
recommendations.append(
|
|
491
|
+
f"[yellow]→ Review {top_tool} usage[/yellow] - It accounts for {top_pct:.1f}% of total cost"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
if summary.total_events > 100:
|
|
495
|
+
recommendations.append(
|
|
496
|
+
"[green]✓ Good event volume[/green] - Sufficient data for optimization analysis"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
recommendations.append(
|
|
500
|
+
"[blue]💡 Tip: Use parallel Task() calls to reduce execution time by ~40%[/blue]"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
for rec in recommendations:
|
|
504
|
+
console.print(f" {rec}")
|
|
505
|
+
|
|
506
|
+
console.print()
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
class CigsStatusCommand(BaseCommand):
|
|
510
|
+
"""Show CIGS status."""
|
|
511
|
+
|
|
512
|
+
@classmethod
|
|
513
|
+
def from_args(cls, args: argparse.Namespace) -> CigsStatusCommand:
|
|
514
|
+
return cls()
|
|
515
|
+
|
|
516
|
+
def execute(self) -> CommandResult:
|
|
517
|
+
"""Show CIGS status."""
|
|
518
|
+
from htmlgraph.cigs.autonomy import AutonomyRecommender
|
|
519
|
+
from htmlgraph.cigs.pattern_storage import PatternStorage
|
|
520
|
+
from htmlgraph.cigs.tracker import ViolationTracker
|
|
521
|
+
|
|
522
|
+
if not self.graph_dir:
|
|
523
|
+
raise CommandError("Graph directory not specified")
|
|
524
|
+
graph_dir = Path(self.graph_dir)
|
|
525
|
+
|
|
526
|
+
# Get violation tracker
|
|
527
|
+
tracker = ViolationTracker(graph_dir)
|
|
528
|
+
summary = tracker.get_session_violations()
|
|
529
|
+
|
|
530
|
+
# Get pattern storage
|
|
531
|
+
pattern_storage = PatternStorage(graph_dir)
|
|
532
|
+
patterns = pattern_storage.get_anti_patterns()
|
|
533
|
+
|
|
534
|
+
# Get autonomy recommendation
|
|
535
|
+
recommender = AutonomyRecommender()
|
|
536
|
+
autonomy = recommender.recommend(summary, patterns)
|
|
537
|
+
|
|
538
|
+
# Display with Rich
|
|
539
|
+
status_table = Table(title="CIGS Status", box=box.ROUNDED)
|
|
540
|
+
status_table.add_column("Metric", style="cyan")
|
|
541
|
+
status_table.add_column("Value", style="green")
|
|
542
|
+
|
|
543
|
+
status_table.add_row("Session", summary.session_id)
|
|
544
|
+
status_table.add_row("Violations", f"{summary.total_violations}/3")
|
|
545
|
+
status_table.add_row("Compliance Rate", f"{summary.compliance_rate:.1%}")
|
|
546
|
+
status_table.add_row("Total Waste", f"{summary.total_waste_tokens} tokens")
|
|
547
|
+
status_table.add_row(
|
|
548
|
+
"Circuit Breaker",
|
|
549
|
+
"🚨 TRIGGERED" if summary.circuit_breaker_triggered else "Not triggered",
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
console.print(status_table)
|
|
553
|
+
|
|
554
|
+
if summary.violations_by_type:
|
|
555
|
+
console.print("\n[bold]Violation Breakdown:[/bold]")
|
|
556
|
+
for vtype, count in summary.violations_by_type.items():
|
|
557
|
+
console.print(f" • {vtype}: {count}")
|
|
558
|
+
|
|
559
|
+
console.print(f"\n[bold]Autonomy Level:[/bold] {autonomy.level.upper()}")
|
|
560
|
+
console.print(
|
|
561
|
+
f"[bold]Messaging Intensity:[/bold] {autonomy.messaging_intensity}"
|
|
562
|
+
)
|
|
563
|
+
console.print(f"[bold]Enforcement Mode:[/bold] {autonomy.enforcement_mode}")
|
|
564
|
+
|
|
565
|
+
if patterns:
|
|
566
|
+
console.print(f"\n[bold]Anti-Patterns Detected:[/bold] {len(patterns)}")
|
|
567
|
+
for pattern in patterns[:3]:
|
|
568
|
+
console.print(f" • {pattern.name} ({pattern.occurrence_count}x)")
|
|
569
|
+
|
|
570
|
+
return CommandResult(text="CIGS status displayed")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
class CigsSummaryCommand(BaseCommand):
|
|
574
|
+
"""Show cost summary."""
|
|
575
|
+
|
|
576
|
+
def __init__(self, *, session_id: str | None) -> None:
|
|
577
|
+
super().__init__()
|
|
578
|
+
self.session_id = session_id
|
|
579
|
+
|
|
580
|
+
@classmethod
|
|
581
|
+
def from_args(cls, args: argparse.Namespace) -> CigsSummaryCommand:
|
|
582
|
+
return cls(session_id=getattr(args, "session_id", None))
|
|
583
|
+
|
|
584
|
+
def execute(self) -> CommandResult:
|
|
585
|
+
"""Show cost summary."""
|
|
586
|
+
from htmlgraph.cigs.tracker import ViolationTracker
|
|
587
|
+
|
|
588
|
+
if not self.graph_dir:
|
|
589
|
+
raise CommandError("Graph directory not specified")
|
|
590
|
+
graph_dir = Path(self.graph_dir)
|
|
591
|
+
tracker = ViolationTracker(graph_dir)
|
|
592
|
+
|
|
593
|
+
# Get session ID
|
|
594
|
+
session_id = self.session_id or tracker._session_id
|
|
595
|
+
|
|
596
|
+
if not session_id:
|
|
597
|
+
console.print(
|
|
598
|
+
"[yellow]⚠️ No active session. Specify --session-id to view past sessions.[/yellow]"
|
|
599
|
+
)
|
|
600
|
+
return CommandResult(text="No active session")
|
|
601
|
+
|
|
602
|
+
summary = tracker.get_session_violations(session_id)
|
|
603
|
+
|
|
604
|
+
# Display summary
|
|
605
|
+
panel = Panel(
|
|
606
|
+
f"[cyan]Session ID:[/cyan] {summary.session_id}\n"
|
|
607
|
+
f"[cyan]Total Violations:[/cyan] {summary.total_violations}\n"
|
|
608
|
+
f"[cyan]Compliance Rate:[/cyan] {summary.compliance_rate:.1%}\n"
|
|
609
|
+
f"[cyan]Total Waste:[/cyan] {summary.total_waste_tokens} tokens\n"
|
|
610
|
+
f"[cyan]Circuit Breaker:[/cyan] {'🚨 TRIGGERED' if summary.circuit_breaker_triggered else 'Not triggered'}",
|
|
611
|
+
title="CIGS Session Summary",
|
|
612
|
+
border_style="cyan",
|
|
613
|
+
)
|
|
614
|
+
console.print(panel)
|
|
615
|
+
|
|
616
|
+
if summary.violations_by_type:
|
|
617
|
+
console.print("\n[bold]Violation Breakdown:[/bold]")
|
|
618
|
+
for vtype, count in summary.violations_by_type.items():
|
|
619
|
+
console.print(f" • {vtype}: {count}")
|
|
620
|
+
|
|
621
|
+
if summary.violations:
|
|
622
|
+
console.print(
|
|
623
|
+
f"\n[bold]Recent Violations ({len(summary.violations)}):[/bold]"
|
|
624
|
+
)
|
|
625
|
+
for v in summary.violations[-5:]:
|
|
626
|
+
console.print(
|
|
627
|
+
f" • {v.tool} - {v.violation_type} - {v.waste_tokens} tokens wasted"
|
|
628
|
+
)
|
|
629
|
+
console.print(f" Should have: {v.should_have_delegated_to}")
|
|
630
|
+
|
|
631
|
+
return CommandResult(text="Cost summary displayed")
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
class TranscriptListCommand(BaseCommand):
|
|
635
|
+
"""List transcripts."""
|
|
636
|
+
|
|
637
|
+
def __init__(self, *, format: str, limit: int, project: str | None) -> None:
|
|
638
|
+
super().__init__()
|
|
639
|
+
self.format = format
|
|
640
|
+
self.limit = limit
|
|
641
|
+
self.project = project
|
|
642
|
+
|
|
643
|
+
@classmethod
|
|
644
|
+
def from_args(cls, args: argparse.Namespace) -> TranscriptListCommand:
|
|
645
|
+
return cls(
|
|
646
|
+
format=getattr(args, "format", "text"),
|
|
647
|
+
limit=getattr(args, "limit", 20),
|
|
648
|
+
project=getattr(args, "project", None),
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
def execute(self) -> CommandResult:
|
|
652
|
+
"""List all transcripts."""
|
|
653
|
+
from htmlgraph.transcript import TranscriptReader
|
|
654
|
+
|
|
655
|
+
reader = TranscriptReader()
|
|
656
|
+
sessions = reader.list_sessions(project_path=self.project, limit=self.limit)
|
|
657
|
+
|
|
658
|
+
if not sessions:
|
|
659
|
+
if self.format == "json":
|
|
660
|
+
console.print_json(json.dumps({"sessions": [], "count": 0}))
|
|
661
|
+
else:
|
|
662
|
+
console.print("[yellow]No Claude Code transcripts found.[/yellow]")
|
|
663
|
+
console.print(f"[dim]Looked in: {reader.claude_dir}[/dim]")
|
|
664
|
+
return CommandResult(text="No transcripts found")
|
|
665
|
+
|
|
666
|
+
if self.format == "json":
|
|
667
|
+
data = {
|
|
668
|
+
"sessions": [
|
|
669
|
+
{
|
|
670
|
+
"session_id": s.session_id,
|
|
671
|
+
"path": str(s.path),
|
|
672
|
+
"cwd": s.cwd,
|
|
673
|
+
"git_branch": s.git_branch,
|
|
674
|
+
"started_at": s.started_at.isoformat()
|
|
675
|
+
if s.started_at
|
|
676
|
+
else None,
|
|
677
|
+
"user_messages": s.user_message_count,
|
|
678
|
+
"tool_calls": s.tool_call_count,
|
|
679
|
+
"duration_seconds": s.duration_seconds,
|
|
680
|
+
}
|
|
681
|
+
for s in sessions
|
|
682
|
+
],
|
|
683
|
+
"count": len(sessions),
|
|
684
|
+
}
|
|
685
|
+
console.print_json(json.dumps(data))
|
|
686
|
+
else:
|
|
687
|
+
# Display with Rich table
|
|
688
|
+
table = Table(
|
|
689
|
+
title=f"Claude Code Transcripts ({len(sessions)} found)",
|
|
690
|
+
box=box.ROUNDED,
|
|
691
|
+
)
|
|
692
|
+
table.add_column("Session ID", style="cyan", no_wrap=False, max_width=20)
|
|
693
|
+
table.add_column("Started", style="dim")
|
|
694
|
+
table.add_column("Duration", justify="right")
|
|
695
|
+
table.add_column("Messages", justify="right")
|
|
696
|
+
table.add_column("Branch", style="blue")
|
|
697
|
+
|
|
698
|
+
for s in sessions:
|
|
699
|
+
started = (
|
|
700
|
+
s.started_at.strftime("%Y-%m-%d %H:%M")
|
|
701
|
+
if s.started_at
|
|
702
|
+
else "unknown"
|
|
703
|
+
)
|
|
704
|
+
duration = (
|
|
705
|
+
f"{int(s.duration_seconds / 60)}m" if s.duration_seconds else "?"
|
|
706
|
+
)
|
|
707
|
+
branch = s.git_branch or "no branch"
|
|
708
|
+
|
|
709
|
+
table.add_row(
|
|
710
|
+
s.session_id[:20] + "...",
|
|
711
|
+
started,
|
|
712
|
+
duration,
|
|
713
|
+
str(s.user_message_count),
|
|
714
|
+
branch,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
console.print(table)
|
|
718
|
+
|
|
719
|
+
return CommandResult(text=f"Listed {len(sessions)} transcripts")
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
class TranscriptImportCommand(BaseCommand):
|
|
723
|
+
"""Import transcript."""
|
|
724
|
+
|
|
725
|
+
def __init__(
|
|
726
|
+
self,
|
|
727
|
+
*,
|
|
728
|
+
session_id: str,
|
|
729
|
+
to_session: str | None,
|
|
730
|
+
agent: str,
|
|
731
|
+
overwrite: bool,
|
|
732
|
+
link_feature: str | None,
|
|
733
|
+
format: str,
|
|
734
|
+
) -> None:
|
|
735
|
+
super().__init__()
|
|
736
|
+
self.session_id = session_id
|
|
737
|
+
self.to_session = to_session
|
|
738
|
+
self.agent = agent
|
|
739
|
+
self.overwrite = overwrite
|
|
740
|
+
self.link_feature = link_feature
|
|
741
|
+
self.format = format
|
|
742
|
+
|
|
743
|
+
@classmethod
|
|
744
|
+
def from_args(cls, args: argparse.Namespace) -> TranscriptImportCommand:
|
|
745
|
+
return cls(
|
|
746
|
+
session_id=args.session_id,
|
|
747
|
+
to_session=getattr(args, "to_session", None),
|
|
748
|
+
agent=getattr(args, "agent", "claude-code"),
|
|
749
|
+
overwrite=getattr(args, "overwrite", False),
|
|
750
|
+
link_feature=getattr(args, "link_feature", None),
|
|
751
|
+
format=getattr(args, "format", "text"),
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
def execute(self) -> CommandResult:
|
|
755
|
+
"""Import a transcript file."""
|
|
756
|
+
from htmlgraph.session_manager import SessionManager
|
|
757
|
+
from htmlgraph.transcript import TranscriptReader
|
|
758
|
+
|
|
759
|
+
if not self.graph_dir:
|
|
760
|
+
raise CommandError("Graph directory not specified")
|
|
761
|
+
|
|
762
|
+
reader = TranscriptReader()
|
|
763
|
+
manager = SessionManager(self.graph_dir)
|
|
764
|
+
|
|
765
|
+
# Find the transcript
|
|
766
|
+
transcript = reader.read_session(self.session_id)
|
|
767
|
+
if not transcript:
|
|
768
|
+
console.print(f"[red]Error: Transcript not found: {self.session_id}[/red]")
|
|
769
|
+
return CommandResult(text="Transcript not found", exit_code=1)
|
|
770
|
+
|
|
771
|
+
# Find or create HtmlGraph session
|
|
772
|
+
htmlgraph_session_id = self.to_session
|
|
773
|
+
if not htmlgraph_session_id:
|
|
774
|
+
# Check if already linked
|
|
775
|
+
existing = manager.find_session_by_transcript(self.session_id)
|
|
776
|
+
if existing:
|
|
777
|
+
htmlgraph_session_id = existing.id
|
|
778
|
+
console.print(
|
|
779
|
+
f"[blue]Found existing linked session: {htmlgraph_session_id}[/blue]"
|
|
780
|
+
)
|
|
781
|
+
else:
|
|
782
|
+
# Create new session
|
|
783
|
+
new_session = manager.start_session(
|
|
784
|
+
agent=self.agent,
|
|
785
|
+
title=f"Imported: {transcript.session_id[:12]}",
|
|
786
|
+
)
|
|
787
|
+
htmlgraph_session_id = new_session.id
|
|
788
|
+
console.print(
|
|
789
|
+
f"[green]Created new session: {htmlgraph_session_id}[/green]"
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
# Import events
|
|
793
|
+
result = manager.import_transcript_events(
|
|
794
|
+
session_id=htmlgraph_session_id,
|
|
795
|
+
transcript_session=transcript,
|
|
796
|
+
overwrite=self.overwrite,
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
# Link to feature if specified
|
|
800
|
+
if self.link_feature:
|
|
801
|
+
session = manager.get_session(htmlgraph_session_id)
|
|
802
|
+
if session and self.link_feature not in session.worked_on:
|
|
803
|
+
session.worked_on.append(self.link_feature)
|
|
804
|
+
manager.session_converter.save(session)
|
|
805
|
+
result["linked_feature"] = self.link_feature
|
|
806
|
+
|
|
807
|
+
# Display results
|
|
808
|
+
if self.format == "json":
|
|
809
|
+
console.print_json(json.dumps(result))
|
|
810
|
+
else:
|
|
811
|
+
console.print(
|
|
812
|
+
f"[green]✅ Imported transcript {self.session_id[:12]}:[/green]"
|
|
813
|
+
)
|
|
814
|
+
console.print(f" → HtmlGraph session: {htmlgraph_session_id}")
|
|
815
|
+
console.print(f" → Events imported: {result.get('imported', 0)}")
|
|
816
|
+
console.print(f" → Events skipped: {result.get('skipped', 0)}")
|
|
817
|
+
if result.get("linked_feature"):
|
|
818
|
+
console.print(f" → Linked to feature: {result['linked_feature']}")
|
|
819
|
+
|
|
820
|
+
return CommandResult(text=f"Imported transcript: {self.session_id}")
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
class SyncDocsCommand(BaseCommand):
|
|
824
|
+
"""Synchronize AI agent memory files."""
|
|
825
|
+
|
|
826
|
+
def __init__(
|
|
827
|
+
self,
|
|
828
|
+
*,
|
|
829
|
+
project_root: str | None,
|
|
830
|
+
check: bool,
|
|
831
|
+
generate: str | None,
|
|
832
|
+
force: bool,
|
|
833
|
+
) -> None:
|
|
834
|
+
super().__init__()
|
|
835
|
+
self.project_root = project_root
|
|
836
|
+
self.check = check
|
|
837
|
+
self.generate = generate
|
|
838
|
+
self.force = force
|
|
839
|
+
|
|
840
|
+
@classmethod
|
|
841
|
+
def from_args(cls, args: argparse.Namespace) -> SyncDocsCommand:
|
|
842
|
+
return cls(
|
|
843
|
+
project_root=getattr(args, "project_root", None),
|
|
844
|
+
check=getattr(args, "check", False),
|
|
845
|
+
generate=getattr(args, "generate", None),
|
|
846
|
+
force=getattr(args, "force", False),
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
def execute(self) -> CommandResult:
|
|
850
|
+
"""Synchronize AI agent memory files across platforms."""
|
|
851
|
+
import os
|
|
852
|
+
|
|
853
|
+
from htmlgraph.sync_docs import (
|
|
854
|
+
PLATFORM_TEMPLATES,
|
|
855
|
+
check_all_files,
|
|
856
|
+
generate_platform_file,
|
|
857
|
+
sync_all_files,
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
project_root = Path(self.project_root or os.getcwd()).resolve()
|
|
861
|
+
|
|
862
|
+
if self.check:
|
|
863
|
+
# Check mode
|
|
864
|
+
console.print("[blue]🔍 Checking memory files...[/blue]")
|
|
865
|
+
results = check_all_files(project_root)
|
|
866
|
+
|
|
867
|
+
table = Table(title="Memory File Status", box=box.ROUNDED)
|
|
868
|
+
table.add_column("File", style="cyan")
|
|
869
|
+
table.add_column("Status", style="green")
|
|
870
|
+
|
|
871
|
+
all_good = True
|
|
872
|
+
for filename, status in results.items():
|
|
873
|
+
if filename == "AGENTS.md":
|
|
874
|
+
if status:
|
|
875
|
+
table.add_row(filename, "✅ exists")
|
|
876
|
+
else:
|
|
877
|
+
table.add_row(filename, "❌ MISSING (required)")
|
|
878
|
+
all_good = False
|
|
879
|
+
else:
|
|
880
|
+
if status:
|
|
881
|
+
table.add_row(filename, "✅ references AGENTS.md")
|
|
882
|
+
else:
|
|
883
|
+
table.add_row(filename, "⚠️ missing reference")
|
|
884
|
+
all_good = False
|
|
885
|
+
|
|
886
|
+
console.print(table)
|
|
887
|
+
|
|
888
|
+
if all_good:
|
|
889
|
+
console.print(
|
|
890
|
+
"\n[green]✅ All files are properly synchronized![/green]"
|
|
891
|
+
)
|
|
892
|
+
return CommandResult(text="All files synchronized", exit_code=0)
|
|
893
|
+
else:
|
|
894
|
+
console.print("\n[yellow]⚠️ Some files need attention[/yellow]")
|
|
895
|
+
return CommandResult(text="Files need attention", exit_code=1)
|
|
896
|
+
|
|
897
|
+
elif self.generate:
|
|
898
|
+
# Generate mode
|
|
899
|
+
platform = self.generate.lower()
|
|
900
|
+
console.print(
|
|
901
|
+
f"[blue]📝 Generating {platform.upper()} memory file...[/blue]"
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
try:
|
|
905
|
+
content = generate_platform_file(platform, project_root)
|
|
906
|
+
template = PLATFORM_TEMPLATES[platform]
|
|
907
|
+
filepath = project_root / template["filename"]
|
|
908
|
+
|
|
909
|
+
if filepath.exists() and not self.force:
|
|
910
|
+
console.print(
|
|
911
|
+
f"[yellow]⚠️ {filepath.name} already exists. Use --force to overwrite.[/yellow]"
|
|
912
|
+
)
|
|
913
|
+
raise CommandError("File already exists")
|
|
914
|
+
|
|
915
|
+
filepath.write_text(content)
|
|
916
|
+
console.print(f"[green]✅ Created: {filepath}[/green]")
|
|
917
|
+
console.print(
|
|
918
|
+
"\n[dim]The file references AGENTS.md for core documentation.[/dim]"
|
|
919
|
+
)
|
|
920
|
+
return CommandResult(text=f"Generated {platform} file")
|
|
921
|
+
|
|
922
|
+
except ValueError as e:
|
|
923
|
+
console.print(f"[red]❌ Error: {e}[/red]")
|
|
924
|
+
return CommandResult(text=str(e), exit_code=1)
|
|
925
|
+
|
|
926
|
+
else:
|
|
927
|
+
# Sync mode (default)
|
|
928
|
+
console.print("[blue]🔄 Synchronizing memory files...[/blue]")
|
|
929
|
+
changes = sync_all_files(project_root)
|
|
930
|
+
|
|
931
|
+
console.print("\n[bold]Results:[/bold]")
|
|
932
|
+
for change in changes:
|
|
933
|
+
console.print(f" {change}")
|
|
934
|
+
|
|
935
|
+
has_errors = any("⚠️" in c or "❌" in c for c in changes)
|
|
936
|
+
return CommandResult(
|
|
937
|
+
text="Synchronization complete",
|
|
938
|
+
exit_code=1 if has_errors else 0,
|
|
939
|
+
)
|