htmlgraph 0.20.1__py3-none-any.whl → 0.27.5__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 +6 -0
- htmlgraph/.htmlgraph/agents.json +72 -0
- htmlgraph/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/__init__.py +51 -1
- htmlgraph/__init__.pyi +123 -0
- htmlgraph/agent_detection.py +26 -10
- htmlgraph/agent_registry.py +2 -1
- htmlgraph/analytics/__init__.py +8 -1
- htmlgraph/analytics/cli.py +86 -20
- htmlgraph/analytics/cost_analyzer.py +391 -0
- htmlgraph/analytics/cost_monitor.py +664 -0
- htmlgraph/analytics/cost_reporter.py +675 -0
- htmlgraph/analytics/cross_session.py +617 -0
- htmlgraph/analytics/dependency.py +10 -6
- htmlgraph/analytics/pattern_learning.py +771 -0
- htmlgraph/analytics/session_graph.py +707 -0
- htmlgraph/analytics/strategic/__init__.py +80 -0
- htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
- htmlgraph/analytics/strategic/pattern_detector.py +876 -0
- htmlgraph/analytics/strategic/preference_manager.py +709 -0
- htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
- htmlgraph/analytics/work_type.py +67 -27
- htmlgraph/analytics_index.py +53 -20
- htmlgraph/api/__init__.py +3 -0
- htmlgraph/api/cost_alerts_websocket.py +416 -0
- htmlgraph/api/main.py +2498 -0
- htmlgraph/api/static/htmx.min.js +1 -0
- htmlgraph/api/static/style-redesign.css +1344 -0
- htmlgraph/api/static/style.css +1079 -0
- htmlgraph/api/templates/dashboard-redesign.html +1366 -0
- htmlgraph/api/templates/dashboard.html +794 -0
- htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
- htmlgraph/api/templates/partials/activity-feed.html +1100 -0
- htmlgraph/api/templates/partials/agents-redesign.html +317 -0
- htmlgraph/api/templates/partials/agents.html +317 -0
- htmlgraph/api/templates/partials/event-traces.html +373 -0
- htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
- htmlgraph/api/templates/partials/features.html +578 -0
- htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
- htmlgraph/api/templates/partials/metrics.html +346 -0
- htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
- htmlgraph/api/templates/partials/orchestration.html +198 -0
- htmlgraph/api/templates/partials/spawners.html +375 -0
- htmlgraph/api/templates/partials/work-items.html +613 -0
- htmlgraph/api/websocket.py +538 -0
- htmlgraph/archive/__init__.py +24 -0
- htmlgraph/archive/bloom.py +234 -0
- htmlgraph/archive/fts.py +297 -0
- htmlgraph/archive/manager.py +583 -0
- htmlgraph/archive/search.py +244 -0
- htmlgraph/atomic_ops.py +560 -0
- htmlgraph/attribute_index.py +2 -1
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/builders/base.py +57 -2
- htmlgraph/builders/bug.py +19 -3
- htmlgraph/builders/chore.py +19 -3
- htmlgraph/builders/epic.py +19 -3
- htmlgraph/builders/feature.py +27 -3
- htmlgraph/builders/insight.py +2 -1
- htmlgraph/builders/metric.py +2 -1
- htmlgraph/builders/pattern.py +2 -1
- htmlgraph/builders/phase.py +19 -3
- htmlgraph/builders/spike.py +29 -3
- htmlgraph/builders/track.py +42 -1
- htmlgraph/cigs/__init__.py +81 -0
- htmlgraph/cigs/autonomy.py +385 -0
- htmlgraph/cigs/cost.py +475 -0
- htmlgraph/cigs/messages_basic.py +472 -0
- htmlgraph/cigs/messaging.py +365 -0
- htmlgraph/cigs/models.py +771 -0
- htmlgraph/cigs/pattern_storage.py +427 -0
- htmlgraph/cigs/patterns.py +503 -0
- htmlgraph/cigs/posttool_analyzer.py +234 -0
- htmlgraph/cigs/reporter.py +818 -0
- htmlgraph/cigs/tracker.py +317 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +1424 -0
- htmlgraph/cli/base.py +685 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +954 -0
- htmlgraph/cli/main.py +147 -0
- htmlgraph/cli/models.py +475 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +399 -0
- htmlgraph/cli/work/__init__.py +239 -0
- htmlgraph/cli/work/browse.py +115 -0
- htmlgraph/cli/work/features.py +568 -0
- htmlgraph/cli/work/orchestration.py +676 -0
- htmlgraph/cli/work/report.py +728 -0
- htmlgraph/cli/work/sessions.py +466 -0
- htmlgraph/cli/work/snapshot.py +559 -0
- htmlgraph/cli/work/tracks.py +486 -0
- htmlgraph/cli_commands/__init__.py +1 -0
- htmlgraph/cli_commands/feature.py +195 -0
- htmlgraph/cli_framework.py +115 -0
- htmlgraph/collections/__init__.py +2 -0
- htmlgraph/collections/base.py +197 -14
- htmlgraph/collections/bug.py +2 -1
- htmlgraph/collections/chore.py +2 -1
- htmlgraph/collections/epic.py +2 -1
- htmlgraph/collections/feature.py +2 -1
- htmlgraph/collections/insight.py +2 -1
- htmlgraph/collections/metric.py +2 -1
- htmlgraph/collections/pattern.py +2 -1
- htmlgraph/collections/phase.py +2 -1
- htmlgraph/collections/session.py +194 -0
- htmlgraph/collections/spike.py +13 -2
- htmlgraph/collections/task_delegation.py +241 -0
- htmlgraph/collections/todo.py +14 -1
- htmlgraph/collections/traces.py +487 -0
- htmlgraph/config/cost_models.json +56 -0
- htmlgraph/config.py +190 -0
- htmlgraph/context_analytics.py +2 -1
- htmlgraph/converter.py +116 -7
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +2246 -248
- htmlgraph/dashboard.html.backup +6592 -0
- htmlgraph/dashboard.html.bak +7181 -0
- htmlgraph/dashboard.html.bak2 +7231 -0
- htmlgraph/dashboard.html.bak3 +7232 -0
- htmlgraph/db/__init__.py +38 -0
- htmlgraph/db/queries.py +790 -0
- htmlgraph/db/schema.py +1788 -0
- htmlgraph/decorators.py +317 -0
- htmlgraph/dependency_models.py +2 -1
- htmlgraph/deploy.py +26 -27
- htmlgraph/docs/API_REFERENCE.md +841 -0
- htmlgraph/docs/HTTP_API.md +750 -0
- htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
- htmlgraph/docs/README.md +532 -0
- htmlgraph/docs/__init__.py +77 -0
- htmlgraph/docs/docs_version.py +55 -0
- htmlgraph/docs/metadata.py +93 -0
- htmlgraph/docs/migrations.py +232 -0
- htmlgraph/docs/template_engine.py +143 -0
- htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
- htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
- htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
- htmlgraph/docs/templates/base_agents.md.j2 +78 -0
- htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
- htmlgraph/docs/version_check.py +163 -0
- htmlgraph/edge_index.py +2 -1
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +86 -37
- htmlgraph/event_migration.py +2 -1
- htmlgraph/file_watcher.py +12 -8
- htmlgraph/find_api.py +2 -1
- htmlgraph/git_events.py +67 -9
- htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/hooks/.htmlgraph/agents.json +72 -0
- htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
- htmlgraph/hooks/__init__.py +8 -0
- htmlgraph/hooks/bootstrap.py +169 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +350 -0
- htmlgraph/hooks/drift_handler.py +525 -0
- htmlgraph/hooks/event_tracker.py +790 -99
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/installer.py +5 -1
- htmlgraph/hooks/orchestrator.py +327 -76
- htmlgraph/hooks/orchestrator_reflector.py +31 -4
- htmlgraph/hooks/post_tool_use_failure.py +32 -7
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/posttooluse.py +92 -19
- htmlgraph/hooks/pretooluse.py +527 -7
- htmlgraph/hooks/prompt_analyzer.py +637 -0
- htmlgraph/hooks/session_handler.py +668 -0
- htmlgraph/hooks/session_summary.py +395 -0
- htmlgraph/hooks/state_manager.py +504 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +369 -0
- htmlgraph/hooks/task_enforcer.py +99 -4
- htmlgraph/hooks/validator.py +212 -91
- htmlgraph/ids.py +2 -1
- htmlgraph/learning.py +125 -100
- htmlgraph/mcp_server.py +2 -1
- htmlgraph/models.py +217 -18
- htmlgraph/operations/README.md +62 -0
- htmlgraph/operations/__init__.py +79 -0
- htmlgraph/operations/analytics.py +339 -0
- htmlgraph/operations/bootstrap.py +289 -0
- htmlgraph/operations/events.py +244 -0
- htmlgraph/operations/fastapi_server.py +231 -0
- htmlgraph/operations/hooks.py +350 -0
- htmlgraph/operations/initialization.py +597 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/operations/server.py +303 -0
- htmlgraph/orchestration/__init__.py +58 -0
- htmlgraph/orchestration/claude_launcher.py +179 -0
- htmlgraph/orchestration/command_builder.py +72 -0
- htmlgraph/orchestration/headless_spawner.py +281 -0
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/orchestration/model_selection.py +327 -0
- htmlgraph/orchestration/plugin_manager.py +140 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawner_event_tracker.py +383 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +173 -0
- htmlgraph/orchestration/spawners/codex.py +435 -0
- htmlgraph/orchestration/spawners/copilot.py +294 -0
- htmlgraph/orchestration/spawners/gemini.py +471 -0
- htmlgraph/orchestration/subprocess_runner.py +36 -0
- htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
- htmlgraph/orchestrator.py +2 -1
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +115 -4
- htmlgraph/parallel.py +2 -1
- htmlgraph/parser.py +86 -6
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/query_builder.py +2 -1
- htmlgraph/query_composer.py +509 -0
- htmlgraph/reflection.py +443 -0
- htmlgraph/refs.py +344 -0
- htmlgraph/repo_hash.py +512 -0
- htmlgraph/repositories/__init__.py +292 -0
- htmlgraph/repositories/analytics_repository.py +455 -0
- htmlgraph/repositories/analytics_repository_standard.py +628 -0
- htmlgraph/repositories/feature_repository.py +581 -0
- htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
- htmlgraph/repositories/feature_repository_memory.py +607 -0
- htmlgraph/repositories/feature_repository_sqlite.py +858 -0
- htmlgraph/repositories/filter_service.py +620 -0
- htmlgraph/repositories/filter_service_standard.py +445 -0
- htmlgraph/repositories/shared_cache.py +621 -0
- htmlgraph/repositories/shared_cache_memory.py +395 -0
- htmlgraph/repositories/track_repository.py +552 -0
- htmlgraph/repositories/track_repository_htmlfile.py +619 -0
- htmlgraph/repositories/track_repository_memory.py +508 -0
- htmlgraph/repositories/track_repository_sqlite.py +711 -0
- htmlgraph/sdk/__init__.py +398 -0
- htmlgraph/sdk/__init__.pyi +14 -0
- htmlgraph/sdk/analytics/__init__.py +19 -0
- htmlgraph/sdk/analytics/engine.py +155 -0
- htmlgraph/sdk/analytics/helpers.py +178 -0
- htmlgraph/sdk/analytics/registry.py +109 -0
- htmlgraph/sdk/base.py +484 -0
- htmlgraph/sdk/constants.py +216 -0
- htmlgraph/sdk/core.pyi +308 -0
- htmlgraph/sdk/discovery.py +120 -0
- htmlgraph/sdk/help/__init__.py +12 -0
- htmlgraph/sdk/help/mixin.py +699 -0
- htmlgraph/sdk/mixins/__init__.py +15 -0
- htmlgraph/sdk/mixins/attribution.py +113 -0
- htmlgraph/sdk/mixins/mixin.py +410 -0
- htmlgraph/sdk/operations/__init__.py +12 -0
- htmlgraph/sdk/operations/mixin.py +427 -0
- htmlgraph/sdk/orchestration/__init__.py +17 -0
- htmlgraph/sdk/orchestration/coordinator.py +203 -0
- htmlgraph/sdk/orchestration/spawner.py +204 -0
- htmlgraph/sdk/planning/__init__.py +19 -0
- htmlgraph/sdk/planning/bottlenecks.py +93 -0
- htmlgraph/sdk/planning/mixin.py +211 -0
- htmlgraph/sdk/planning/parallel.py +186 -0
- htmlgraph/sdk/planning/queue.py +210 -0
- htmlgraph/sdk/planning/recommendations.py +87 -0
- htmlgraph/sdk/planning/smart_planning.py +319 -0
- htmlgraph/sdk/session/__init__.py +19 -0
- htmlgraph/sdk/session/continuity.py +57 -0
- htmlgraph/sdk/session/handoff.py +110 -0
- htmlgraph/sdk/session/info.py +309 -0
- htmlgraph/sdk/session/manager.py +103 -0
- htmlgraph/sdk/strategic/__init__.py +26 -0
- htmlgraph/sdk/strategic/mixin.py +563 -0
- htmlgraph/server.py +295 -107
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +285 -3
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/session_warning.py +2 -1
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +756 -0
- htmlgraph/system_prompts.py +450 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +33 -1
- htmlgraph/track_manager.py +38 -0
- htmlgraph/transcript.py +18 -5
- htmlgraph/validation.py +115 -0
- htmlgraph/watch.py +2 -1
- htmlgraph/work_type_utils.py +2 -1
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
- htmlgraph-0.27.5.dist-info/RECORD +337 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -4839
- htmlgraph/sdk.py +0 -2359
- htmlgraph-0.20.1.dist-info/RECORD +0 -118
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,1424 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""HtmlGraph CLI - Analytics and reporting commands.
|
|
4
|
+
|
|
5
|
+
Commands for analytics and reporting:
|
|
6
|
+
- analytics: Project-wide analytics
|
|
7
|
+
- cigs: Cost dashboard and attribution
|
|
8
|
+
- transcripts: Transcript management
|
|
9
|
+
- sync-docs: Documentation synchronization
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import webbrowser
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
from rich import box
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
|
|
26
|
+
from htmlgraph.cli.base import BaseCommand, CommandError, CommandResult
|
|
27
|
+
from htmlgraph.cli.constants import DEFAULT_GRAPH_DIR
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from argparse import _SubParsersAction
|
|
31
|
+
|
|
32
|
+
console = Console()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ============================================================================
|
|
36
|
+
# Command Registration
|
|
37
|
+
# ============================================================================
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def register_commands(subparsers: _SubParsersAction) -> None:
|
|
41
|
+
"""Register analytics and reporting commands with the argument parser.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
subparsers: Subparser action from ArgumentParser.add_subparsers()
|
|
45
|
+
"""
|
|
46
|
+
# Analytics command
|
|
47
|
+
analytics_parser = subparsers.add_parser(
|
|
48
|
+
"analytics", help="Project-wide analytics and insights"
|
|
49
|
+
)
|
|
50
|
+
analytics_parser.add_argument(
|
|
51
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
52
|
+
)
|
|
53
|
+
analytics_parser.add_argument("--session-id", help="Analyze specific session")
|
|
54
|
+
analytics_parser.add_argument(
|
|
55
|
+
"--recent", type=int, metavar="N", help="Analyze recent N sessions"
|
|
56
|
+
)
|
|
57
|
+
analytics_parser.add_argument(
|
|
58
|
+
"--agent", default="cli", help="Agent name for SDK initialization"
|
|
59
|
+
)
|
|
60
|
+
analytics_parser.add_argument(
|
|
61
|
+
"--quiet", "-q", action="store_true", help="Suppress progress indicators"
|
|
62
|
+
)
|
|
63
|
+
analytics_parser.set_defaults(func=AnalyticsCommand.from_args)
|
|
64
|
+
|
|
65
|
+
# CIGS commands
|
|
66
|
+
_register_cigs_commands(subparsers)
|
|
67
|
+
|
|
68
|
+
# Transcript commands
|
|
69
|
+
_register_transcript_commands(subparsers)
|
|
70
|
+
|
|
71
|
+
# Sync docs command
|
|
72
|
+
_register_sync_docs_command(subparsers)
|
|
73
|
+
|
|
74
|
+
# Costs command
|
|
75
|
+
_register_costs_command(subparsers)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _register_cigs_commands(subparsers: _SubParsersAction) -> None:
|
|
79
|
+
"""Register CIGS (Cost Intelligence & Governance System) commands."""
|
|
80
|
+
cigs_parser = subparsers.add_parser("cigs", help="Cost dashboard and attribution")
|
|
81
|
+
cigs_subparsers = cigs_parser.add_subparsers(
|
|
82
|
+
dest="cigs_command", help="CIGS command"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# cigs cost-dashboard
|
|
86
|
+
cost_dashboard = cigs_subparsers.add_parser(
|
|
87
|
+
"cost-dashboard", help="Display cost summary dashboard"
|
|
88
|
+
)
|
|
89
|
+
cost_dashboard.add_argument(
|
|
90
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
91
|
+
)
|
|
92
|
+
cost_dashboard.add_argument(
|
|
93
|
+
"--save", action="store_true", help="Save to .htmlgraph/cost-dashboard.html"
|
|
94
|
+
)
|
|
95
|
+
cost_dashboard.add_argument(
|
|
96
|
+
"--open", action="store_true", help="Open in browser after generation"
|
|
97
|
+
)
|
|
98
|
+
cost_dashboard.add_argument(
|
|
99
|
+
"--json", action="store_true", help="Output JSON instead of HTML"
|
|
100
|
+
)
|
|
101
|
+
cost_dashboard.add_argument("--output", help="Custom output path")
|
|
102
|
+
cost_dashboard.set_defaults(func=CostDashboardCommand.from_args)
|
|
103
|
+
|
|
104
|
+
# cigs roi-analysis (Phase 1 OTEL ROI)
|
|
105
|
+
roi_analysis = cigs_subparsers.add_parser(
|
|
106
|
+
"roi-analysis", help="OTEL ROI analysis - cost attribution of Task delegations"
|
|
107
|
+
)
|
|
108
|
+
roi_analysis.add_argument(
|
|
109
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
110
|
+
)
|
|
111
|
+
roi_analysis.add_argument(
|
|
112
|
+
"--save", action="store_true", help="Save to .htmlgraph/cost-analysis.html"
|
|
113
|
+
)
|
|
114
|
+
roi_analysis.add_argument(
|
|
115
|
+
"--open", action="store_true", help="Open in browser after generation"
|
|
116
|
+
)
|
|
117
|
+
roi_analysis.add_argument(
|
|
118
|
+
"--json", action="store_true", help="Output JSON instead of HTML"
|
|
119
|
+
)
|
|
120
|
+
roi_analysis.add_argument("--output", help="Custom output path")
|
|
121
|
+
# roi_analysis.set_defaults(func=OTELROIAnalysisCommand.from_args) # TODO: Implement OTELROIAnalysisCommand
|
|
122
|
+
|
|
123
|
+
# cigs status
|
|
124
|
+
cigs_status = cigs_subparsers.add_parser("status", help="Show CIGS status")
|
|
125
|
+
cigs_status.add_argument(
|
|
126
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
127
|
+
)
|
|
128
|
+
cigs_status.set_defaults(func=CigsStatusCommand.from_args)
|
|
129
|
+
|
|
130
|
+
# cigs summary
|
|
131
|
+
cigs_summary = cigs_subparsers.add_parser("summary", help="Show cost summary")
|
|
132
|
+
cigs_summary.add_argument(
|
|
133
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
134
|
+
)
|
|
135
|
+
cigs_summary.add_argument("--session-id", help="Specific session ID")
|
|
136
|
+
cigs_summary.set_defaults(func=CigsSummaryCommand.from_args)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _register_transcript_commands(subparsers: _SubParsersAction) -> None:
|
|
140
|
+
"""Register transcript management commands."""
|
|
141
|
+
transcript_parser = subparsers.add_parser(
|
|
142
|
+
"transcript", help="Transcript management"
|
|
143
|
+
)
|
|
144
|
+
transcript_subparsers = transcript_parser.add_subparsers(
|
|
145
|
+
dest="transcript_command", help="Transcript command"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# transcript list
|
|
149
|
+
transcript_list = transcript_subparsers.add_parser("list", help="List transcripts")
|
|
150
|
+
transcript_list.add_argument(
|
|
151
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
152
|
+
)
|
|
153
|
+
transcript_list.add_argument("--format", choices=["text", "json"], default="text")
|
|
154
|
+
transcript_list.add_argument("--limit", type=int, default=20)
|
|
155
|
+
transcript_list.add_argument("--project", help="Filter by project path")
|
|
156
|
+
transcript_list.set_defaults(func=TranscriptListCommand.from_args)
|
|
157
|
+
|
|
158
|
+
# transcript import
|
|
159
|
+
transcript_import = transcript_subparsers.add_parser(
|
|
160
|
+
"import", help="Import transcript"
|
|
161
|
+
)
|
|
162
|
+
transcript_import.add_argument("session_id", help="Transcript session ID to import")
|
|
163
|
+
transcript_import.add_argument(
|
|
164
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
165
|
+
)
|
|
166
|
+
transcript_import.add_argument("--to-session", help="Target HtmlGraph session ID")
|
|
167
|
+
transcript_import.add_argument("--agent", default="claude-code", help="Agent name")
|
|
168
|
+
transcript_import.add_argument(
|
|
169
|
+
"--overwrite", action="store_true", help="Overwrite existing events"
|
|
170
|
+
)
|
|
171
|
+
transcript_import.add_argument("--link-feature", help="Link to feature ID")
|
|
172
|
+
transcript_import.add_argument("--format", choices=["text", "json"], default="text")
|
|
173
|
+
transcript_import.set_defaults(func=TranscriptImportCommand.from_args)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _register_sync_docs_command(subparsers: _SubParsersAction) -> None:
|
|
177
|
+
"""Register documentation synchronization command."""
|
|
178
|
+
sync_docs = subparsers.add_parser(
|
|
179
|
+
"sync-docs", help="Synchronize AI agent memory files"
|
|
180
|
+
)
|
|
181
|
+
sync_docs.add_argument(
|
|
182
|
+
"--project-root", help="Project root directory (default: current directory)"
|
|
183
|
+
)
|
|
184
|
+
sync_docs.add_argument(
|
|
185
|
+
"--check", action="store_true", help="Check synchronization status"
|
|
186
|
+
)
|
|
187
|
+
sync_docs.add_argument(
|
|
188
|
+
"--generate",
|
|
189
|
+
choices=["claude", "gemini"],
|
|
190
|
+
help="Generate specific platform file",
|
|
191
|
+
)
|
|
192
|
+
sync_docs.add_argument(
|
|
193
|
+
"--force", action="store_true", help="Force overwrite existing files"
|
|
194
|
+
)
|
|
195
|
+
sync_docs.set_defaults(func=SyncDocsCommand.from_args)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _register_costs_command(subparsers: _SubParsersAction) -> None:
|
|
199
|
+
"""Register cost visibility and analysis command."""
|
|
200
|
+
costs_parser = subparsers.add_parser(
|
|
201
|
+
"costs",
|
|
202
|
+
help="View token cost breakdown and analytics",
|
|
203
|
+
)
|
|
204
|
+
costs_parser.add_argument(
|
|
205
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
206
|
+
)
|
|
207
|
+
costs_parser.add_argument(
|
|
208
|
+
"--period",
|
|
209
|
+
choices=["today", "day", "week", "month", "all"],
|
|
210
|
+
default="week",
|
|
211
|
+
help="Time period to analyze (default: week)",
|
|
212
|
+
)
|
|
213
|
+
costs_parser.add_argument(
|
|
214
|
+
"--by",
|
|
215
|
+
choices=["session", "feature", "tool", "agent"],
|
|
216
|
+
default="session",
|
|
217
|
+
help="Group costs by (default: session)",
|
|
218
|
+
)
|
|
219
|
+
costs_parser.add_argument(
|
|
220
|
+
"--format",
|
|
221
|
+
choices=["terminal", "csv"],
|
|
222
|
+
default="terminal",
|
|
223
|
+
help="Output format (default: terminal)",
|
|
224
|
+
)
|
|
225
|
+
costs_parser.add_argument(
|
|
226
|
+
"--model",
|
|
227
|
+
choices=["opus", "sonnet", "haiku", "auto"],
|
|
228
|
+
default="auto",
|
|
229
|
+
help="Claude model to assume for pricing (default: auto-detect)",
|
|
230
|
+
)
|
|
231
|
+
costs_parser.add_argument(
|
|
232
|
+
"--limit",
|
|
233
|
+
type=int,
|
|
234
|
+
default=10,
|
|
235
|
+
help="Maximum number of rows to display (default: 10)",
|
|
236
|
+
)
|
|
237
|
+
costs_parser.set_defaults(func=CostsCommand.from_args)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ============================================================================
|
|
241
|
+
# Pydantic Models for Cost Analytics
|
|
242
|
+
# ============================================================================
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class ToolCostData(BaseModel):
|
|
246
|
+
"""Cost data for a specific tool."""
|
|
247
|
+
|
|
248
|
+
count: int = Field(ge=0)
|
|
249
|
+
total_tokens: int = Field(ge=0)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class CategoryCostData(BaseModel):
|
|
253
|
+
"""Cost data for a category (delegation/direct)."""
|
|
254
|
+
|
|
255
|
+
count: int = Field(ge=0)
|
|
256
|
+
total_tokens: int = Field(ge=0)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class CostSummary(BaseModel):
|
|
260
|
+
"""Complete cost analysis summary."""
|
|
261
|
+
|
|
262
|
+
total_cost_tokens: int = Field(ge=0)
|
|
263
|
+
total_events: int = Field(ge=0)
|
|
264
|
+
tool_costs: dict[str, ToolCostData] = Field(default_factory=dict)
|
|
265
|
+
session_costs: dict[str, ToolCostData] = Field(default_factory=dict)
|
|
266
|
+
delegation_count: int = Field(ge=0)
|
|
267
|
+
direct_execution_count: int = Field(ge=0)
|
|
268
|
+
cost_by_category: dict[str, CategoryCostData] = Field(default_factory=dict)
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def avg_cost_per_event(self) -> float:
|
|
272
|
+
"""Average token cost per event."""
|
|
273
|
+
return (
|
|
274
|
+
self.total_cost_tokens / self.total_events if self.total_events > 0 else 0
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def delegation_percentage(self) -> float:
|
|
279
|
+
"""Percentage of events that were delegated."""
|
|
280
|
+
return (
|
|
281
|
+
self.delegation_count / self.total_events * 100
|
|
282
|
+
if self.total_events > 0
|
|
283
|
+
else 0
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def estimated_cost_usd(self) -> float:
|
|
288
|
+
"""Estimated cost in USD (rough approximation)."""
|
|
289
|
+
return self.total_cost_tokens / 1_000_000 * 5
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ============================================================================
|
|
293
|
+
# Command Implementations
|
|
294
|
+
# ============================================================================
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class AnalyticsCommand(BaseCommand):
|
|
298
|
+
"""Project-wide analytics and insights."""
|
|
299
|
+
|
|
300
|
+
def __init__(
|
|
301
|
+
self, *, session_id: str | None, recent: int | None, agent: str, quiet: bool
|
|
302
|
+
) -> None:
|
|
303
|
+
super().__init__()
|
|
304
|
+
self.session_id = session_id
|
|
305
|
+
self.recent = recent
|
|
306
|
+
self.agent = agent
|
|
307
|
+
self.quiet = quiet
|
|
308
|
+
|
|
309
|
+
@classmethod
|
|
310
|
+
def from_args(cls, args: argparse.Namespace) -> AnalyticsCommand:
|
|
311
|
+
return cls(
|
|
312
|
+
session_id=getattr(args, "session_id", None),
|
|
313
|
+
recent=getattr(args, "recent", None),
|
|
314
|
+
agent=getattr(args, "agent", "cli"),
|
|
315
|
+
quiet=getattr(args, "quiet", False),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def execute(self) -> CommandResult:
|
|
319
|
+
"""Execute analytics analysis using analytics/cli.py implementation."""
|
|
320
|
+
from htmlgraph.analytics.cli import cmd_analytics
|
|
321
|
+
|
|
322
|
+
args = argparse.Namespace(
|
|
323
|
+
graph_dir=self.graph_dir,
|
|
324
|
+
session_id=self.session_id,
|
|
325
|
+
recent=self.recent,
|
|
326
|
+
agent=self.agent,
|
|
327
|
+
quiet=self.quiet,
|
|
328
|
+
)
|
|
329
|
+
exit_code = cmd_analytics(args)
|
|
330
|
+
if exit_code != 0:
|
|
331
|
+
raise CommandError("Analytics command failed", exit_code=exit_code)
|
|
332
|
+
return CommandResult(text="Analytics complete")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class CostDashboardCommand(BaseCommand):
|
|
336
|
+
"""Display cost summary dashboard."""
|
|
337
|
+
|
|
338
|
+
def __init__(
|
|
339
|
+
self,
|
|
340
|
+
*,
|
|
341
|
+
save: bool,
|
|
342
|
+
open_browser: bool,
|
|
343
|
+
json_output: bool,
|
|
344
|
+
output_path: str | None,
|
|
345
|
+
) -> None:
|
|
346
|
+
super().__init__()
|
|
347
|
+
self.save = save
|
|
348
|
+
self.open_browser = open_browser
|
|
349
|
+
self.json_output = json_output
|
|
350
|
+
self.output_path = output_path
|
|
351
|
+
|
|
352
|
+
@classmethod
|
|
353
|
+
def from_args(cls, args: argparse.Namespace) -> CostDashboardCommand:
|
|
354
|
+
return cls(
|
|
355
|
+
save=args.save,
|
|
356
|
+
open_browser=getattr(args, "open", False),
|
|
357
|
+
json_output=getattr(args, "json", False),
|
|
358
|
+
output_path=getattr(args, "output", None),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def execute(self) -> CommandResult:
|
|
362
|
+
"""Generate and display cost dashboard."""
|
|
363
|
+
if not self.graph_dir:
|
|
364
|
+
raise CommandError("Graph directory not specified")
|
|
365
|
+
graph_dir = Path(self.graph_dir)
|
|
366
|
+
|
|
367
|
+
# Get events from database
|
|
368
|
+
with console.status(
|
|
369
|
+
"[blue]Analyzing HtmlGraph events...[/blue]", spinner="dots"
|
|
370
|
+
):
|
|
371
|
+
try:
|
|
372
|
+
from htmlgraph.operations.events import query_events
|
|
373
|
+
|
|
374
|
+
result = query_events(graph_dir=graph_dir, limit=None)
|
|
375
|
+
events = result.events if hasattr(result, "events") else []
|
|
376
|
+
|
|
377
|
+
if not events:
|
|
378
|
+
console.print(
|
|
379
|
+
"[yellow]No events found. Run some work to generate analytics![/yellow]"
|
|
380
|
+
)
|
|
381
|
+
return CommandResult(text="No events to analyze")
|
|
382
|
+
|
|
383
|
+
# Calculate costs
|
|
384
|
+
cost_summary = self._analyze_event_costs(events)
|
|
385
|
+
|
|
386
|
+
except Exception as e:
|
|
387
|
+
console.print(f"[red]Error analyzing events: {e}[/red]")
|
|
388
|
+
raise CommandError(f"Failed to analyze events: {e}")
|
|
389
|
+
|
|
390
|
+
# Generate output
|
|
391
|
+
if self.json_output:
|
|
392
|
+
self._output_json(cost_summary)
|
|
393
|
+
else:
|
|
394
|
+
if self.save or self.output_path:
|
|
395
|
+
html_file = self._save_html_dashboard(cost_summary, graph_dir)
|
|
396
|
+
if self.open_browser:
|
|
397
|
+
webbrowser.open(f"file://{html_file.absolute()}")
|
|
398
|
+
console.print("[blue]Opening dashboard in browser...[/blue]")
|
|
399
|
+
else:
|
|
400
|
+
self._display_console_summary(cost_summary)
|
|
401
|
+
|
|
402
|
+
# Print recommendations
|
|
403
|
+
self._print_recommendations(cost_summary)
|
|
404
|
+
|
|
405
|
+
return CommandResult(text="Cost dashboard generated")
|
|
406
|
+
|
|
407
|
+
def _analyze_event_costs(self, events: list[dict]) -> CostSummary:
|
|
408
|
+
"""Analyze events and calculate cost attribution."""
|
|
409
|
+
summary = CostSummary(
|
|
410
|
+
total_events=len(events),
|
|
411
|
+
total_cost_tokens=0,
|
|
412
|
+
delegation_count=0,
|
|
413
|
+
direct_execution_count=0,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
for event in events:
|
|
417
|
+
try:
|
|
418
|
+
tool = event.get("tool", "unknown")
|
|
419
|
+
session_id = event.get("session_id", "unknown")
|
|
420
|
+
cost = (
|
|
421
|
+
event.get("predicted_tokens", 0)
|
|
422
|
+
or event.get("actual_tokens", 0)
|
|
423
|
+
or 2000
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# Track by tool
|
|
427
|
+
if tool not in summary.tool_costs:
|
|
428
|
+
summary.tool_costs[tool] = ToolCostData(count=0, total_tokens=0)
|
|
429
|
+
summary.tool_costs[tool].count += 1
|
|
430
|
+
summary.tool_costs[tool].total_tokens += cost
|
|
431
|
+
|
|
432
|
+
# Track by session
|
|
433
|
+
if session_id not in summary.session_costs:
|
|
434
|
+
summary.session_costs[session_id] = ToolCostData(
|
|
435
|
+
count=0, total_tokens=0
|
|
436
|
+
)
|
|
437
|
+
summary.session_costs[session_id].count += 1
|
|
438
|
+
summary.session_costs[session_id].total_tokens += cost
|
|
439
|
+
|
|
440
|
+
# Track delegation vs direct
|
|
441
|
+
delegation_tools = [
|
|
442
|
+
"Task",
|
|
443
|
+
"spawn_gemini",
|
|
444
|
+
"spawn_codex",
|
|
445
|
+
"spawn_copilot",
|
|
446
|
+
]
|
|
447
|
+
if tool in delegation_tools:
|
|
448
|
+
summary.delegation_count += 1
|
|
449
|
+
category = "delegation"
|
|
450
|
+
else:
|
|
451
|
+
summary.direct_execution_count += 1
|
|
452
|
+
category = "direct"
|
|
453
|
+
|
|
454
|
+
if category not in summary.cost_by_category:
|
|
455
|
+
summary.cost_by_category[category] = CategoryCostData(
|
|
456
|
+
count=0, total_tokens=0
|
|
457
|
+
)
|
|
458
|
+
summary.cost_by_category[category].count += 1
|
|
459
|
+
summary.cost_by_category[category].total_tokens += cost
|
|
460
|
+
|
|
461
|
+
summary.total_cost_tokens += cost
|
|
462
|
+
|
|
463
|
+
except Exception:
|
|
464
|
+
continue
|
|
465
|
+
|
|
466
|
+
return summary
|
|
467
|
+
|
|
468
|
+
def _output_json(self, summary: CostSummary) -> None:
|
|
469
|
+
"""Output cost data as JSON."""
|
|
470
|
+
output_file = (
|
|
471
|
+
Path(self.output_path) if self.output_path else Path("cost-summary.json")
|
|
472
|
+
)
|
|
473
|
+
output_file.write_text(summary.model_dump_json(indent=2))
|
|
474
|
+
console.print(f"[green]✓ JSON output saved to: {output_file}[/green]")
|
|
475
|
+
|
|
476
|
+
def _save_html_dashboard(self, summary: CostSummary, graph_dir: Path) -> Path:
|
|
477
|
+
"""Save HTML dashboard to file."""
|
|
478
|
+
from htmlgraph.cli.templates.cost_dashboard import generate_html
|
|
479
|
+
|
|
480
|
+
html_content = generate_html(summary)
|
|
481
|
+
output_file = (
|
|
482
|
+
Path(self.output_path)
|
|
483
|
+
if self.output_path
|
|
484
|
+
else graph_dir / "cost-dashboard.html"
|
|
485
|
+
)
|
|
486
|
+
output_file.write_text(html_content)
|
|
487
|
+
console.print(f"[green]✓ Dashboard saved to: {output_file}[/green]")
|
|
488
|
+
return output_file
|
|
489
|
+
|
|
490
|
+
def _display_console_summary(self, summary: CostSummary) -> None:
|
|
491
|
+
"""Display cost summary in console."""
|
|
492
|
+
from htmlgraph.cli.base import TableBuilder
|
|
493
|
+
|
|
494
|
+
console.print("\n[bold cyan]Cost Dashboard Summary[/bold cyan]\n")
|
|
495
|
+
|
|
496
|
+
# Summary table
|
|
497
|
+
summary_builder = TableBuilder.create_list_table(title=None)
|
|
498
|
+
summary_builder.add_column("Metric", style="cyan")
|
|
499
|
+
summary_builder.add_column("Value", style="green")
|
|
500
|
+
|
|
501
|
+
summary_builder.add_row("Total Events", str(summary.total_events))
|
|
502
|
+
summary_builder.add_row("Total Cost", f"{summary.total_cost_tokens:,} tokens")
|
|
503
|
+
summary_builder.add_row(
|
|
504
|
+
"Average Cost", f"{summary.avg_cost_per_event:,.0f} tokens/event"
|
|
505
|
+
)
|
|
506
|
+
summary_builder.add_row("Estimated USD", f"${summary.estimated_cost_usd:.2f}")
|
|
507
|
+
summary_builder.add_row("Delegation Count", str(summary.delegation_count))
|
|
508
|
+
summary_builder.add_row(
|
|
509
|
+
"Delegation Rate", f"{summary.delegation_percentage:.1f}%"
|
|
510
|
+
)
|
|
511
|
+
summary_builder.add_row(
|
|
512
|
+
"Direct Executions", str(summary.direct_execution_count)
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
console.print(summary_builder.table)
|
|
516
|
+
|
|
517
|
+
# Top tools table
|
|
518
|
+
if summary.tool_costs:
|
|
519
|
+
console.print("\n[bold cyan]Top Cost Drivers (by Tool)[/bold cyan]\n")
|
|
520
|
+
tools_builder = TableBuilder.create_list_table(title=None)
|
|
521
|
+
tools_builder.add_column("Tool", style="cyan")
|
|
522
|
+
tools_builder.add_numeric_column("Count", style="green")
|
|
523
|
+
tools_builder.add_numeric_column("Tokens", style="yellow")
|
|
524
|
+
tools_builder.add_numeric_column("% Total", style="magenta")
|
|
525
|
+
|
|
526
|
+
sorted_tools = sorted(
|
|
527
|
+
summary.tool_costs.items(),
|
|
528
|
+
key=lambda x: x[1].total_tokens,
|
|
529
|
+
reverse=True,
|
|
530
|
+
)
|
|
531
|
+
for tool, data in sorted_tools[:10]:
|
|
532
|
+
pct = data.total_tokens / summary.total_cost_tokens * 100
|
|
533
|
+
tools_builder.add_row(
|
|
534
|
+
tool, str(data.count), f"{data.total_tokens:,}", f"{pct:.1f}%"
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
console.print(tools_builder.table)
|
|
538
|
+
|
|
539
|
+
def _print_recommendations(self, summary: CostSummary) -> None:
|
|
540
|
+
"""Print cost optimization recommendations."""
|
|
541
|
+
console.print("\n[bold cyan]Recommendations[/bold cyan]\n")
|
|
542
|
+
|
|
543
|
+
recommendations = []
|
|
544
|
+
|
|
545
|
+
if summary.delegation_percentage < 50:
|
|
546
|
+
recommendations.append(
|
|
547
|
+
"[yellow]→ Increase delegation usage[/yellow] - Consider using Task() and spawn_* for more operations"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
if summary.tool_costs:
|
|
551
|
+
top_tool, top_data = max(
|
|
552
|
+
summary.tool_costs.items(), key=lambda x: x[1].total_tokens
|
|
553
|
+
)
|
|
554
|
+
top_pct = top_data.total_tokens / summary.total_cost_tokens * 100
|
|
555
|
+
if top_pct > 40:
|
|
556
|
+
recommendations.append(
|
|
557
|
+
f"[yellow]→ Review {top_tool} usage[/yellow] - It accounts for {top_pct:.1f}% of total cost"
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
if summary.total_events > 100:
|
|
561
|
+
recommendations.append(
|
|
562
|
+
"[green]✓ Good event volume[/green] - Sufficient data for optimization analysis"
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
recommendations.append(
|
|
566
|
+
"[blue]💡 Tip: Use parallel Task() calls to reduce execution time by ~40%[/blue]"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
for rec in recommendations:
|
|
570
|
+
console.print(f" {rec}")
|
|
571
|
+
|
|
572
|
+
console.print()
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
class CigsStatusCommand(BaseCommand):
|
|
576
|
+
"""Show CIGS status."""
|
|
577
|
+
|
|
578
|
+
@classmethod
|
|
579
|
+
def from_args(cls, args: argparse.Namespace) -> CigsStatusCommand:
|
|
580
|
+
return cls()
|
|
581
|
+
|
|
582
|
+
def execute(self) -> CommandResult:
|
|
583
|
+
"""Show CIGS status."""
|
|
584
|
+
from htmlgraph.cigs.autonomy import AutonomyRecommender
|
|
585
|
+
from htmlgraph.cigs.pattern_storage import PatternStorage
|
|
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
|
+
|
|
592
|
+
# Get violation tracker
|
|
593
|
+
tracker = ViolationTracker(graph_dir)
|
|
594
|
+
summary = tracker.get_session_violations()
|
|
595
|
+
|
|
596
|
+
# Get pattern storage
|
|
597
|
+
pattern_storage = PatternStorage(graph_dir)
|
|
598
|
+
patterns = pattern_storage.get_anti_patterns()
|
|
599
|
+
|
|
600
|
+
# Get autonomy recommendation
|
|
601
|
+
recommender = AutonomyRecommender()
|
|
602
|
+
autonomy = recommender.recommend(summary, patterns)
|
|
603
|
+
|
|
604
|
+
# Display with Rich
|
|
605
|
+
status_table = Table(title="CIGS Status", box=box.ROUNDED)
|
|
606
|
+
status_table.add_column("Metric", style="cyan")
|
|
607
|
+
status_table.add_column("Value", style="green")
|
|
608
|
+
|
|
609
|
+
status_table.add_row("Session", summary.session_id)
|
|
610
|
+
status_table.add_row("Violations", f"{summary.total_violations}/3")
|
|
611
|
+
status_table.add_row("Compliance Rate", f"{summary.compliance_rate:.1%}")
|
|
612
|
+
status_table.add_row("Total Waste", f"{summary.total_waste_tokens} tokens")
|
|
613
|
+
status_table.add_row(
|
|
614
|
+
"Circuit Breaker",
|
|
615
|
+
"🚨 TRIGGERED" if summary.circuit_breaker_triggered else "Not triggered",
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
console.print(status_table)
|
|
619
|
+
|
|
620
|
+
if summary.violations_by_type:
|
|
621
|
+
console.print("\n[bold]Violation Breakdown:[/bold]")
|
|
622
|
+
for vtype, count in summary.violations_by_type.items():
|
|
623
|
+
console.print(f" • {vtype}: {count}")
|
|
624
|
+
|
|
625
|
+
console.print(f"\n[bold]Autonomy Level:[/bold] {autonomy.level.upper()}")
|
|
626
|
+
console.print(
|
|
627
|
+
f"[bold]Messaging Intensity:[/bold] {autonomy.messaging_intensity}"
|
|
628
|
+
)
|
|
629
|
+
console.print(f"[bold]Enforcement Mode:[/bold] {autonomy.enforcement_mode}")
|
|
630
|
+
|
|
631
|
+
if patterns:
|
|
632
|
+
console.print(f"\n[bold]Anti-Patterns Detected:[/bold] {len(patterns)}")
|
|
633
|
+
for pattern in patterns[:3]:
|
|
634
|
+
console.print(f" • {pattern.name} ({pattern.occurrence_count}x)")
|
|
635
|
+
|
|
636
|
+
return CommandResult(text="CIGS status displayed")
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
class CigsSummaryCommand(BaseCommand):
|
|
640
|
+
"""Show cost summary."""
|
|
641
|
+
|
|
642
|
+
def __init__(self, *, session_id: str | None) -> None:
|
|
643
|
+
super().__init__()
|
|
644
|
+
self.session_id = session_id
|
|
645
|
+
|
|
646
|
+
@classmethod
|
|
647
|
+
def from_args(cls, args: argparse.Namespace) -> CigsSummaryCommand:
|
|
648
|
+
return cls(session_id=getattr(args, "session_id", None))
|
|
649
|
+
|
|
650
|
+
def execute(self) -> CommandResult:
|
|
651
|
+
"""Show cost summary."""
|
|
652
|
+
from htmlgraph.cigs.tracker import ViolationTracker
|
|
653
|
+
|
|
654
|
+
if not self.graph_dir:
|
|
655
|
+
raise CommandError("Graph directory not specified")
|
|
656
|
+
graph_dir = Path(self.graph_dir)
|
|
657
|
+
tracker = ViolationTracker(graph_dir)
|
|
658
|
+
|
|
659
|
+
# Get session ID
|
|
660
|
+
session_id = self.session_id or tracker._session_id
|
|
661
|
+
|
|
662
|
+
if not session_id:
|
|
663
|
+
console.print(
|
|
664
|
+
"[yellow]⚠️ No active session. Specify --session-id to view past sessions.[/yellow]"
|
|
665
|
+
)
|
|
666
|
+
return CommandResult(text="No active session")
|
|
667
|
+
|
|
668
|
+
summary = tracker.get_session_violations(session_id)
|
|
669
|
+
|
|
670
|
+
# Display summary
|
|
671
|
+
panel = Panel(
|
|
672
|
+
f"[cyan]Session ID:[/cyan] {summary.session_id}\n"
|
|
673
|
+
f"[cyan]Total Violations:[/cyan] {summary.total_violations}\n"
|
|
674
|
+
f"[cyan]Compliance Rate:[/cyan] {summary.compliance_rate:.1%}\n"
|
|
675
|
+
f"[cyan]Total Waste:[/cyan] {summary.total_waste_tokens} tokens\n"
|
|
676
|
+
f"[cyan]Circuit Breaker:[/cyan] {'🚨 TRIGGERED' if summary.circuit_breaker_triggered else 'Not triggered'}",
|
|
677
|
+
title="CIGS Session Summary",
|
|
678
|
+
border_style="cyan",
|
|
679
|
+
)
|
|
680
|
+
console.print(panel)
|
|
681
|
+
|
|
682
|
+
if summary.violations_by_type:
|
|
683
|
+
console.print("\n[bold]Violation Breakdown:[/bold]")
|
|
684
|
+
for vtype, count in summary.violations_by_type.items():
|
|
685
|
+
console.print(f" • {vtype}: {count}")
|
|
686
|
+
|
|
687
|
+
if summary.violations:
|
|
688
|
+
console.print(
|
|
689
|
+
f"\n[bold]Recent Violations ({len(summary.violations)}):[/bold]"
|
|
690
|
+
)
|
|
691
|
+
for v in summary.violations[-5:]:
|
|
692
|
+
console.print(
|
|
693
|
+
f" • {v.tool} - {v.violation_type} - {v.waste_tokens} tokens wasted"
|
|
694
|
+
)
|
|
695
|
+
console.print(f" Should have: {v.should_have_delegated_to}")
|
|
696
|
+
|
|
697
|
+
return CommandResult(text="Cost summary displayed")
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class TranscriptListCommand(BaseCommand):
|
|
701
|
+
"""List transcripts."""
|
|
702
|
+
|
|
703
|
+
def __init__(self, *, format: str, limit: int, project: str | None) -> None:
|
|
704
|
+
super().__init__()
|
|
705
|
+
self.format = format
|
|
706
|
+
self.limit = limit
|
|
707
|
+
self.project = project
|
|
708
|
+
|
|
709
|
+
@classmethod
|
|
710
|
+
def from_args(cls, args: argparse.Namespace) -> TranscriptListCommand:
|
|
711
|
+
return cls(
|
|
712
|
+
format=getattr(args, "format", "text"),
|
|
713
|
+
limit=getattr(args, "limit", 20),
|
|
714
|
+
project=getattr(args, "project", None),
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
def execute(self) -> CommandResult:
|
|
718
|
+
"""List all transcripts."""
|
|
719
|
+
from htmlgraph.transcript import TranscriptReader
|
|
720
|
+
|
|
721
|
+
reader = TranscriptReader()
|
|
722
|
+
sessions = reader.list_sessions(project_path=self.project, limit=self.limit)
|
|
723
|
+
|
|
724
|
+
if not sessions:
|
|
725
|
+
if self.format == "json":
|
|
726
|
+
console.print_json(json.dumps({"sessions": [], "count": 0}))
|
|
727
|
+
else:
|
|
728
|
+
console.print("[yellow]No Claude Code transcripts found.[/yellow]")
|
|
729
|
+
console.print(f"[dim]Looked in: {reader.claude_dir}[/dim]")
|
|
730
|
+
return CommandResult(text="No transcripts found")
|
|
731
|
+
|
|
732
|
+
if self.format == "json":
|
|
733
|
+
data = {
|
|
734
|
+
"sessions": [
|
|
735
|
+
{
|
|
736
|
+
"session_id": s.session_id,
|
|
737
|
+
"path": str(s.path),
|
|
738
|
+
"cwd": s.cwd,
|
|
739
|
+
"git_branch": s.git_branch,
|
|
740
|
+
"started_at": s.started_at.isoformat()
|
|
741
|
+
if s.started_at
|
|
742
|
+
else None,
|
|
743
|
+
"user_messages": s.user_message_count,
|
|
744
|
+
"tool_calls": s.tool_call_count,
|
|
745
|
+
"duration_seconds": s.duration_seconds,
|
|
746
|
+
}
|
|
747
|
+
for s in sessions
|
|
748
|
+
],
|
|
749
|
+
"count": len(sessions),
|
|
750
|
+
}
|
|
751
|
+
console.print_json(json.dumps(data))
|
|
752
|
+
else:
|
|
753
|
+
# Display with Rich table
|
|
754
|
+
table = Table(
|
|
755
|
+
title=f"Claude Code Transcripts ({len(sessions)} found)",
|
|
756
|
+
box=box.ROUNDED,
|
|
757
|
+
)
|
|
758
|
+
table.add_column("Session ID", style="cyan", no_wrap=False, max_width=20)
|
|
759
|
+
table.add_column("Started", style="dim")
|
|
760
|
+
table.add_column("Duration", justify="right")
|
|
761
|
+
table.add_column("Messages", justify="right")
|
|
762
|
+
table.add_column("Branch", style="blue")
|
|
763
|
+
|
|
764
|
+
for s in sessions:
|
|
765
|
+
started = (
|
|
766
|
+
s.started_at.strftime("%Y-%m-%d %H:%M")
|
|
767
|
+
if s.started_at
|
|
768
|
+
else "unknown"
|
|
769
|
+
)
|
|
770
|
+
duration = (
|
|
771
|
+
f"{int(s.duration_seconds / 60)}m" if s.duration_seconds else "?"
|
|
772
|
+
)
|
|
773
|
+
branch = s.git_branch or "no branch"
|
|
774
|
+
|
|
775
|
+
table.add_row(
|
|
776
|
+
s.session_id[:20] + "...",
|
|
777
|
+
started,
|
|
778
|
+
duration,
|
|
779
|
+
str(s.user_message_count),
|
|
780
|
+
branch,
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
console.print(table)
|
|
784
|
+
|
|
785
|
+
return CommandResult(text=f"Listed {len(sessions)} transcripts")
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
class TranscriptImportCommand(BaseCommand):
|
|
789
|
+
"""Import transcript."""
|
|
790
|
+
|
|
791
|
+
def __init__(
|
|
792
|
+
self,
|
|
793
|
+
*,
|
|
794
|
+
session_id: str,
|
|
795
|
+
to_session: str | None,
|
|
796
|
+
agent: str,
|
|
797
|
+
overwrite: bool,
|
|
798
|
+
link_feature: str | None,
|
|
799
|
+
format: str,
|
|
800
|
+
) -> None:
|
|
801
|
+
super().__init__()
|
|
802
|
+
self.session_id = session_id
|
|
803
|
+
self.to_session = to_session
|
|
804
|
+
self.agent = agent
|
|
805
|
+
self.overwrite = overwrite
|
|
806
|
+
self.link_feature = link_feature
|
|
807
|
+
self.format = format
|
|
808
|
+
|
|
809
|
+
@classmethod
|
|
810
|
+
def from_args(cls, args: argparse.Namespace) -> TranscriptImportCommand:
|
|
811
|
+
return cls(
|
|
812
|
+
session_id=args.session_id,
|
|
813
|
+
to_session=getattr(args, "to_session", None),
|
|
814
|
+
agent=getattr(args, "agent", "claude-code"),
|
|
815
|
+
overwrite=getattr(args, "overwrite", False),
|
|
816
|
+
link_feature=getattr(args, "link_feature", None),
|
|
817
|
+
format=getattr(args, "format", "text"),
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
def execute(self) -> CommandResult:
|
|
821
|
+
"""Import a transcript file."""
|
|
822
|
+
from htmlgraph.session_manager import SessionManager
|
|
823
|
+
from htmlgraph.transcript import TranscriptReader
|
|
824
|
+
|
|
825
|
+
if not self.graph_dir:
|
|
826
|
+
raise CommandError("Graph directory not specified")
|
|
827
|
+
|
|
828
|
+
reader = TranscriptReader()
|
|
829
|
+
manager = SessionManager(self.graph_dir)
|
|
830
|
+
|
|
831
|
+
# Find the transcript
|
|
832
|
+
transcript = reader.read_session(self.session_id)
|
|
833
|
+
if not transcript:
|
|
834
|
+
console.print(f"[red]Error: Transcript not found: {self.session_id}[/red]")
|
|
835
|
+
return CommandResult(text="Transcript not found", exit_code=1)
|
|
836
|
+
|
|
837
|
+
# Find or create HtmlGraph session
|
|
838
|
+
htmlgraph_session_id = self.to_session
|
|
839
|
+
if not htmlgraph_session_id:
|
|
840
|
+
# Check if already linked
|
|
841
|
+
existing = manager.find_session_by_transcript(self.session_id)
|
|
842
|
+
if existing:
|
|
843
|
+
htmlgraph_session_id = existing.id
|
|
844
|
+
console.print(
|
|
845
|
+
f"[blue]Found existing linked session: {htmlgraph_session_id}[/blue]"
|
|
846
|
+
)
|
|
847
|
+
else:
|
|
848
|
+
# Create new session
|
|
849
|
+
new_session = manager.start_session(
|
|
850
|
+
agent=self.agent,
|
|
851
|
+
title=f"Imported: {transcript.session_id[:12]}",
|
|
852
|
+
)
|
|
853
|
+
htmlgraph_session_id = new_session.id
|
|
854
|
+
console.print(
|
|
855
|
+
f"[green]Created new session: {htmlgraph_session_id}[/green]"
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
# Import events
|
|
859
|
+
result = manager.import_transcript_events(
|
|
860
|
+
session_id=htmlgraph_session_id,
|
|
861
|
+
transcript_session=transcript,
|
|
862
|
+
overwrite=self.overwrite,
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
# Link to feature if specified
|
|
866
|
+
if self.link_feature:
|
|
867
|
+
session = manager.get_session(htmlgraph_session_id)
|
|
868
|
+
if session and self.link_feature not in session.worked_on:
|
|
869
|
+
session.worked_on.append(self.link_feature)
|
|
870
|
+
manager.session_converter.save(session)
|
|
871
|
+
result["linked_feature"] = self.link_feature
|
|
872
|
+
|
|
873
|
+
# Display results
|
|
874
|
+
if self.format == "json":
|
|
875
|
+
console.print_json(json.dumps(result))
|
|
876
|
+
else:
|
|
877
|
+
console.print(
|
|
878
|
+
f"[green]✅ Imported transcript {self.session_id[:12]}:[/green]"
|
|
879
|
+
)
|
|
880
|
+
console.print(f" → HtmlGraph session: {htmlgraph_session_id}")
|
|
881
|
+
console.print(f" → Events imported: {result.get('imported', 0)}")
|
|
882
|
+
console.print(f" → Events skipped: {result.get('skipped', 0)}")
|
|
883
|
+
if result.get("linked_feature"):
|
|
884
|
+
console.print(f" → Linked to feature: {result['linked_feature']}")
|
|
885
|
+
|
|
886
|
+
return CommandResult(text=f"Imported transcript: {self.session_id}")
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
class SyncDocsCommand(BaseCommand):
|
|
890
|
+
"""Synchronize AI agent memory files."""
|
|
891
|
+
|
|
892
|
+
def __init__(
|
|
893
|
+
self,
|
|
894
|
+
*,
|
|
895
|
+
project_root: str | None,
|
|
896
|
+
check: bool,
|
|
897
|
+
generate: str | None,
|
|
898
|
+
force: bool,
|
|
899
|
+
) -> None:
|
|
900
|
+
super().__init__()
|
|
901
|
+
self.project_root = project_root
|
|
902
|
+
self.check = check
|
|
903
|
+
self.generate = generate
|
|
904
|
+
self.force = force
|
|
905
|
+
|
|
906
|
+
@classmethod
|
|
907
|
+
def from_args(cls, args: argparse.Namespace) -> SyncDocsCommand:
|
|
908
|
+
return cls(
|
|
909
|
+
project_root=getattr(args, "project_root", None),
|
|
910
|
+
check=getattr(args, "check", False),
|
|
911
|
+
generate=getattr(args, "generate", None),
|
|
912
|
+
force=getattr(args, "force", False),
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
def execute(self) -> CommandResult:
|
|
916
|
+
"""Synchronize AI agent memory files across platforms."""
|
|
917
|
+
import os
|
|
918
|
+
|
|
919
|
+
from htmlgraph.sync_docs import (
|
|
920
|
+
PLATFORM_TEMPLATES,
|
|
921
|
+
check_all_files,
|
|
922
|
+
generate_platform_file,
|
|
923
|
+
sync_all_files,
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
project_root = Path(self.project_root or os.getcwd()).resolve()
|
|
927
|
+
|
|
928
|
+
if self.check:
|
|
929
|
+
# Check mode
|
|
930
|
+
console.print("[blue]🔍 Checking memory files...[/blue]")
|
|
931
|
+
results = check_all_files(project_root)
|
|
932
|
+
|
|
933
|
+
table = Table(title="Memory File Status", box=box.ROUNDED)
|
|
934
|
+
table.add_column("File", style="cyan")
|
|
935
|
+
table.add_column("Status", style="green")
|
|
936
|
+
|
|
937
|
+
all_good = True
|
|
938
|
+
for filename, status in results.items():
|
|
939
|
+
if filename == "AGENTS.md":
|
|
940
|
+
if status:
|
|
941
|
+
table.add_row(filename, "✅ exists")
|
|
942
|
+
else:
|
|
943
|
+
table.add_row(filename, "❌ MISSING (required)")
|
|
944
|
+
all_good = False
|
|
945
|
+
else:
|
|
946
|
+
if status:
|
|
947
|
+
table.add_row(filename, "✅ references AGENTS.md")
|
|
948
|
+
else:
|
|
949
|
+
table.add_row(filename, "⚠️ missing reference")
|
|
950
|
+
all_good = False
|
|
951
|
+
|
|
952
|
+
console.print(table)
|
|
953
|
+
|
|
954
|
+
if all_good:
|
|
955
|
+
console.print(
|
|
956
|
+
"\n[green]✅ All files are properly synchronized![/green]"
|
|
957
|
+
)
|
|
958
|
+
return CommandResult(text="All files synchronized", exit_code=0)
|
|
959
|
+
else:
|
|
960
|
+
console.print("\n[yellow]⚠️ Some files need attention[/yellow]")
|
|
961
|
+
return CommandResult(text="Files need attention", exit_code=1)
|
|
962
|
+
|
|
963
|
+
elif self.generate:
|
|
964
|
+
# Generate mode
|
|
965
|
+
platform = self.generate.lower()
|
|
966
|
+
console.print(
|
|
967
|
+
f"[blue]📝 Generating {platform.upper()} memory file...[/blue]"
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
try:
|
|
971
|
+
content = generate_platform_file(platform, project_root)
|
|
972
|
+
template = PLATFORM_TEMPLATES[platform]
|
|
973
|
+
filepath = project_root / template["filename"]
|
|
974
|
+
|
|
975
|
+
if filepath.exists() and not self.force:
|
|
976
|
+
console.print(
|
|
977
|
+
f"[yellow]⚠️ {filepath.name} already exists. Use --force to overwrite.[/yellow]"
|
|
978
|
+
)
|
|
979
|
+
raise CommandError("File already exists")
|
|
980
|
+
|
|
981
|
+
filepath.write_text(content)
|
|
982
|
+
console.print(f"[green]✅ Created: {filepath}[/green]")
|
|
983
|
+
console.print(
|
|
984
|
+
"\n[dim]The file references AGENTS.md for core documentation.[/dim]"
|
|
985
|
+
)
|
|
986
|
+
return CommandResult(text=f"Generated {platform} file")
|
|
987
|
+
|
|
988
|
+
except ValueError as e:
|
|
989
|
+
console.print(f"[red]❌ Error: {e}[/red]")
|
|
990
|
+
return CommandResult(text=str(e), exit_code=1)
|
|
991
|
+
|
|
992
|
+
else:
|
|
993
|
+
# Sync mode (default)
|
|
994
|
+
console.print("[blue]🔄 Synchronizing memory files...[/blue]")
|
|
995
|
+
changes = sync_all_files(project_root)
|
|
996
|
+
|
|
997
|
+
console.print("\n[bold]Results:[/bold]")
|
|
998
|
+
for change in changes:
|
|
999
|
+
console.print(f" {change}")
|
|
1000
|
+
|
|
1001
|
+
has_errors = any("⚠️" in c or "❌" in c for c in changes)
|
|
1002
|
+
return CommandResult(
|
|
1003
|
+
text="Synchronization complete",
|
|
1004
|
+
exit_code=1 if has_errors else 0,
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
# ============================================================================
|
|
1009
|
+
# Cost Command Implementation
|
|
1010
|
+
# ============================================================================
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
class CostsCommand(BaseCommand):
|
|
1014
|
+
"""View token cost breakdown and analytics by session, feature, or tool."""
|
|
1015
|
+
|
|
1016
|
+
def __init__(
|
|
1017
|
+
self,
|
|
1018
|
+
*,
|
|
1019
|
+
period: str,
|
|
1020
|
+
by: str,
|
|
1021
|
+
format: str,
|
|
1022
|
+
model: str,
|
|
1023
|
+
limit: int,
|
|
1024
|
+
) -> None:
|
|
1025
|
+
super().__init__()
|
|
1026
|
+
self.period = period
|
|
1027
|
+
self.by = by
|
|
1028
|
+
self.format = format
|
|
1029
|
+
self.model = model
|
|
1030
|
+
self.limit = limit
|
|
1031
|
+
|
|
1032
|
+
@classmethod
|
|
1033
|
+
def from_args(cls, args: argparse.Namespace) -> CostsCommand:
|
|
1034
|
+
return cls(
|
|
1035
|
+
period=getattr(args, "period", "week"),
|
|
1036
|
+
by=getattr(args, "by", "session"),
|
|
1037
|
+
format=getattr(args, "format", "terminal"),
|
|
1038
|
+
model=getattr(args, "model", "auto"),
|
|
1039
|
+
limit=getattr(args, "limit", 10),
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
def execute(self) -> CommandResult:
|
|
1043
|
+
"""Execute cost analysis and display results."""
|
|
1044
|
+
|
|
1045
|
+
if not self.graph_dir:
|
|
1046
|
+
raise CommandError("Graph directory not specified")
|
|
1047
|
+
|
|
1048
|
+
graph_dir = Path(self.graph_dir)
|
|
1049
|
+
db_path = graph_dir / "htmlgraph.db"
|
|
1050
|
+
|
|
1051
|
+
if not db_path.exists():
|
|
1052
|
+
console.print(
|
|
1053
|
+
"[yellow]No HtmlGraph database found. Run some work to generate cost data![/yellow]"
|
|
1054
|
+
)
|
|
1055
|
+
return CommandResult(text="No database", exit_code=1)
|
|
1056
|
+
|
|
1057
|
+
# Query costs from database
|
|
1058
|
+
with console.status("[blue]Analyzing costs...[/blue]", spinner="dots"):
|
|
1059
|
+
try:
|
|
1060
|
+
cost_data = self._query_costs(db_path)
|
|
1061
|
+
except Exception as e:
|
|
1062
|
+
raise CommandError(f"Failed to query costs: {e}")
|
|
1063
|
+
|
|
1064
|
+
if not cost_data:
|
|
1065
|
+
console.print(
|
|
1066
|
+
"[yellow]No cost data found for the specified period.[/yellow]"
|
|
1067
|
+
)
|
|
1068
|
+
return CommandResult(text="No cost data")
|
|
1069
|
+
|
|
1070
|
+
# Calculate USD costs based on model pricing
|
|
1071
|
+
cost_data = self._add_usd_costs(cost_data)
|
|
1072
|
+
|
|
1073
|
+
# Display results
|
|
1074
|
+
if self.format == "csv":
|
|
1075
|
+
self._display_csv(cost_data)
|
|
1076
|
+
else:
|
|
1077
|
+
self._display_terminal(cost_data)
|
|
1078
|
+
|
|
1079
|
+
# Display insights
|
|
1080
|
+
self._display_insights(cost_data)
|
|
1081
|
+
|
|
1082
|
+
return CommandResult(text="Cost analysis complete")
|
|
1083
|
+
|
|
1084
|
+
def _query_costs(self, db_path: Path) -> list[dict]:
|
|
1085
|
+
"""Query costs from the database based on period and grouping."""
|
|
1086
|
+
import sqlite3
|
|
1087
|
+
from datetime import datetime, timezone
|
|
1088
|
+
|
|
1089
|
+
conn = sqlite3.connect(str(db_path))
|
|
1090
|
+
conn.row_factory = sqlite3.Row
|
|
1091
|
+
cursor = conn.cursor()
|
|
1092
|
+
|
|
1093
|
+
# Calculate time filter
|
|
1094
|
+
now = datetime.now(timezone.utc)
|
|
1095
|
+
time_filter = self._get_time_filter(now)
|
|
1096
|
+
|
|
1097
|
+
# Build the query based on grouping
|
|
1098
|
+
if self.by == "session":
|
|
1099
|
+
query = """
|
|
1100
|
+
SELECT
|
|
1101
|
+
session_id as group_id,
|
|
1102
|
+
session_id as name,
|
|
1103
|
+
'session' as type,
|
|
1104
|
+
COUNT(*) as event_count,
|
|
1105
|
+
SUM(cost_tokens) as total_tokens,
|
|
1106
|
+
MIN(timestamp) as start_time,
|
|
1107
|
+
MAX(timestamp) as end_time
|
|
1108
|
+
FROM agent_events
|
|
1109
|
+
WHERE event_type IN ('tool_call', 'tool_result')
|
|
1110
|
+
AND cost_tokens > 0
|
|
1111
|
+
AND timestamp >= ?
|
|
1112
|
+
GROUP BY session_id
|
|
1113
|
+
ORDER BY total_tokens DESC
|
|
1114
|
+
LIMIT ?
|
|
1115
|
+
"""
|
|
1116
|
+
cursor.execute(query, (time_filter, self.limit))
|
|
1117
|
+
|
|
1118
|
+
elif self.by == "feature":
|
|
1119
|
+
query = """
|
|
1120
|
+
SELECT
|
|
1121
|
+
feature_id as group_id,
|
|
1122
|
+
COALESCE(feature_id, 'unlinked') as name,
|
|
1123
|
+
'feature' as type,
|
|
1124
|
+
COUNT(*) as event_count,
|
|
1125
|
+
SUM(cost_tokens) as total_tokens,
|
|
1126
|
+
MIN(timestamp) as start_time,
|
|
1127
|
+
MAX(timestamp) as end_time
|
|
1128
|
+
FROM agent_events
|
|
1129
|
+
WHERE event_type IN ('tool_call', 'tool_result')
|
|
1130
|
+
AND cost_tokens > 0
|
|
1131
|
+
AND timestamp >= ?
|
|
1132
|
+
GROUP BY feature_id
|
|
1133
|
+
ORDER BY total_tokens DESC
|
|
1134
|
+
LIMIT ?
|
|
1135
|
+
"""
|
|
1136
|
+
cursor.execute(query, (time_filter, self.limit))
|
|
1137
|
+
|
|
1138
|
+
elif self.by == "tool":
|
|
1139
|
+
query = """
|
|
1140
|
+
SELECT
|
|
1141
|
+
tool_name as group_id,
|
|
1142
|
+
tool_name as name,
|
|
1143
|
+
'tool' as type,
|
|
1144
|
+
COUNT(*) as event_count,
|
|
1145
|
+
SUM(cost_tokens) as total_tokens,
|
|
1146
|
+
MIN(timestamp) as start_time,
|
|
1147
|
+
MAX(timestamp) as end_time
|
|
1148
|
+
FROM agent_events
|
|
1149
|
+
WHERE event_type IN ('tool_call', 'tool_result')
|
|
1150
|
+
AND cost_tokens > 0
|
|
1151
|
+
AND timestamp >= ?
|
|
1152
|
+
GROUP BY tool_name
|
|
1153
|
+
ORDER BY total_tokens DESC
|
|
1154
|
+
LIMIT ?
|
|
1155
|
+
"""
|
|
1156
|
+
cursor.execute(query, (time_filter, self.limit))
|
|
1157
|
+
|
|
1158
|
+
elif self.by == "agent":
|
|
1159
|
+
query = """
|
|
1160
|
+
SELECT
|
|
1161
|
+
agent as group_id,
|
|
1162
|
+
agent as name,
|
|
1163
|
+
'agent' as type,
|
|
1164
|
+
COUNT(*) as event_count,
|
|
1165
|
+
SUM(cost_tokens) as total_tokens,
|
|
1166
|
+
MIN(timestamp) as start_time,
|
|
1167
|
+
MAX(timestamp) as end_time
|
|
1168
|
+
FROM agent_events
|
|
1169
|
+
WHERE event_type IN ('tool_call', 'tool_result')
|
|
1170
|
+
AND cost_tokens > 0
|
|
1171
|
+
AND timestamp >= ?
|
|
1172
|
+
GROUP BY agent
|
|
1173
|
+
ORDER BY total_tokens DESC
|
|
1174
|
+
LIMIT ?
|
|
1175
|
+
"""
|
|
1176
|
+
cursor.execute(query, (time_filter, self.limit))
|
|
1177
|
+
|
|
1178
|
+
results = []
|
|
1179
|
+
for row in cursor.fetchall():
|
|
1180
|
+
results.append(dict(row))
|
|
1181
|
+
|
|
1182
|
+
conn.close()
|
|
1183
|
+
return results
|
|
1184
|
+
|
|
1185
|
+
def _get_time_filter(self, now: datetime) -> str:
|
|
1186
|
+
"""Get ISO format timestamp for time filtering."""
|
|
1187
|
+
from datetime import timedelta
|
|
1188
|
+
|
|
1189
|
+
if self.period == "today":
|
|
1190
|
+
delta = timedelta(hours=24)
|
|
1191
|
+
elif self.period == "day":
|
|
1192
|
+
delta = timedelta(days=1)
|
|
1193
|
+
elif self.period == "week":
|
|
1194
|
+
delta = timedelta(days=7)
|
|
1195
|
+
elif self.period == "month":
|
|
1196
|
+
delta = timedelta(days=30)
|
|
1197
|
+
else: # "all"
|
|
1198
|
+
delta = timedelta(days=36500) # ~100 years
|
|
1199
|
+
|
|
1200
|
+
cutoff = now - delta
|
|
1201
|
+
return cutoff.isoformat()
|
|
1202
|
+
|
|
1203
|
+
def _add_usd_costs(self, cost_data: list[dict]) -> list[dict]:
|
|
1204
|
+
"""Add USD cost estimates to cost data."""
|
|
1205
|
+
for item in cost_data:
|
|
1206
|
+
item["cost_usd"] = self._calculate_usd(item["total_tokens"])
|
|
1207
|
+
return cost_data
|
|
1208
|
+
|
|
1209
|
+
def _calculate_usd(self, tokens: int) -> float:
|
|
1210
|
+
"""Calculate USD cost from tokens based on model pricing."""
|
|
1211
|
+
# Claude pricing (per 1M tokens):
|
|
1212
|
+
# Opus: $15 input, $45 output
|
|
1213
|
+
# Sonnet: $3 input, $15 output
|
|
1214
|
+
# Haiku: $0.80 input, $4 output
|
|
1215
|
+
|
|
1216
|
+
# Assume ~90% input, 10% output ratio
|
|
1217
|
+
input_ratio = 0.9
|
|
1218
|
+
output_ratio = 0.1
|
|
1219
|
+
|
|
1220
|
+
if self.model == "opus" or (self.model == "auto"):
|
|
1221
|
+
# Default to Opus for conservative estimate
|
|
1222
|
+
input_cost = 15 / 1_000_000
|
|
1223
|
+
output_cost = 45 / 1_000_000
|
|
1224
|
+
elif self.model == "sonnet":
|
|
1225
|
+
input_cost = 3 / 1_000_000
|
|
1226
|
+
output_cost = 15 / 1_000_000
|
|
1227
|
+
elif self.model == "haiku":
|
|
1228
|
+
input_cost = 0.80 / 1_000_000
|
|
1229
|
+
output_cost = 4 / 1_000_000
|
|
1230
|
+
else:
|
|
1231
|
+
# Fallback to Opus
|
|
1232
|
+
input_cost = 15 / 1_000_000
|
|
1233
|
+
output_cost = 45 / 1_000_000
|
|
1234
|
+
|
|
1235
|
+
cost = (tokens * input_ratio * input_cost) + (
|
|
1236
|
+
tokens * output_ratio * output_cost
|
|
1237
|
+
)
|
|
1238
|
+
return cost
|
|
1239
|
+
|
|
1240
|
+
def _display_terminal(self, cost_data: list[dict]) -> None:
|
|
1241
|
+
"""Display costs in terminal with rich formatting."""
|
|
1242
|
+
from htmlgraph.cli.base import TableBuilder
|
|
1243
|
+
|
|
1244
|
+
# Period label
|
|
1245
|
+
period_label = self.period.upper()
|
|
1246
|
+
if self.period == "today":
|
|
1247
|
+
period_label = "TODAY"
|
|
1248
|
+
elif self.period == "day":
|
|
1249
|
+
period_label = "LAST 24 HOURS"
|
|
1250
|
+
elif self.period == "week":
|
|
1251
|
+
period_label = "LAST 7 DAYS"
|
|
1252
|
+
elif self.period == "month":
|
|
1253
|
+
period_label = "LAST 30 DAYS"
|
|
1254
|
+
|
|
1255
|
+
console.print(f"\n[bold cyan]{period_label} - COST SUMMARY[/bold cyan]")
|
|
1256
|
+
console.print("[dim]═" * 60 + "[/dim]\n")
|
|
1257
|
+
|
|
1258
|
+
# Build table
|
|
1259
|
+
table_builder = TableBuilder.create_list_table(title=None)
|
|
1260
|
+
table_builder.add_column("Name", style="cyan")
|
|
1261
|
+
table_builder.add_numeric_column("Events", style="green")
|
|
1262
|
+
table_builder.add_numeric_column("Tokens", style="yellow")
|
|
1263
|
+
table_builder.add_numeric_column("Estimated Cost", style="magenta")
|
|
1264
|
+
|
|
1265
|
+
total_tokens = 0
|
|
1266
|
+
total_usd = 0.0
|
|
1267
|
+
|
|
1268
|
+
for item in cost_data:
|
|
1269
|
+
name = item["name"] or "(unlinked)"
|
|
1270
|
+
if len(name) > 30:
|
|
1271
|
+
name = name[:27] + "..."
|
|
1272
|
+
|
|
1273
|
+
events = f"{item['event_count']:,}"
|
|
1274
|
+
tokens = f"{item['total_tokens']:,}"
|
|
1275
|
+
cost_str = f"${item['cost_usd']:.2f}"
|
|
1276
|
+
|
|
1277
|
+
table_builder.add_row(name, events, tokens, cost_str)
|
|
1278
|
+
|
|
1279
|
+
total_tokens += item["total_tokens"]
|
|
1280
|
+
total_usd += item["cost_usd"]
|
|
1281
|
+
|
|
1282
|
+
console.print(table_builder.table)
|
|
1283
|
+
|
|
1284
|
+
# Summary
|
|
1285
|
+
console.print("\n[dim]─" * 60 + "[/dim]")
|
|
1286
|
+
console.print(
|
|
1287
|
+
f"[bold]Total Tokens:[/bold] {total_tokens:,} [dim]({self._format_duration(cost_data)})[/dim]"
|
|
1288
|
+
)
|
|
1289
|
+
console.print(
|
|
1290
|
+
f"[bold]Estimated Cost:[/bold] ${total_usd:.2f} ({self.model.upper() if self.model != 'auto' else 'Opus'})"
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
# Insights
|
|
1294
|
+
if len(cost_data) > 0:
|
|
1295
|
+
top_item = cost_data[0]
|
|
1296
|
+
pct = (
|
|
1297
|
+
(top_item["total_tokens"] / total_tokens * 100)
|
|
1298
|
+
if total_tokens > 0
|
|
1299
|
+
else 0
|
|
1300
|
+
)
|
|
1301
|
+
console.print(
|
|
1302
|
+
f"\n[dim]Most expensive:[/dim] [yellow]{top_item['name']}[/yellow] "
|
|
1303
|
+
f"[dim]({pct:.0f}% of total)[/dim]"
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
def _display_csv(self, cost_data: list[dict]) -> None:
|
|
1307
|
+
"""Display costs in CSV format for spreadsheet analysis."""
|
|
1308
|
+
import csv
|
|
1309
|
+
import io
|
|
1310
|
+
|
|
1311
|
+
output = io.StringIO()
|
|
1312
|
+
writer = csv.writer(output)
|
|
1313
|
+
|
|
1314
|
+
# Header
|
|
1315
|
+
if self.by == "session":
|
|
1316
|
+
writer.writerow(["Session ID", "Events", "Tokens", "Estimated Cost (USD)"])
|
|
1317
|
+
else:
|
|
1318
|
+
writer.writerow(
|
|
1319
|
+
[
|
|
1320
|
+
self.by.capitalize(),
|
|
1321
|
+
"Events",
|
|
1322
|
+
"Tokens",
|
|
1323
|
+
"Estimated Cost (USD)",
|
|
1324
|
+
]
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
# Data rows
|
|
1328
|
+
for item in cost_data:
|
|
1329
|
+
writer.writerow(
|
|
1330
|
+
[
|
|
1331
|
+
item["name"],
|
|
1332
|
+
item["event_count"],
|
|
1333
|
+
item["total_tokens"],
|
|
1334
|
+
f"{item['cost_usd']:.2f}",
|
|
1335
|
+
]
|
|
1336
|
+
)
|
|
1337
|
+
|
|
1338
|
+
# Totals
|
|
1339
|
+
total_tokens = sum(item["total_tokens"] for item in cost_data)
|
|
1340
|
+
total_usd = sum(item["cost_usd"] for item in cost_data)
|
|
1341
|
+
writer.writerow(["TOTAL", "", total_tokens, f"{total_usd:.2f}"])
|
|
1342
|
+
|
|
1343
|
+
csv_content = output.getvalue()
|
|
1344
|
+
console.print(csv_content)
|
|
1345
|
+
|
|
1346
|
+
def _display_insights(self, cost_data: list[dict]) -> None:
|
|
1347
|
+
"""Display cost optimization insights."""
|
|
1348
|
+
if not cost_data:
|
|
1349
|
+
return
|
|
1350
|
+
|
|
1351
|
+
console.print("\n[bold cyan]Insights & Recommendations[/bold cyan]")
|
|
1352
|
+
console.print("[dim]─" * 60 + "[/dim]\n")
|
|
1353
|
+
|
|
1354
|
+
total_tokens = sum(item["total_tokens"] for item in cost_data)
|
|
1355
|
+
|
|
1356
|
+
# Insight 1: Top cost driver
|
|
1357
|
+
top_item = cost_data[0]
|
|
1358
|
+
top_pct = (
|
|
1359
|
+
(top_item["total_tokens"] / total_tokens * 100) if total_tokens > 0 else 0
|
|
1360
|
+
)
|
|
1361
|
+
console.print(
|
|
1362
|
+
f"[blue]→ Highest cost:[/blue] {top_item['name']} "
|
|
1363
|
+
f"[yellow]({top_pct:.0f}% of total)[/yellow]"
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
# Insight 2: Concentration
|
|
1367
|
+
if len(cost_data) > 1:
|
|
1368
|
+
top_3_pct = (
|
|
1369
|
+
sum(item["total_tokens"] for item in cost_data[:3])
|
|
1370
|
+
/ (total_tokens if total_tokens > 0 else 1)
|
|
1371
|
+
* 100
|
|
1372
|
+
)
|
|
1373
|
+
console.print(
|
|
1374
|
+
f"[blue]→ Cost concentration:[/blue] Top 3 account for [yellow]{top_3_pct:.0f}%[/yellow]"
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
# Insight 3: Recommendations
|
|
1378
|
+
if self.by == "tool" and top_item["name"] in ["Read", "Bash", "Grep"]:
|
|
1379
|
+
console.print(
|
|
1380
|
+
f"[yellow]→ Tip:[/yellow] {top_item['name']} is expensive. Consider batching operations "
|
|
1381
|
+
"or using more efficient approaches."
|
|
1382
|
+
)
|
|
1383
|
+
elif self.by == "session" and len(cost_data) > 5:
|
|
1384
|
+
console.print(
|
|
1385
|
+
"[yellow]→ Tip:[/yellow] Many sessions with costs. Consider consolidating work "
|
|
1386
|
+
"to fewer, focused sessions."
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
console.print()
|
|
1390
|
+
|
|
1391
|
+
def _format_duration(self, cost_data: list[dict]) -> str:
|
|
1392
|
+
"""Format duration from start/end times."""
|
|
1393
|
+
if not cost_data or "start_time" not in cost_data[0]:
|
|
1394
|
+
return "unknown"
|
|
1395
|
+
|
|
1396
|
+
try:
|
|
1397
|
+
from datetime import datetime
|
|
1398
|
+
|
|
1399
|
+
start_times = [
|
|
1400
|
+
datetime.fromisoformat(item["start_time"])
|
|
1401
|
+
for item in cost_data
|
|
1402
|
+
if item.get("start_time")
|
|
1403
|
+
]
|
|
1404
|
+
end_times = [
|
|
1405
|
+
datetime.fromisoformat(item["end_time"])
|
|
1406
|
+
for item in cost_data
|
|
1407
|
+
if item.get("end_time")
|
|
1408
|
+
]
|
|
1409
|
+
|
|
1410
|
+
if not start_times or not end_times:
|
|
1411
|
+
return "unknown"
|
|
1412
|
+
|
|
1413
|
+
earliest = min(start_times)
|
|
1414
|
+
latest = max(end_times)
|
|
1415
|
+
duration = latest - earliest
|
|
1416
|
+
|
|
1417
|
+
hours = duration.total_seconds() / 3600
|
|
1418
|
+
if hours > 1:
|
|
1419
|
+
return f"{hours:.1f}h"
|
|
1420
|
+
else:
|
|
1421
|
+
minutes = duration.total_seconds() / 60
|
|
1422
|
+
return f"{minutes:.0f}m"
|
|
1423
|
+
except Exception:
|
|
1424
|
+
return "unknown"
|