htmlgraph 0.26.21__py3-none-any.whl → 0.26.23__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/__init__.py +6 -1
- htmlgraph/cli/analytics.py +465 -0
- htmlgraph/cli/core.py +97 -0
- htmlgraph/cli/main.py +3 -0
- htmlgraph/cli/models.py +12 -0
- htmlgraph/cli/work/__init__.py +5 -0
- htmlgraph/cli/work/report.py +727 -0
- htmlgraph/decorators.py +317 -0
- htmlgraph/hooks/subagent_stop.py +65 -6
- htmlgraph/operations/bootstrap.py +288 -0
- {htmlgraph-0.26.21.dist-info → htmlgraph-0.26.23.dist-info}/METADATA +1 -1
- {htmlgraph-0.26.21.dist-info → htmlgraph-0.26.23.dist-info}/RECORD +19 -16
- {htmlgraph-0.26.21.data → htmlgraph-0.26.23.data}/data/htmlgraph/dashboard.html +0 -0
- {htmlgraph-0.26.21.data → htmlgraph-0.26.23.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.26.21.data → htmlgraph-0.26.23.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.26.21.data → htmlgraph-0.26.23.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.26.21.data → htmlgraph-0.26.23.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.26.21.dist-info → htmlgraph-0.26.23.dist-info}/WHEEL +0 -0
- {htmlgraph-0.26.21.dist-info → htmlgraph-0.26.23.dist-info}/entry_points.txt +0 -0
htmlgraph/__init__.py
CHANGED
|
@@ -19,6 +19,7 @@ from htmlgraph.atomic_ops import (
|
|
|
19
19
|
from htmlgraph.builders import BaseBuilder, FeatureBuilder, SpikeBuilder
|
|
20
20
|
from htmlgraph.collections import BaseCollection, FeatureCollection, SpikeCollection
|
|
21
21
|
from htmlgraph.context_analytics import ContextAnalytics, ContextUsage
|
|
22
|
+
from htmlgraph.decorators import RetryError, retry, retry_async
|
|
22
23
|
from htmlgraph.edge_index import EdgeIndex, EdgeRef
|
|
23
24
|
from htmlgraph.exceptions import (
|
|
24
25
|
ClaimConflictError,
|
|
@@ -95,7 +96,7 @@ from htmlgraph.types import (
|
|
|
95
96
|
)
|
|
96
97
|
from htmlgraph.work_type_utils import infer_work_type, infer_work_type_from_id
|
|
97
98
|
|
|
98
|
-
__version__ = "0.26.
|
|
99
|
+
__version__ = "0.26.23"
|
|
99
100
|
__all__ = [
|
|
100
101
|
# Exceptions
|
|
101
102
|
"HtmlGraphError",
|
|
@@ -103,6 +104,10 @@ __all__ = [
|
|
|
103
104
|
"SessionNotFoundError",
|
|
104
105
|
"ClaimConflictError",
|
|
105
106
|
"ValidationError",
|
|
107
|
+
"RetryError",
|
|
108
|
+
# Decorators
|
|
109
|
+
"retry",
|
|
110
|
+
"retry_async",
|
|
106
111
|
# Core models
|
|
107
112
|
"Node",
|
|
108
113
|
"Edge",
|
htmlgraph/cli/analytics.py
CHANGED
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
import argparse
|
|
13
13
|
import json
|
|
14
14
|
import webbrowser
|
|
15
|
+
from datetime import datetime
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
from typing import TYPE_CHECKING
|
|
17
18
|
|
|
@@ -69,6 +70,9 @@ def register_commands(subparsers: _SubParsersAction) -> None:
|
|
|
69
70
|
# Sync docs command
|
|
70
71
|
_register_sync_docs_command(subparsers)
|
|
71
72
|
|
|
73
|
+
# Costs command
|
|
74
|
+
_register_costs_command(subparsers)
|
|
75
|
+
|
|
72
76
|
|
|
73
77
|
def _register_cigs_commands(subparsers: _SubParsersAction) -> None:
|
|
74
78
|
"""Register CIGS (Cost Intelligence & Governance System) commands."""
|
|
@@ -190,6 +194,48 @@ def _register_sync_docs_command(subparsers: _SubParsersAction) -> None:
|
|
|
190
194
|
sync_docs.set_defaults(func=SyncDocsCommand.from_args)
|
|
191
195
|
|
|
192
196
|
|
|
197
|
+
def _register_costs_command(subparsers: _SubParsersAction) -> None:
|
|
198
|
+
"""Register cost visibility and analysis command."""
|
|
199
|
+
costs_parser = subparsers.add_parser(
|
|
200
|
+
"costs",
|
|
201
|
+
help="View token cost breakdown and analytics",
|
|
202
|
+
)
|
|
203
|
+
costs_parser.add_argument(
|
|
204
|
+
"--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
|
|
205
|
+
)
|
|
206
|
+
costs_parser.add_argument(
|
|
207
|
+
"--period",
|
|
208
|
+
choices=["today", "day", "week", "month", "all"],
|
|
209
|
+
default="week",
|
|
210
|
+
help="Time period to analyze (default: week)",
|
|
211
|
+
)
|
|
212
|
+
costs_parser.add_argument(
|
|
213
|
+
"--by",
|
|
214
|
+
choices=["session", "feature", "tool", "agent"],
|
|
215
|
+
default="session",
|
|
216
|
+
help="Group costs by (default: session)",
|
|
217
|
+
)
|
|
218
|
+
costs_parser.add_argument(
|
|
219
|
+
"--format",
|
|
220
|
+
choices=["terminal", "csv"],
|
|
221
|
+
default="terminal",
|
|
222
|
+
help="Output format (default: terminal)",
|
|
223
|
+
)
|
|
224
|
+
costs_parser.add_argument(
|
|
225
|
+
"--model",
|
|
226
|
+
choices=["opus", "sonnet", "haiku", "auto"],
|
|
227
|
+
default="auto",
|
|
228
|
+
help="Claude model to assume for pricing (default: auto-detect)",
|
|
229
|
+
)
|
|
230
|
+
costs_parser.add_argument(
|
|
231
|
+
"--limit",
|
|
232
|
+
type=int,
|
|
233
|
+
default=10,
|
|
234
|
+
help="Maximum number of rows to display (default: 10)",
|
|
235
|
+
)
|
|
236
|
+
costs_parser.set_defaults(func=CostsCommand.from_args)
|
|
237
|
+
|
|
238
|
+
|
|
193
239
|
# ============================================================================
|
|
194
240
|
# Pydantic Models for Cost Analytics
|
|
195
241
|
# ============================================================================
|
|
@@ -956,3 +1002,422 @@ class SyncDocsCommand(BaseCommand):
|
|
|
956
1002
|
text="Synchronization complete",
|
|
957
1003
|
exit_code=1 if has_errors else 0,
|
|
958
1004
|
)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
# ============================================================================
|
|
1008
|
+
# Cost Command Implementation
|
|
1009
|
+
# ============================================================================
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
class CostsCommand(BaseCommand):
|
|
1013
|
+
"""View token cost breakdown and analytics by session, feature, or tool."""
|
|
1014
|
+
|
|
1015
|
+
def __init__(
|
|
1016
|
+
self,
|
|
1017
|
+
*,
|
|
1018
|
+
period: str,
|
|
1019
|
+
by: str,
|
|
1020
|
+
format: str,
|
|
1021
|
+
model: str,
|
|
1022
|
+
limit: int,
|
|
1023
|
+
) -> None:
|
|
1024
|
+
super().__init__()
|
|
1025
|
+
self.period = period
|
|
1026
|
+
self.by = by
|
|
1027
|
+
self.format = format
|
|
1028
|
+
self.model = model
|
|
1029
|
+
self.limit = limit
|
|
1030
|
+
|
|
1031
|
+
@classmethod
|
|
1032
|
+
def from_args(cls, args: argparse.Namespace) -> CostsCommand:
|
|
1033
|
+
return cls(
|
|
1034
|
+
period=getattr(args, "period", "week"),
|
|
1035
|
+
by=getattr(args, "by", "session"),
|
|
1036
|
+
format=getattr(args, "format", "terminal"),
|
|
1037
|
+
model=getattr(args, "model", "auto"),
|
|
1038
|
+
limit=getattr(args, "limit", 10),
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
def execute(self) -> CommandResult:
|
|
1042
|
+
"""Execute cost analysis and display results."""
|
|
1043
|
+
|
|
1044
|
+
if not self.graph_dir:
|
|
1045
|
+
raise CommandError("Graph directory not specified")
|
|
1046
|
+
|
|
1047
|
+
graph_dir = Path(self.graph_dir)
|
|
1048
|
+
db_path = graph_dir / "htmlgraph.db"
|
|
1049
|
+
|
|
1050
|
+
if not db_path.exists():
|
|
1051
|
+
console.print(
|
|
1052
|
+
"[yellow]No HtmlGraph database found. Run some work to generate cost data![/yellow]"
|
|
1053
|
+
)
|
|
1054
|
+
return CommandResult(text="No database", exit_code=1)
|
|
1055
|
+
|
|
1056
|
+
# Query costs from database
|
|
1057
|
+
with console.status("[blue]Analyzing costs...[/blue]", spinner="dots"):
|
|
1058
|
+
try:
|
|
1059
|
+
cost_data = self._query_costs(db_path)
|
|
1060
|
+
except Exception as e:
|
|
1061
|
+
raise CommandError(f"Failed to query costs: {e}")
|
|
1062
|
+
|
|
1063
|
+
if not cost_data:
|
|
1064
|
+
console.print(
|
|
1065
|
+
"[yellow]No cost data found for the specified period.[/yellow]"
|
|
1066
|
+
)
|
|
1067
|
+
return CommandResult(text="No cost data")
|
|
1068
|
+
|
|
1069
|
+
# Calculate USD costs based on model pricing
|
|
1070
|
+
cost_data = self._add_usd_costs(cost_data)
|
|
1071
|
+
|
|
1072
|
+
# Display results
|
|
1073
|
+
if self.format == "csv":
|
|
1074
|
+
self._display_csv(cost_data)
|
|
1075
|
+
else:
|
|
1076
|
+
self._display_terminal(cost_data)
|
|
1077
|
+
|
|
1078
|
+
# Display insights
|
|
1079
|
+
self._display_insights(cost_data)
|
|
1080
|
+
|
|
1081
|
+
return CommandResult(text="Cost analysis complete")
|
|
1082
|
+
|
|
1083
|
+
def _query_costs(self, db_path: Path) -> list[dict]:
|
|
1084
|
+
"""Query costs from the database based on period and grouping."""
|
|
1085
|
+
import sqlite3
|
|
1086
|
+
from datetime import datetime, timezone
|
|
1087
|
+
|
|
1088
|
+
conn = sqlite3.connect(str(db_path))
|
|
1089
|
+
conn.row_factory = sqlite3.Row
|
|
1090
|
+
cursor = conn.cursor()
|
|
1091
|
+
|
|
1092
|
+
# Calculate time filter
|
|
1093
|
+
now = datetime.now(timezone.utc)
|
|
1094
|
+
time_filter = self._get_time_filter(now)
|
|
1095
|
+
|
|
1096
|
+
# Build the query based on grouping
|
|
1097
|
+
if self.by == "session":
|
|
1098
|
+
query = """
|
|
1099
|
+
SELECT
|
|
1100
|
+
session_id as group_id,
|
|
1101
|
+
session_id as name,
|
|
1102
|
+
'session' as type,
|
|
1103
|
+
COUNT(*) as event_count,
|
|
1104
|
+
SUM(cost_tokens) as total_tokens,
|
|
1105
|
+
MIN(timestamp) as start_time,
|
|
1106
|
+
MAX(timestamp) as end_time
|
|
1107
|
+
FROM agent_events
|
|
1108
|
+
WHERE event_type IN ('tool_call', 'tool_result')
|
|
1109
|
+
AND cost_tokens > 0
|
|
1110
|
+
AND timestamp >= ?
|
|
1111
|
+
GROUP BY session_id
|
|
1112
|
+
ORDER BY total_tokens DESC
|
|
1113
|
+
LIMIT ?
|
|
1114
|
+
"""
|
|
1115
|
+
cursor.execute(query, (time_filter, self.limit))
|
|
1116
|
+
|
|
1117
|
+
elif self.by == "feature":
|
|
1118
|
+
query = """
|
|
1119
|
+
SELECT
|
|
1120
|
+
feature_id as group_id,
|
|
1121
|
+
COALESCE(feature_id, 'unlinked') as name,
|
|
1122
|
+
'feature' as type,
|
|
1123
|
+
COUNT(*) as event_count,
|
|
1124
|
+
SUM(cost_tokens) as total_tokens,
|
|
1125
|
+
MIN(timestamp) as start_time,
|
|
1126
|
+
MAX(timestamp) as end_time
|
|
1127
|
+
FROM agent_events
|
|
1128
|
+
WHERE event_type IN ('tool_call', 'tool_result')
|
|
1129
|
+
AND cost_tokens > 0
|
|
1130
|
+
AND timestamp >= ?
|
|
1131
|
+
GROUP BY feature_id
|
|
1132
|
+
ORDER BY total_tokens DESC
|
|
1133
|
+
LIMIT ?
|
|
1134
|
+
"""
|
|
1135
|
+
cursor.execute(query, (time_filter, self.limit))
|
|
1136
|
+
|
|
1137
|
+
elif self.by == "tool":
|
|
1138
|
+
query = """
|
|
1139
|
+
SELECT
|
|
1140
|
+
tool_name as group_id,
|
|
1141
|
+
tool_name as name,
|
|
1142
|
+
'tool' as type,
|
|
1143
|
+
COUNT(*) as event_count,
|
|
1144
|
+
SUM(cost_tokens) as total_tokens,
|
|
1145
|
+
MIN(timestamp) as start_time,
|
|
1146
|
+
MAX(timestamp) as end_time
|
|
1147
|
+
FROM agent_events
|
|
1148
|
+
WHERE event_type IN ('tool_call', 'tool_result')
|
|
1149
|
+
AND cost_tokens > 0
|
|
1150
|
+
AND timestamp >= ?
|
|
1151
|
+
GROUP BY tool_name
|
|
1152
|
+
ORDER BY total_tokens DESC
|
|
1153
|
+
LIMIT ?
|
|
1154
|
+
"""
|
|
1155
|
+
cursor.execute(query, (time_filter, self.limit))
|
|
1156
|
+
|
|
1157
|
+
elif self.by == "agent":
|
|
1158
|
+
query = """
|
|
1159
|
+
SELECT
|
|
1160
|
+
agent as group_id,
|
|
1161
|
+
agent as name,
|
|
1162
|
+
'agent' as type,
|
|
1163
|
+
COUNT(*) as event_count,
|
|
1164
|
+
SUM(cost_tokens) as total_tokens,
|
|
1165
|
+
MIN(timestamp) as start_time,
|
|
1166
|
+
MAX(timestamp) as end_time
|
|
1167
|
+
FROM agent_events
|
|
1168
|
+
WHERE event_type IN ('tool_call', 'tool_result')
|
|
1169
|
+
AND cost_tokens > 0
|
|
1170
|
+
AND timestamp >= ?
|
|
1171
|
+
GROUP BY agent
|
|
1172
|
+
ORDER BY total_tokens DESC
|
|
1173
|
+
LIMIT ?
|
|
1174
|
+
"""
|
|
1175
|
+
cursor.execute(query, (time_filter, self.limit))
|
|
1176
|
+
|
|
1177
|
+
results = []
|
|
1178
|
+
for row in cursor.fetchall():
|
|
1179
|
+
results.append(dict(row))
|
|
1180
|
+
|
|
1181
|
+
conn.close()
|
|
1182
|
+
return results
|
|
1183
|
+
|
|
1184
|
+
def _get_time_filter(self, now: datetime) -> str:
|
|
1185
|
+
"""Get ISO format timestamp for time filtering."""
|
|
1186
|
+
from datetime import timedelta
|
|
1187
|
+
|
|
1188
|
+
if self.period == "today":
|
|
1189
|
+
delta = timedelta(hours=24)
|
|
1190
|
+
elif self.period == "day":
|
|
1191
|
+
delta = timedelta(days=1)
|
|
1192
|
+
elif self.period == "week":
|
|
1193
|
+
delta = timedelta(days=7)
|
|
1194
|
+
elif self.period == "month":
|
|
1195
|
+
delta = timedelta(days=30)
|
|
1196
|
+
else: # "all"
|
|
1197
|
+
delta = timedelta(days=36500) # ~100 years
|
|
1198
|
+
|
|
1199
|
+
cutoff = now - delta
|
|
1200
|
+
return cutoff.isoformat()
|
|
1201
|
+
|
|
1202
|
+
def _add_usd_costs(self, cost_data: list[dict]) -> list[dict]:
|
|
1203
|
+
"""Add USD cost estimates to cost data."""
|
|
1204
|
+
for item in cost_data:
|
|
1205
|
+
item["cost_usd"] = self._calculate_usd(item["total_tokens"])
|
|
1206
|
+
return cost_data
|
|
1207
|
+
|
|
1208
|
+
def _calculate_usd(self, tokens: int) -> float:
|
|
1209
|
+
"""Calculate USD cost from tokens based on model pricing."""
|
|
1210
|
+
# Claude pricing (per 1M tokens):
|
|
1211
|
+
# Opus: $15 input, $45 output
|
|
1212
|
+
# Sonnet: $3 input, $15 output
|
|
1213
|
+
# Haiku: $0.80 input, $4 output
|
|
1214
|
+
|
|
1215
|
+
# Assume ~90% input, 10% output ratio
|
|
1216
|
+
input_ratio = 0.9
|
|
1217
|
+
output_ratio = 0.1
|
|
1218
|
+
|
|
1219
|
+
if self.model == "opus" or (self.model == "auto"):
|
|
1220
|
+
# Default to Opus for conservative estimate
|
|
1221
|
+
input_cost = 15 / 1_000_000
|
|
1222
|
+
output_cost = 45 / 1_000_000
|
|
1223
|
+
elif self.model == "sonnet":
|
|
1224
|
+
input_cost = 3 / 1_000_000
|
|
1225
|
+
output_cost = 15 / 1_000_000
|
|
1226
|
+
elif self.model == "haiku":
|
|
1227
|
+
input_cost = 0.80 / 1_000_000
|
|
1228
|
+
output_cost = 4 / 1_000_000
|
|
1229
|
+
else:
|
|
1230
|
+
# Fallback to Opus
|
|
1231
|
+
input_cost = 15 / 1_000_000
|
|
1232
|
+
output_cost = 45 / 1_000_000
|
|
1233
|
+
|
|
1234
|
+
cost = (tokens * input_ratio * input_cost) + (
|
|
1235
|
+
tokens * output_ratio * output_cost
|
|
1236
|
+
)
|
|
1237
|
+
return cost
|
|
1238
|
+
|
|
1239
|
+
def _display_terminal(self, cost_data: list[dict]) -> None:
|
|
1240
|
+
"""Display costs in terminal with rich formatting."""
|
|
1241
|
+
from htmlgraph.cli.base import TableBuilder
|
|
1242
|
+
|
|
1243
|
+
# Period label
|
|
1244
|
+
period_label = self.period.upper()
|
|
1245
|
+
if self.period == "today":
|
|
1246
|
+
period_label = "TODAY"
|
|
1247
|
+
elif self.period == "day":
|
|
1248
|
+
period_label = "LAST 24 HOURS"
|
|
1249
|
+
elif self.period == "week":
|
|
1250
|
+
period_label = "LAST 7 DAYS"
|
|
1251
|
+
elif self.period == "month":
|
|
1252
|
+
period_label = "LAST 30 DAYS"
|
|
1253
|
+
|
|
1254
|
+
console.print(f"\n[bold cyan]{period_label} - COST SUMMARY[/bold cyan]")
|
|
1255
|
+
console.print("[dim]═" * 60 + "[/dim]\n")
|
|
1256
|
+
|
|
1257
|
+
# Build table
|
|
1258
|
+
table_builder = TableBuilder.create_list_table(title=None)
|
|
1259
|
+
table_builder.add_column("Name", style="cyan")
|
|
1260
|
+
table_builder.add_numeric_column("Events", style="green")
|
|
1261
|
+
table_builder.add_numeric_column("Tokens", style="yellow")
|
|
1262
|
+
table_builder.add_numeric_column("Estimated Cost", style="magenta")
|
|
1263
|
+
|
|
1264
|
+
total_tokens = 0
|
|
1265
|
+
total_usd = 0.0
|
|
1266
|
+
|
|
1267
|
+
for item in cost_data:
|
|
1268
|
+
name = item["name"] or "(unlinked)"
|
|
1269
|
+
if len(name) > 30:
|
|
1270
|
+
name = name[:27] + "..."
|
|
1271
|
+
|
|
1272
|
+
events = f"{item['event_count']:,}"
|
|
1273
|
+
tokens = f"{item['total_tokens']:,}"
|
|
1274
|
+
cost_str = f"${item['cost_usd']:.2f}"
|
|
1275
|
+
|
|
1276
|
+
table_builder.add_row(name, events, tokens, cost_str)
|
|
1277
|
+
|
|
1278
|
+
total_tokens += item["total_tokens"]
|
|
1279
|
+
total_usd += item["cost_usd"]
|
|
1280
|
+
|
|
1281
|
+
console.print(table_builder.table)
|
|
1282
|
+
|
|
1283
|
+
# Summary
|
|
1284
|
+
console.print("\n[dim]─" * 60 + "[/dim]")
|
|
1285
|
+
console.print(
|
|
1286
|
+
f"[bold]Total Tokens:[/bold] {total_tokens:,} [dim]({self._format_duration(cost_data)})[/dim]"
|
|
1287
|
+
)
|
|
1288
|
+
console.print(
|
|
1289
|
+
f"[bold]Estimated Cost:[/bold] ${total_usd:.2f} ({self.model.upper() if self.model != 'auto' else 'Opus'})"
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
# Insights
|
|
1293
|
+
if len(cost_data) > 0:
|
|
1294
|
+
top_item = cost_data[0]
|
|
1295
|
+
pct = (
|
|
1296
|
+
(top_item["total_tokens"] / total_tokens * 100)
|
|
1297
|
+
if total_tokens > 0
|
|
1298
|
+
else 0
|
|
1299
|
+
)
|
|
1300
|
+
console.print(
|
|
1301
|
+
f"\n[dim]Most expensive:[/dim] [yellow]{top_item['name']}[/yellow] "
|
|
1302
|
+
f"[dim]({pct:.0f}% of total)[/dim]"
|
|
1303
|
+
)
|
|
1304
|
+
|
|
1305
|
+
def _display_csv(self, cost_data: list[dict]) -> None:
|
|
1306
|
+
"""Display costs in CSV format for spreadsheet analysis."""
|
|
1307
|
+
import csv
|
|
1308
|
+
import io
|
|
1309
|
+
|
|
1310
|
+
output = io.StringIO()
|
|
1311
|
+
writer = csv.writer(output)
|
|
1312
|
+
|
|
1313
|
+
# Header
|
|
1314
|
+
if self.by == "session":
|
|
1315
|
+
writer.writerow(["Session ID", "Events", "Tokens", "Estimated Cost (USD)"])
|
|
1316
|
+
else:
|
|
1317
|
+
writer.writerow(
|
|
1318
|
+
[
|
|
1319
|
+
self.by.capitalize(),
|
|
1320
|
+
"Events",
|
|
1321
|
+
"Tokens",
|
|
1322
|
+
"Estimated Cost (USD)",
|
|
1323
|
+
]
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
# Data rows
|
|
1327
|
+
for item in cost_data:
|
|
1328
|
+
writer.writerow(
|
|
1329
|
+
[
|
|
1330
|
+
item["name"],
|
|
1331
|
+
item["event_count"],
|
|
1332
|
+
item["total_tokens"],
|
|
1333
|
+
f"{item['cost_usd']:.2f}",
|
|
1334
|
+
]
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
# Totals
|
|
1338
|
+
total_tokens = sum(item["total_tokens"] for item in cost_data)
|
|
1339
|
+
total_usd = sum(item["cost_usd"] for item in cost_data)
|
|
1340
|
+
writer.writerow(["TOTAL", "", total_tokens, f"{total_usd:.2f}"])
|
|
1341
|
+
|
|
1342
|
+
csv_content = output.getvalue()
|
|
1343
|
+
console.print(csv_content)
|
|
1344
|
+
|
|
1345
|
+
def _display_insights(self, cost_data: list[dict]) -> None:
|
|
1346
|
+
"""Display cost optimization insights."""
|
|
1347
|
+
if not cost_data:
|
|
1348
|
+
return
|
|
1349
|
+
|
|
1350
|
+
console.print("\n[bold cyan]Insights & Recommendations[/bold cyan]")
|
|
1351
|
+
console.print("[dim]─" * 60 + "[/dim]\n")
|
|
1352
|
+
|
|
1353
|
+
total_tokens = sum(item["total_tokens"] for item in cost_data)
|
|
1354
|
+
|
|
1355
|
+
# Insight 1: Top cost driver
|
|
1356
|
+
top_item = cost_data[0]
|
|
1357
|
+
top_pct = (
|
|
1358
|
+
(top_item["total_tokens"] / total_tokens * 100) if total_tokens > 0 else 0
|
|
1359
|
+
)
|
|
1360
|
+
console.print(
|
|
1361
|
+
f"[blue]→ Highest cost:[/blue] {top_item['name']} "
|
|
1362
|
+
f"[yellow]({top_pct:.0f}% of total)[/yellow]"
|
|
1363
|
+
)
|
|
1364
|
+
|
|
1365
|
+
# Insight 2: Concentration
|
|
1366
|
+
if len(cost_data) > 1:
|
|
1367
|
+
top_3_pct = (
|
|
1368
|
+
sum(item["total_tokens"] for item in cost_data[:3])
|
|
1369
|
+
/ (total_tokens if total_tokens > 0 else 1)
|
|
1370
|
+
* 100
|
|
1371
|
+
)
|
|
1372
|
+
console.print(
|
|
1373
|
+
f"[blue]→ Cost concentration:[/blue] Top 3 account for [yellow]{top_3_pct:.0f}%[/yellow]"
|
|
1374
|
+
)
|
|
1375
|
+
|
|
1376
|
+
# Insight 3: Recommendations
|
|
1377
|
+
if self.by == "tool" and top_item["name"] in ["Read", "Bash", "Grep"]:
|
|
1378
|
+
console.print(
|
|
1379
|
+
f"[yellow]→ Tip:[/yellow] {top_item['name']} is expensive. Consider batching operations "
|
|
1380
|
+
"or using more efficient approaches."
|
|
1381
|
+
)
|
|
1382
|
+
elif self.by == "session" and len(cost_data) > 5:
|
|
1383
|
+
console.print(
|
|
1384
|
+
"[yellow]→ Tip:[/yellow] Many sessions with costs. Consider consolidating work "
|
|
1385
|
+
"to fewer, focused sessions."
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
console.print()
|
|
1389
|
+
|
|
1390
|
+
def _format_duration(self, cost_data: list[dict]) -> str:
|
|
1391
|
+
"""Format duration from start/end times."""
|
|
1392
|
+
if not cost_data or "start_time" not in cost_data[0]:
|
|
1393
|
+
return "unknown"
|
|
1394
|
+
|
|
1395
|
+
try:
|
|
1396
|
+
from datetime import datetime
|
|
1397
|
+
|
|
1398
|
+
start_times = [
|
|
1399
|
+
datetime.fromisoformat(item["start_time"])
|
|
1400
|
+
for item in cost_data
|
|
1401
|
+
if item.get("start_time")
|
|
1402
|
+
]
|
|
1403
|
+
end_times = [
|
|
1404
|
+
datetime.fromisoformat(item["end_time"])
|
|
1405
|
+
for item in cost_data
|
|
1406
|
+
if item.get("end_time")
|
|
1407
|
+
]
|
|
1408
|
+
|
|
1409
|
+
if not start_times or not end_times:
|
|
1410
|
+
return "unknown"
|
|
1411
|
+
|
|
1412
|
+
earliest = min(start_times)
|
|
1413
|
+
latest = max(end_times)
|
|
1414
|
+
duration = latest - earliest
|
|
1415
|
+
|
|
1416
|
+
hours = duration.total_seconds() / 3600
|
|
1417
|
+
if hours > 1:
|
|
1418
|
+
return f"{hours:.1f}h"
|
|
1419
|
+
else:
|
|
1420
|
+
minutes = duration.total_seconds() / 60
|
|
1421
|
+
return f"{minutes:.0f}m"
|
|
1422
|
+
except Exception:
|
|
1423
|
+
return "unknown"
|
htmlgraph/cli/core.py
CHANGED
|
@@ -37,6 +37,22 @@ def register_commands(subparsers: _SubParsersAction) -> None:
|
|
|
37
37
|
Args:
|
|
38
38
|
subparsers: Subparser action from ArgumentParser.add_subparsers()
|
|
39
39
|
"""
|
|
40
|
+
# bootstrap
|
|
41
|
+
bootstrap_parser = subparsers.add_parser(
|
|
42
|
+
"bootstrap", help="One-command setup: Initialize HtmlGraph in under 60 seconds"
|
|
43
|
+
)
|
|
44
|
+
bootstrap_parser.add_argument(
|
|
45
|
+
"--project-path",
|
|
46
|
+
default=".",
|
|
47
|
+
help="Directory to bootstrap (default: current directory)",
|
|
48
|
+
)
|
|
49
|
+
bootstrap_parser.add_argument(
|
|
50
|
+
"--no-plugins",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="Skip plugin installation",
|
|
53
|
+
)
|
|
54
|
+
bootstrap_parser.set_defaults(func=BootstrapCommand.from_args)
|
|
55
|
+
|
|
40
56
|
# serve
|
|
41
57
|
serve_parser = subparsers.add_parser("serve", help="Start the HtmlGraph server")
|
|
42
58
|
serve_parser.add_argument(
|
|
@@ -854,3 +870,84 @@ class InstallHooksCommand(BaseCommand):
|
|
|
854
870
|
},
|
|
855
871
|
},
|
|
856
872
|
)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
class BootstrapCommand(BaseCommand):
|
|
876
|
+
"""Bootstrap HtmlGraph in under 60 seconds."""
|
|
877
|
+
|
|
878
|
+
def __init__(self, *, project_path: str, no_plugins: bool) -> None:
|
|
879
|
+
super().__init__()
|
|
880
|
+
self.project_path = project_path
|
|
881
|
+
self.no_plugins = no_plugins
|
|
882
|
+
|
|
883
|
+
@classmethod
|
|
884
|
+
def from_args(cls, args: argparse.Namespace) -> BootstrapCommand:
|
|
885
|
+
return cls(
|
|
886
|
+
project_path=args.project_path,
|
|
887
|
+
no_plugins=args.no_plugins,
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
def execute(self) -> CommandResult:
|
|
891
|
+
"""Bootstrap HtmlGraph setup."""
|
|
892
|
+
from rich.console import Console
|
|
893
|
+
from rich.panel import Panel
|
|
894
|
+
|
|
895
|
+
from htmlgraph.cli.models import BootstrapConfig
|
|
896
|
+
from htmlgraph.operations.bootstrap import bootstrap_htmlgraph
|
|
897
|
+
|
|
898
|
+
console = Console()
|
|
899
|
+
|
|
900
|
+
# Create config
|
|
901
|
+
config = BootstrapConfig(
|
|
902
|
+
project_path=self.project_path,
|
|
903
|
+
no_plugins=self.no_plugins,
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
# Run bootstrap
|
|
907
|
+
console.print()
|
|
908
|
+
console.print("[bold cyan]Bootstrapping HtmlGraph...[/bold cyan]")
|
|
909
|
+
console.print()
|
|
910
|
+
|
|
911
|
+
result = bootstrap_htmlgraph(config)
|
|
912
|
+
|
|
913
|
+
if not result["success"]:
|
|
914
|
+
raise CommandError(result.get("message", "Bootstrap failed"))
|
|
915
|
+
|
|
916
|
+
# Display success message
|
|
917
|
+
console.print()
|
|
918
|
+
console.print(
|
|
919
|
+
Panel.fit(
|
|
920
|
+
"[bold green]✓ HtmlGraph initialized successfully![/bold green]",
|
|
921
|
+
border_style="green",
|
|
922
|
+
)
|
|
923
|
+
)
|
|
924
|
+
console.print()
|
|
925
|
+
|
|
926
|
+
# Show project info
|
|
927
|
+
console.print(f"[cyan]Project type:[/cyan] {result['project_type']}")
|
|
928
|
+
console.print(f"[cyan]Location:[/cyan] {result['graph_dir']}")
|
|
929
|
+
console.print()
|
|
930
|
+
|
|
931
|
+
# Show next steps
|
|
932
|
+
console.print("[bold yellow]Next steps:[/bold yellow]")
|
|
933
|
+
for step in result["next_steps"]:
|
|
934
|
+
console.print(f" {step}")
|
|
935
|
+
console.print()
|
|
936
|
+
|
|
937
|
+
# Show documentation link
|
|
938
|
+
console.print(
|
|
939
|
+
"[dim]📚 Learn more: https://github.com/Shakes-tzd/htmlgraph[/dim]"
|
|
940
|
+
)
|
|
941
|
+
console.print()
|
|
942
|
+
|
|
943
|
+
return CommandResult(
|
|
944
|
+
text="Bootstrap completed successfully",
|
|
945
|
+
json_data={
|
|
946
|
+
"project_type": result["project_type"],
|
|
947
|
+
"graph_dir": result["graph_dir"],
|
|
948
|
+
"directories_created": len(result["directories_created"]),
|
|
949
|
+
"files_created": len(result["files_created"]),
|
|
950
|
+
"has_claude": result["has_claude"],
|
|
951
|
+
"plugin_installed": result["plugin_installed"],
|
|
952
|
+
},
|
|
953
|
+
)
|
htmlgraph/cli/main.py
CHANGED
|
@@ -28,6 +28,9 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
28
28
|
description="HtmlGraph - HTML is All You Need",
|
|
29
29
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
30
30
|
epilog="""
|
|
31
|
+
Quick Start:
|
|
32
|
+
htmlgraph bootstrap # One-command setup (< 60 seconds)
|
|
33
|
+
|
|
31
34
|
Examples:
|
|
32
35
|
htmlgraph init # Initialize .htmlgraph in current dir
|
|
33
36
|
htmlgraph serve # Start server on port 8080
|
htmlgraph/cli/models.py
CHANGED
|
@@ -460,3 +460,15 @@ class TrackDisplay(BaseModel):
|
|
|
460
460
|
priority_order = {"high": 0, "medium": 1, "low": 2}
|
|
461
461
|
priority_rank = priority_order.get(self.priority, 99)
|
|
462
462
|
return (priority_rank, self.id)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class BootstrapConfig(BaseModel):
|
|
466
|
+
"""Configuration for htmlgraph bootstrap command.
|
|
467
|
+
|
|
468
|
+
Attributes:
|
|
469
|
+
project_path: Directory to bootstrap (default: .)
|
|
470
|
+
no_plugins: Skip plugin installation
|
|
471
|
+
"""
|
|
472
|
+
|
|
473
|
+
project_path: str = Field(default=".")
|
|
474
|
+
no_plugins: bool = Field(default=False)
|
htmlgraph/cli/work/__init__.py
CHANGED
|
@@ -29,6 +29,7 @@ def register_commands(subparsers: _SubParsersAction) -> None:
|
|
|
29
29
|
register_claude_commands,
|
|
30
30
|
register_orchestrator_commands,
|
|
31
31
|
)
|
|
32
|
+
from htmlgraph.cli.work.report import register_report_commands
|
|
32
33
|
from htmlgraph.cli.work.sessions import register_session_commands
|
|
33
34
|
from htmlgraph.cli.work.tracks import register_track_commands
|
|
34
35
|
|
|
@@ -39,6 +40,7 @@ def register_commands(subparsers: _SubParsersAction) -> None:
|
|
|
39
40
|
register_archive_commands(subparsers)
|
|
40
41
|
register_orchestrator_commands(subparsers)
|
|
41
42
|
register_claude_commands(subparsers)
|
|
43
|
+
register_report_commands(subparsers)
|
|
42
44
|
|
|
43
45
|
|
|
44
46
|
# Re-export all command classes for backward compatibility
|
|
@@ -61,6 +63,7 @@ from htmlgraph.cli.work.orchestration import (
|
|
|
61
63
|
OrchestratorSetLevelCommand,
|
|
62
64
|
OrchestratorStatusCommand,
|
|
63
65
|
)
|
|
66
|
+
from htmlgraph.cli.work.report import SessionReportCommand
|
|
64
67
|
from htmlgraph.cli.work.sessions import (
|
|
65
68
|
SessionEndCommand,
|
|
66
69
|
SessionHandoffCommand,
|
|
@@ -84,6 +87,8 @@ __all__ = [
|
|
|
84
87
|
"SessionListCommand",
|
|
85
88
|
"SessionHandoffCommand",
|
|
86
89
|
"SessionStartInfoCommand",
|
|
90
|
+
# Report commands
|
|
91
|
+
"SessionReportCommand",
|
|
87
92
|
# Feature commands
|
|
88
93
|
"FeatureListCommand",
|
|
89
94
|
"FeatureCreateCommand",
|