htmlgraph 0.9.3__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.
Files changed (331) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
  2. htmlgraph/.htmlgraph/agents.json +72 -0
  3. htmlgraph/.htmlgraph/htmlgraph.db +0 -0
  4. htmlgraph/__init__.py +173 -17
  5. htmlgraph/__init__.pyi +123 -0
  6. htmlgraph/agent_detection.py +127 -0
  7. htmlgraph/agent_registry.py +45 -30
  8. htmlgraph/agents.py +160 -107
  9. htmlgraph/analytics/__init__.py +9 -2
  10. htmlgraph/analytics/cli.py +190 -51
  11. htmlgraph/analytics/cost_analyzer.py +391 -0
  12. htmlgraph/analytics/cost_monitor.py +664 -0
  13. htmlgraph/analytics/cost_reporter.py +675 -0
  14. htmlgraph/analytics/cross_session.py +617 -0
  15. htmlgraph/analytics/dependency.py +192 -100
  16. htmlgraph/analytics/pattern_learning.py +771 -0
  17. htmlgraph/analytics/session_graph.py +707 -0
  18. htmlgraph/analytics/strategic/__init__.py +80 -0
  19. htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
  20. htmlgraph/analytics/strategic/pattern_detector.py +876 -0
  21. htmlgraph/analytics/strategic/preference_manager.py +709 -0
  22. htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
  23. htmlgraph/analytics/work_type.py +190 -14
  24. htmlgraph/analytics_index.py +135 -51
  25. htmlgraph/api/__init__.py +3 -0
  26. htmlgraph/api/cost_alerts_websocket.py +416 -0
  27. htmlgraph/api/main.py +2498 -0
  28. htmlgraph/api/static/htmx.min.js +1 -0
  29. htmlgraph/api/static/style-redesign.css +1344 -0
  30. htmlgraph/api/static/style.css +1079 -0
  31. htmlgraph/api/templates/dashboard-redesign.html +1366 -0
  32. htmlgraph/api/templates/dashboard.html +794 -0
  33. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  34. htmlgraph/api/templates/partials/activity-feed.html +1100 -0
  35. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  36. htmlgraph/api/templates/partials/agents.html +317 -0
  37. htmlgraph/api/templates/partials/event-traces.html +373 -0
  38. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  39. htmlgraph/api/templates/partials/features.html +578 -0
  40. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  41. htmlgraph/api/templates/partials/metrics.html +346 -0
  42. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  43. htmlgraph/api/templates/partials/orchestration.html +198 -0
  44. htmlgraph/api/templates/partials/spawners.html +375 -0
  45. htmlgraph/api/templates/partials/work-items.html +613 -0
  46. htmlgraph/api/websocket.py +538 -0
  47. htmlgraph/archive/__init__.py +24 -0
  48. htmlgraph/archive/bloom.py +234 -0
  49. htmlgraph/archive/fts.py +297 -0
  50. htmlgraph/archive/manager.py +583 -0
  51. htmlgraph/archive/search.py +244 -0
  52. htmlgraph/atomic_ops.py +560 -0
  53. htmlgraph/attribute_index.py +208 -0
  54. htmlgraph/bounded_paths.py +539 -0
  55. htmlgraph/builders/__init__.py +14 -0
  56. htmlgraph/builders/base.py +118 -29
  57. htmlgraph/builders/bug.py +150 -0
  58. htmlgraph/builders/chore.py +119 -0
  59. htmlgraph/builders/epic.py +150 -0
  60. htmlgraph/builders/feature.py +31 -6
  61. htmlgraph/builders/insight.py +195 -0
  62. htmlgraph/builders/metric.py +217 -0
  63. htmlgraph/builders/pattern.py +202 -0
  64. htmlgraph/builders/phase.py +162 -0
  65. htmlgraph/builders/spike.py +52 -19
  66. htmlgraph/builders/track.py +148 -72
  67. htmlgraph/cigs/__init__.py +81 -0
  68. htmlgraph/cigs/autonomy.py +385 -0
  69. htmlgraph/cigs/cost.py +475 -0
  70. htmlgraph/cigs/messages_basic.py +472 -0
  71. htmlgraph/cigs/messaging.py +365 -0
  72. htmlgraph/cigs/models.py +771 -0
  73. htmlgraph/cigs/pattern_storage.py +427 -0
  74. htmlgraph/cigs/patterns.py +503 -0
  75. htmlgraph/cigs/posttool_analyzer.py +234 -0
  76. htmlgraph/cigs/reporter.py +818 -0
  77. htmlgraph/cigs/tracker.py +317 -0
  78. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  79. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  80. htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
  81. htmlgraph/cli/__init__.py +42 -0
  82. htmlgraph/cli/__main__.py +6 -0
  83. htmlgraph/cli/analytics.py +1424 -0
  84. htmlgraph/cli/base.py +685 -0
  85. htmlgraph/cli/constants.py +206 -0
  86. htmlgraph/cli/core.py +954 -0
  87. htmlgraph/cli/main.py +147 -0
  88. htmlgraph/cli/models.py +475 -0
  89. htmlgraph/cli/templates/__init__.py +1 -0
  90. htmlgraph/cli/templates/cost_dashboard.py +399 -0
  91. htmlgraph/cli/work/__init__.py +239 -0
  92. htmlgraph/cli/work/browse.py +115 -0
  93. htmlgraph/cli/work/features.py +568 -0
  94. htmlgraph/cli/work/orchestration.py +676 -0
  95. htmlgraph/cli/work/report.py +728 -0
  96. htmlgraph/cli/work/sessions.py +466 -0
  97. htmlgraph/cli/work/snapshot.py +559 -0
  98. htmlgraph/cli/work/tracks.py +486 -0
  99. htmlgraph/cli_commands/__init__.py +1 -0
  100. htmlgraph/cli_commands/feature.py +195 -0
  101. htmlgraph/cli_framework.py +115 -0
  102. htmlgraph/collections/__init__.py +18 -0
  103. htmlgraph/collections/base.py +415 -98
  104. htmlgraph/collections/bug.py +53 -0
  105. htmlgraph/collections/chore.py +53 -0
  106. htmlgraph/collections/epic.py +53 -0
  107. htmlgraph/collections/feature.py +12 -26
  108. htmlgraph/collections/insight.py +100 -0
  109. htmlgraph/collections/metric.py +92 -0
  110. htmlgraph/collections/pattern.py +97 -0
  111. htmlgraph/collections/phase.py +53 -0
  112. htmlgraph/collections/session.py +194 -0
  113. htmlgraph/collections/spike.py +56 -16
  114. htmlgraph/collections/task_delegation.py +241 -0
  115. htmlgraph/collections/todo.py +511 -0
  116. htmlgraph/collections/traces.py +487 -0
  117. htmlgraph/config/cost_models.json +56 -0
  118. htmlgraph/config.py +190 -0
  119. htmlgraph/context_analytics.py +344 -0
  120. htmlgraph/converter.py +216 -28
  121. htmlgraph/cost_analysis/__init__.py +5 -0
  122. htmlgraph/cost_analysis/analyzer.py +438 -0
  123. htmlgraph/dashboard.html +2406 -307
  124. htmlgraph/dashboard.html.backup +6592 -0
  125. htmlgraph/dashboard.html.bak +7181 -0
  126. htmlgraph/dashboard.html.bak2 +7231 -0
  127. htmlgraph/dashboard.html.bak3 +7232 -0
  128. htmlgraph/db/__init__.py +38 -0
  129. htmlgraph/db/queries.py +790 -0
  130. htmlgraph/db/schema.py +1788 -0
  131. htmlgraph/decorators.py +317 -0
  132. htmlgraph/dependency_models.py +19 -2
  133. htmlgraph/deploy.py +142 -125
  134. htmlgraph/deployment_models.py +474 -0
  135. htmlgraph/docs/API_REFERENCE.md +841 -0
  136. htmlgraph/docs/HTTP_API.md +750 -0
  137. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  138. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
  139. htmlgraph/docs/README.md +532 -0
  140. htmlgraph/docs/__init__.py +77 -0
  141. htmlgraph/docs/docs_version.py +55 -0
  142. htmlgraph/docs/metadata.py +93 -0
  143. htmlgraph/docs/migrations.py +232 -0
  144. htmlgraph/docs/template_engine.py +143 -0
  145. htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
  146. htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
  147. htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
  148. htmlgraph/docs/templates/base_agents.md.j2 +78 -0
  149. htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
  150. htmlgraph/docs/version_check.py +163 -0
  151. htmlgraph/edge_index.py +182 -27
  152. htmlgraph/error_handler.py +544 -0
  153. htmlgraph/event_log.py +100 -52
  154. htmlgraph/event_migration.py +13 -4
  155. htmlgraph/exceptions.py +49 -0
  156. htmlgraph/file_watcher.py +101 -28
  157. htmlgraph/find_api.py +75 -63
  158. htmlgraph/git_events.py +145 -63
  159. htmlgraph/graph.py +1122 -106
  160. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  161. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  162. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  163. htmlgraph/hooks/__init__.py +45 -0
  164. htmlgraph/hooks/bootstrap.py +169 -0
  165. htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
  166. htmlgraph/hooks/concurrent_sessions.py +208 -0
  167. htmlgraph/hooks/context.py +350 -0
  168. htmlgraph/hooks/drift_handler.py +525 -0
  169. htmlgraph/hooks/event_tracker.py +1314 -0
  170. htmlgraph/hooks/git_commands.py +175 -0
  171. htmlgraph/hooks/hooks-config.example.json +12 -0
  172. htmlgraph/hooks/installer.py +343 -0
  173. htmlgraph/hooks/orchestrator.py +674 -0
  174. htmlgraph/hooks/orchestrator_reflector.py +223 -0
  175. htmlgraph/hooks/post-checkout.sh +28 -0
  176. htmlgraph/hooks/post-commit.sh +24 -0
  177. htmlgraph/hooks/post-merge.sh +26 -0
  178. htmlgraph/hooks/post_tool_use_failure.py +273 -0
  179. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  180. htmlgraph/hooks/posttooluse.py +408 -0
  181. htmlgraph/hooks/pre-commit.sh +94 -0
  182. htmlgraph/hooks/pre-push.sh +28 -0
  183. htmlgraph/hooks/pretooluse.py +819 -0
  184. htmlgraph/hooks/prompt_analyzer.py +637 -0
  185. htmlgraph/hooks/session_handler.py +668 -0
  186. htmlgraph/hooks/session_summary.py +395 -0
  187. htmlgraph/hooks/state_manager.py +504 -0
  188. htmlgraph/hooks/subagent_detection.py +202 -0
  189. htmlgraph/hooks/subagent_stop.py +369 -0
  190. htmlgraph/hooks/task_enforcer.py +255 -0
  191. htmlgraph/hooks/task_validator.py +177 -0
  192. htmlgraph/hooks/validator.py +628 -0
  193. htmlgraph/ids.py +41 -27
  194. htmlgraph/index.d.ts +286 -0
  195. htmlgraph/learning.py +767 -0
  196. htmlgraph/mcp_server.py +69 -23
  197. htmlgraph/models.py +1586 -87
  198. htmlgraph/operations/README.md +62 -0
  199. htmlgraph/operations/__init__.py +79 -0
  200. htmlgraph/operations/analytics.py +339 -0
  201. htmlgraph/operations/bootstrap.py +289 -0
  202. htmlgraph/operations/events.py +244 -0
  203. htmlgraph/operations/fastapi_server.py +231 -0
  204. htmlgraph/operations/hooks.py +350 -0
  205. htmlgraph/operations/initialization.py +597 -0
  206. htmlgraph/operations/initialization.py.backup +228 -0
  207. htmlgraph/operations/server.py +303 -0
  208. htmlgraph/orchestration/__init__.py +58 -0
  209. htmlgraph/orchestration/claude_launcher.py +179 -0
  210. htmlgraph/orchestration/command_builder.py +72 -0
  211. htmlgraph/orchestration/headless_spawner.py +281 -0
  212. htmlgraph/orchestration/live_events.py +377 -0
  213. htmlgraph/orchestration/model_selection.py +327 -0
  214. htmlgraph/orchestration/plugin_manager.py +140 -0
  215. htmlgraph/orchestration/prompts.py +137 -0
  216. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  217. htmlgraph/orchestration/spawners/__init__.py +16 -0
  218. htmlgraph/orchestration/spawners/base.py +194 -0
  219. htmlgraph/orchestration/spawners/claude.py +173 -0
  220. htmlgraph/orchestration/spawners/codex.py +435 -0
  221. htmlgraph/orchestration/spawners/copilot.py +294 -0
  222. htmlgraph/orchestration/spawners/gemini.py +471 -0
  223. htmlgraph/orchestration/subprocess_runner.py +36 -0
  224. htmlgraph/orchestration/task_coordination.py +343 -0
  225. htmlgraph/orchestration.md +563 -0
  226. htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
  227. htmlgraph/orchestrator.py +669 -0
  228. htmlgraph/orchestrator_config.py +357 -0
  229. htmlgraph/orchestrator_mode.py +328 -0
  230. htmlgraph/orchestrator_validator.py +133 -0
  231. htmlgraph/parallel.py +646 -0
  232. htmlgraph/parser.py +160 -35
  233. htmlgraph/path_query.py +608 -0
  234. htmlgraph/pattern_matcher.py +636 -0
  235. htmlgraph/planning.py +147 -52
  236. htmlgraph/pydantic_models.py +476 -0
  237. htmlgraph/quality_gates.py +350 -0
  238. htmlgraph/query_builder.py +109 -72
  239. htmlgraph/query_composer.py +509 -0
  240. htmlgraph/reflection.py +443 -0
  241. htmlgraph/refs.py +344 -0
  242. htmlgraph/repo_hash.py +512 -0
  243. htmlgraph/repositories/__init__.py +292 -0
  244. htmlgraph/repositories/analytics_repository.py +455 -0
  245. htmlgraph/repositories/analytics_repository_standard.py +628 -0
  246. htmlgraph/repositories/feature_repository.py +581 -0
  247. htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
  248. htmlgraph/repositories/feature_repository_memory.py +607 -0
  249. htmlgraph/repositories/feature_repository_sqlite.py +858 -0
  250. htmlgraph/repositories/filter_service.py +620 -0
  251. htmlgraph/repositories/filter_service_standard.py +445 -0
  252. htmlgraph/repositories/shared_cache.py +621 -0
  253. htmlgraph/repositories/shared_cache_memory.py +395 -0
  254. htmlgraph/repositories/track_repository.py +552 -0
  255. htmlgraph/repositories/track_repository_htmlfile.py +619 -0
  256. htmlgraph/repositories/track_repository_memory.py +508 -0
  257. htmlgraph/repositories/track_repository_sqlite.py +711 -0
  258. htmlgraph/routing.py +8 -19
  259. htmlgraph/scripts/deploy.py +1 -2
  260. htmlgraph/sdk/__init__.py +398 -0
  261. htmlgraph/sdk/__init__.pyi +14 -0
  262. htmlgraph/sdk/analytics/__init__.py +19 -0
  263. htmlgraph/sdk/analytics/engine.py +155 -0
  264. htmlgraph/sdk/analytics/helpers.py +178 -0
  265. htmlgraph/sdk/analytics/registry.py +109 -0
  266. htmlgraph/sdk/base.py +484 -0
  267. htmlgraph/sdk/constants.py +216 -0
  268. htmlgraph/sdk/core.pyi +308 -0
  269. htmlgraph/sdk/discovery.py +120 -0
  270. htmlgraph/sdk/help/__init__.py +12 -0
  271. htmlgraph/sdk/help/mixin.py +699 -0
  272. htmlgraph/sdk/mixins/__init__.py +15 -0
  273. htmlgraph/sdk/mixins/attribution.py +113 -0
  274. htmlgraph/sdk/mixins/mixin.py +410 -0
  275. htmlgraph/sdk/operations/__init__.py +12 -0
  276. htmlgraph/sdk/operations/mixin.py +427 -0
  277. htmlgraph/sdk/orchestration/__init__.py +17 -0
  278. htmlgraph/sdk/orchestration/coordinator.py +203 -0
  279. htmlgraph/sdk/orchestration/spawner.py +204 -0
  280. htmlgraph/sdk/planning/__init__.py +19 -0
  281. htmlgraph/sdk/planning/bottlenecks.py +93 -0
  282. htmlgraph/sdk/planning/mixin.py +211 -0
  283. htmlgraph/sdk/planning/parallel.py +186 -0
  284. htmlgraph/sdk/planning/queue.py +210 -0
  285. htmlgraph/sdk/planning/recommendations.py +87 -0
  286. htmlgraph/sdk/planning/smart_planning.py +319 -0
  287. htmlgraph/sdk/session/__init__.py +19 -0
  288. htmlgraph/sdk/session/continuity.py +57 -0
  289. htmlgraph/sdk/session/handoff.py +110 -0
  290. htmlgraph/sdk/session/info.py +309 -0
  291. htmlgraph/sdk/session/manager.py +103 -0
  292. htmlgraph/sdk/strategic/__init__.py +26 -0
  293. htmlgraph/sdk/strategic/mixin.py +563 -0
  294. htmlgraph/server.py +685 -180
  295. htmlgraph/services/__init__.py +10 -0
  296. htmlgraph/services/claiming.py +199 -0
  297. htmlgraph/session_hooks.py +300 -0
  298. htmlgraph/session_manager.py +1392 -175
  299. htmlgraph/session_registry.py +587 -0
  300. htmlgraph/session_state.py +436 -0
  301. htmlgraph/session_warning.py +201 -0
  302. htmlgraph/sessions/__init__.py +23 -0
  303. htmlgraph/sessions/handoff.py +756 -0
  304. htmlgraph/setup.py +34 -17
  305. htmlgraph/spike_index.py +143 -0
  306. htmlgraph/sync_docs.py +12 -15
  307. htmlgraph/system_prompts.py +450 -0
  308. htmlgraph/templates/AGENTS.md.template +366 -0
  309. htmlgraph/templates/CLAUDE.md.template +97 -0
  310. htmlgraph/templates/GEMINI.md.template +87 -0
  311. htmlgraph/templates/orchestration-view.html +350 -0
  312. htmlgraph/track_builder.py +146 -15
  313. htmlgraph/track_manager.py +69 -21
  314. htmlgraph/transcript.py +890 -0
  315. htmlgraph/transcript_analytics.py +699 -0
  316. htmlgraph/types.py +323 -0
  317. htmlgraph/validation.py +115 -0
  318. htmlgraph/watch.py +8 -5
  319. htmlgraph/work_type_utils.py +3 -2
  320. {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2406 -307
  321. htmlgraph-0.27.5.data/data/htmlgraph/templates/AGENTS.md.template +366 -0
  322. htmlgraph-0.27.5.data/data/htmlgraph/templates/CLAUDE.md.template +97 -0
  323. htmlgraph-0.27.5.data/data/htmlgraph/templates/GEMINI.md.template +87 -0
  324. {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +97 -64
  325. htmlgraph-0.27.5.dist-info/RECORD +337 -0
  326. {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
  327. htmlgraph/cli.py +0 -2688
  328. htmlgraph/sdk.py +0 -709
  329. htmlgraph-0.9.3.dist-info/RECORD +0 -61
  330. {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
  331. {htmlgraph-0.9.3.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"