cli-consumption 0.0.1__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.
- cli_consumption/__init__.py +10 -0
- cli_consumption/__main__.py +4 -0
- cli_consumption/adapters/__init__.py +3 -0
- cli_consumption/adapters/base.py +18 -0
- cli_consumption/adapters/codex.py +591 -0
- cli_consumption/api.py +66 -0
- cli_consumption/cli.py +245 -0
- cli_consumption/dashboard.py +431 -0
- cli_consumption/exporting.py +31 -0
- cli_consumption/models.py +60 -0
- cli_consumption/py.typed +0 -0
- cli_consumption/storage.py +525 -0
- cli_consumption/sync.py +25 -0
- cli_consumption-0.0.1.dist-info/METADATA +192 -0
- cli_consumption-0.0.1.dist-info/RECORD +19 -0
- cli_consumption-0.0.1.dist-info/WHEEL +4 -0
- cli_consumption-0.0.1.dist-info/entry_points.txt +2 -0
- cli_consumption-0.0.1.dist-info/licenses/LICENSE +201 -0
- cli_consumption-0.0.1.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sqlalchemy.engine import Engine
|
|
10
|
+
|
|
11
|
+
from cli_consumption.storage import read_table
|
|
12
|
+
|
|
13
|
+
TOKEN_FIELDS = (
|
|
14
|
+
"input_tokens",
|
|
15
|
+
"cached_input_tokens",
|
|
16
|
+
"cache_write_input_tokens",
|
|
17
|
+
"uncached_input_tokens",
|
|
18
|
+
"output_tokens",
|
|
19
|
+
"reasoning_output_tokens",
|
|
20
|
+
"visible_output_tokens",
|
|
21
|
+
"unattributed_tokens",
|
|
22
|
+
"total_tokens",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def generate_dashboard(
|
|
27
|
+
engine: Engine, output: Path, *, share_safe: bool = False
|
|
28
|
+
) -> None:
|
|
29
|
+
encoded = json.dumps(
|
|
30
|
+
_dashboard_payload(engine, share_safe=share_safe),
|
|
31
|
+
separators=(",", ":"),
|
|
32
|
+
ensure_ascii=True,
|
|
33
|
+
)
|
|
34
|
+
encoded = (
|
|
35
|
+
encoded.replace("&", "\\u0026").replace("<", "\\u003c").replace(">", "\\u003e")
|
|
36
|
+
)
|
|
37
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
output.write_text(_document(encoded), encoding="utf-8")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _dashboard_payload(engine: Engine, *, share_safe: bool = False) -> dict[str, Any]:
|
|
42
|
+
"""Return only metadata needed by the dashboard, with local relationship keys."""
|
|
43
|
+
conversations = read_table(engine, "conversations")
|
|
44
|
+
turns = read_table(engine, "turns")
|
|
45
|
+
model_calls = read_table(engine, "model_calls")
|
|
46
|
+
tool_calls = read_table(engine, "tool_calls")
|
|
47
|
+
work_items = read_table(engine, "work_items")
|
|
48
|
+
context_samples = read_table(engine, "context_samples")
|
|
49
|
+
turn_settings = read_table(engine, "turn_settings")
|
|
50
|
+
compaction_events = read_table(engine, "compaction_events")
|
|
51
|
+
subagents = read_table(engine, "subagents")
|
|
52
|
+
ingestion_runs = read_table(engine, "ingestion_runs")
|
|
53
|
+
|
|
54
|
+
conversation_keys = {
|
|
55
|
+
str(row["id"]): index for index, row in enumerate(conversations)
|
|
56
|
+
}
|
|
57
|
+
external_keys = {
|
|
58
|
+
(
|
|
59
|
+
str(row["provider"]),
|
|
60
|
+
str(row["source_machine"]),
|
|
61
|
+
str(row["external_id"]),
|
|
62
|
+
): index
|
|
63
|
+
for index, row in enumerate(conversations)
|
|
64
|
+
}
|
|
65
|
+
turn_keys = {str(row["id"]): index for index, row in enumerate(turns)}
|
|
66
|
+
projects = _aliases(
|
|
67
|
+
(str(row["project"]) for row in conversations), "project", share_safe
|
|
68
|
+
)
|
|
69
|
+
machines = _aliases(
|
|
70
|
+
(str(row["source_machine"]) for row in conversations),
|
|
71
|
+
"machine",
|
|
72
|
+
share_safe,
|
|
73
|
+
)
|
|
74
|
+
model_names = {
|
|
75
|
+
*(
|
|
76
|
+
str(model)
|
|
77
|
+
for row in conversations
|
|
78
|
+
for model in json.loads(row["models_json"])
|
|
79
|
+
),
|
|
80
|
+
*(str(row["model"]) for row in model_calls),
|
|
81
|
+
*(str(row["model"]) for row in turn_settings if row["model"]),
|
|
82
|
+
}
|
|
83
|
+
models = _aliases(model_names, "model", share_safe)
|
|
84
|
+
roles = _aliases(
|
|
85
|
+
(str(row["agent_role"] or "unspecified") for row in subagents),
|
|
86
|
+
"role",
|
|
87
|
+
share_safe,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def timestamp(value: Any) -> Any:
|
|
91
|
+
return _round_timestamp(value) if share_safe else value
|
|
92
|
+
|
|
93
|
+
def epoch(value: Any) -> Any:
|
|
94
|
+
return _round_epoch_day(value) if share_safe else value
|
|
95
|
+
|
|
96
|
+
def tool(value: Any) -> Any:
|
|
97
|
+
if not share_safe or value is None:
|
|
98
|
+
return value
|
|
99
|
+
return _tool_category(str(value))
|
|
100
|
+
|
|
101
|
+
def tokens(row: dict[str, Any]) -> dict[str, int]:
|
|
102
|
+
return {field: int(row[field]) for field in TOKEN_FIELDS}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"meta": {"shareSafe": share_safe},
|
|
106
|
+
"conversations": [
|
|
107
|
+
{
|
|
108
|
+
"key": index,
|
|
109
|
+
"provider": row["provider"],
|
|
110
|
+
"machine": machines[str(row["source_machine"])],
|
|
111
|
+
"project": projects[str(row["project"])],
|
|
112
|
+
"startedAt": timestamp(row["started_at"]),
|
|
113
|
+
"endedAt": timestamp(row["ended_at"]),
|
|
114
|
+
"durationSeconds": row["duration_seconds"],
|
|
115
|
+
"models": [
|
|
116
|
+
models[str(model)] for model in json.loads(row["models_json"])
|
|
117
|
+
],
|
|
118
|
+
"turns": row["iterations"],
|
|
119
|
+
"modelCalls": row["model_calls"],
|
|
120
|
+
"toolCalls": row["tool_calls"],
|
|
121
|
+
"compactions": row["compactions"],
|
|
122
|
+
**tokens(row),
|
|
123
|
+
}
|
|
124
|
+
for index, row in enumerate(conversations)
|
|
125
|
+
],
|
|
126
|
+
"turns": [
|
|
127
|
+
{
|
|
128
|
+
"key": index,
|
|
129
|
+
"conversationKey": conversation_keys[str(row["conversation_id"])],
|
|
130
|
+
"startedAt": timestamp(row["started_at"]),
|
|
131
|
+
"endedAt": timestamp(row["ended_at"]),
|
|
132
|
+
"status": row["status"],
|
|
133
|
+
"durationMs": row["duration_ms"],
|
|
134
|
+
"ttftMs": row["time_to_first_token_ms"],
|
|
135
|
+
"modelCalls": row["model_calls"],
|
|
136
|
+
"toolCalls": row["tool_calls"],
|
|
137
|
+
**tokens(row),
|
|
138
|
+
}
|
|
139
|
+
for index, row in enumerate(turns)
|
|
140
|
+
],
|
|
141
|
+
"modelCalls": [
|
|
142
|
+
{
|
|
143
|
+
"conversationKey": conversation_keys[str(row["conversation_id"])],
|
|
144
|
+
"turnKey": turn_keys.get(str(row["turn_id"])),
|
|
145
|
+
"timestamp": timestamp(row["timestamp"]),
|
|
146
|
+
"model": models[str(row["model"])],
|
|
147
|
+
**tokens(row),
|
|
148
|
+
}
|
|
149
|
+
for row in model_calls
|
|
150
|
+
],
|
|
151
|
+
"toolCalls": [
|
|
152
|
+
{
|
|
153
|
+
"conversationKey": conversation_keys[str(row["conversation_id"])],
|
|
154
|
+
"turnKey": turn_keys.get(str(row["turn_id"])),
|
|
155
|
+
"sequence": row["sequence"],
|
|
156
|
+
"timestamp": timestamp(row["timestamp"]),
|
|
157
|
+
"tool": tool(row["tool_name"]),
|
|
158
|
+
}
|
|
159
|
+
for row in tool_calls
|
|
160
|
+
],
|
|
161
|
+
"workItems": [
|
|
162
|
+
{
|
|
163
|
+
"conversationKey": conversation_keys[str(row["conversation_id"])],
|
|
164
|
+
"turnKey": turn_keys.get(str(row["turn_id"])),
|
|
165
|
+
"kind": row["kind"],
|
|
166
|
+
"tool": tool(row["tool_name"]),
|
|
167
|
+
"startedAtMs": epoch(row["started_at_ms"]),
|
|
168
|
+
"durationMs": row["duration_ms"],
|
|
169
|
+
"status": row["status"],
|
|
170
|
+
}
|
|
171
|
+
for row in work_items
|
|
172
|
+
],
|
|
173
|
+
"contextSamples": [
|
|
174
|
+
{
|
|
175
|
+
"conversationKey": conversation_keys[str(row["conversation_id"])],
|
|
176
|
+
"turnKey": turn_keys.get(str(row["turn_id"])),
|
|
177
|
+
"timestamp": timestamp(row["timestamp"]),
|
|
178
|
+
"inputTokens": row["input_tokens"],
|
|
179
|
+
"contextWindowTokens": row["context_window_tokens"],
|
|
180
|
+
}
|
|
181
|
+
for row in context_samples
|
|
182
|
+
],
|
|
183
|
+
"turnSettings": [
|
|
184
|
+
{
|
|
185
|
+
"conversationKey": conversation_keys[str(row["conversation_id"])],
|
|
186
|
+
"turnKey": turn_keys.get(str(row["turn_id"])),
|
|
187
|
+
"model": models[str(row["model"])] if row["model"] else None,
|
|
188
|
+
"effort": row["effort"],
|
|
189
|
+
"mode": row["collaboration_mode"],
|
|
190
|
+
"tier": row["service_tier"],
|
|
191
|
+
"contextWindowTokens": row["context_window_tokens"],
|
|
192
|
+
}
|
|
193
|
+
for row in turn_settings
|
|
194
|
+
],
|
|
195
|
+
"compactions": [
|
|
196
|
+
{
|
|
197
|
+
"conversationKey": conversation_keys[str(row["conversation_id"])],
|
|
198
|
+
"turnKey": turn_keys.get(str(row["turn_id"])),
|
|
199
|
+
"timestamp": timestamp(row["timestamp"]),
|
|
200
|
+
}
|
|
201
|
+
for row in compaction_events
|
|
202
|
+
],
|
|
203
|
+
"subagents": [
|
|
204
|
+
{
|
|
205
|
+
"conversationKey": external_keys.get(
|
|
206
|
+
(
|
|
207
|
+
str(row["provider"]),
|
|
208
|
+
str(row["source_machine"]),
|
|
209
|
+
str(row["parent_thread_id"]),
|
|
210
|
+
)
|
|
211
|
+
),
|
|
212
|
+
"childConversationKey": external_keys.get(
|
|
213
|
+
(
|
|
214
|
+
str(row["provider"]),
|
|
215
|
+
str(row["source_machine"]),
|
|
216
|
+
str(row["child_thread_id"]),
|
|
217
|
+
)
|
|
218
|
+
),
|
|
219
|
+
"provider": row["provider"],
|
|
220
|
+
"machine": machines.get(
|
|
221
|
+
str(row["source_machine"]),
|
|
222
|
+
"machine-unmapped" if share_safe else row["source_machine"],
|
|
223
|
+
),
|
|
224
|
+
"status": row["status"],
|
|
225
|
+
"createdAtMs": epoch(row["created_at_ms"]),
|
|
226
|
+
"updatedAtMs": epoch(row["updated_at_ms"]),
|
|
227
|
+
"role": roles[str(row["agent_role"] or "unspecified")],
|
|
228
|
+
"tokens": row["tokens_used"],
|
|
229
|
+
}
|
|
230
|
+
for row in subagents
|
|
231
|
+
],
|
|
232
|
+
"ingestionRuns": [
|
|
233
|
+
{
|
|
234
|
+
"provider": row["provider"],
|
|
235
|
+
"ingestedAt": timestamp(row["ingested_at"]),
|
|
236
|
+
"received": row["conversations_received"],
|
|
237
|
+
"written": row["conversations_written"],
|
|
238
|
+
"skipped": row["conversations_skipped"],
|
|
239
|
+
"malformed": row["malformed_records"],
|
|
240
|
+
"duplicates": row["duplicate_conversations"],
|
|
241
|
+
}
|
|
242
|
+
for row in ingestion_runs
|
|
243
|
+
],
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _aliases(values: Any, prefix: str, enabled: bool) -> dict[str, str]:
|
|
248
|
+
unique = sorted(set(values))
|
|
249
|
+
if not enabled:
|
|
250
|
+
return {value: value for value in unique}
|
|
251
|
+
return {value: f"{prefix}-{index}" for index, value in enumerate(unique, 1)}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _round_timestamp(value: Any) -> Any:
|
|
255
|
+
if not isinstance(value, str):
|
|
256
|
+
return None
|
|
257
|
+
try:
|
|
258
|
+
instant = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
259
|
+
except ValueError:
|
|
260
|
+
return None
|
|
261
|
+
return f"{instant.date().isoformat()}T00:00:00+00:00"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _round_epoch_day(value: Any) -> Any:
|
|
265
|
+
if (
|
|
266
|
+
not isinstance(value, int | float)
|
|
267
|
+
or isinstance(value, bool)
|
|
268
|
+
or not math.isfinite(value)
|
|
269
|
+
):
|
|
270
|
+
return None
|
|
271
|
+
try:
|
|
272
|
+
instant = datetime.fromtimestamp(value / 1000, UTC)
|
|
273
|
+
except (OSError, OverflowError, ValueError):
|
|
274
|
+
return None
|
|
275
|
+
rounded = instant.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
276
|
+
return int(rounded.timestamp() * 1000)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _tool_category(name: str) -> str:
|
|
280
|
+
if name in {
|
|
281
|
+
"spawn_agent",
|
|
282
|
+
"wait_agent",
|
|
283
|
+
"list_agents",
|
|
284
|
+
"send_message",
|
|
285
|
+
"followup_task",
|
|
286
|
+
"interrupt_agent",
|
|
287
|
+
}:
|
|
288
|
+
return "Agent coordination"
|
|
289
|
+
if name in {"exec_command", "write_stdin", "wait"}:
|
|
290
|
+
return "Shell and processes"
|
|
291
|
+
if name in {"apply_patch", "view_image"}:
|
|
292
|
+
return "Files and workspace"
|
|
293
|
+
if name == "web__run":
|
|
294
|
+
return "Web"
|
|
295
|
+
if name in {"update_plan", "create_goal", "get_goal", "update_goal"}:
|
|
296
|
+
return "Planning"
|
|
297
|
+
if "imagegen" in name:
|
|
298
|
+
return "Media"
|
|
299
|
+
if name.startswith("mcp__") or "mcp_resource" in name:
|
|
300
|
+
return "Integrations"
|
|
301
|
+
return "Other"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _document(payload: str) -> str:
|
|
305
|
+
return f"""<!doctype html>
|
|
306
|
+
<html lang="en">
|
|
307
|
+
<head>
|
|
308
|
+
<meta charset="utf-8">
|
|
309
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
310
|
+
<title>CLI Consumption</title>
|
|
311
|
+
<style>
|
|
312
|
+
:root {{ color-scheme:dark; --bg:#07111f; --panel:#101e31; --panel2:#0c1929; --ink:#eef6ff; --muted:#91a7bf; --accent:#5eead4; --blue:#60a5fa; --violet:#a78bfa; --amber:#fbbf24; --red:#fb7185; --line:#233b55; }}
|
|
313
|
+
* {{ box-sizing:border-box }}
|
|
314
|
+
body {{ margin:0; background:radial-gradient(circle at 85% 0,#12335a 0,transparent 32%),var(--bg); color:var(--ink); font:15px/1.5 Inter,ui-sans-serif,system-ui,sans-serif }}
|
|
315
|
+
main {{ max-width:1440px; margin:auto; padding:40px 24px 64px }}
|
|
316
|
+
h1 {{ margin:0; font-size:clamp(32px,5vw,58px); letter-spacing:-.04em }}
|
|
317
|
+
h2 {{ margin:0; font-size:18px }} h3 {{ margin:0 0 12px; font-size:14px; color:var(--muted) }}
|
|
318
|
+
.eyebrow {{ color:var(--accent); text-transform:uppercase; letter-spacing:.15em; font-size:12px; font-weight:800 }}
|
|
319
|
+
.subtitle,.muted,.definition {{ color:var(--muted) }} .subtitle {{ max-width:780px }}
|
|
320
|
+
.filters,.cards,.grid,.metric-grid,.quality-grid {{ display:grid; gap:14px }}
|
|
321
|
+
.filters {{ grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); margin:28px 0 14px }}
|
|
322
|
+
.custom-dates {{ display:none; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:14px }} .custom-dates.visible {{ display:grid }}
|
|
323
|
+
.cards {{ grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); margin:18px 0 }}
|
|
324
|
+
.grid {{ grid-template-columns:repeat(2,minmax(0,1fr)); margin-bottom:14px }}
|
|
325
|
+
.metric-grid,.quality-grid {{ grid-template-columns:repeat(2,minmax(0,1fr)) }}
|
|
326
|
+
.panel,.card,select,input {{ border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 93%,transparent); border-radius:16px }}
|
|
327
|
+
.panel {{ padding:21px; overflow:auto }} .panel.full {{ margin-bottom:14px }} .card {{ padding:17px }}
|
|
328
|
+
.panel-head {{ display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin-bottom:17px }}
|
|
329
|
+
.card strong {{ display:block; font-size:27px; letter-spacing:-.03em }} .card span,label {{ color:var(--muted) }}
|
|
330
|
+
.delta {{ font-size:12px; margin-top:4px }} .delta.better {{ color:var(--accent) }} .delta.worse {{ color:var(--red) }} .delta.neutral {{ color:var(--muted) }}
|
|
331
|
+
.badge {{ display:none; width:max-content; margin-top:10px; padding:5px 9px; border:1px solid var(--accent); border-radius:99px; color:var(--accent); font-size:11px; text-transform:uppercase; letter-spacing:.08em }} .badge.visible {{ display:block }}
|
|
332
|
+
label {{ display:grid; gap:6px; font-size:11px; text-transform:uppercase; letter-spacing:.08em }}
|
|
333
|
+
select,input {{ color:var(--ink); padding:10px 12px; width:100% }}
|
|
334
|
+
table {{ width:100%; border-collapse:collapse; white-space:nowrap }} th,td {{ padding:9px 8px; border-bottom:1px solid var(--line); text-align:left }}
|
|
335
|
+
th {{ color:var(--muted); font-size:11px; text-transform:uppercase; cursor:pointer }} tbody tr:hover {{ background:#13243a }}
|
|
336
|
+
.bars {{ display:grid; gap:10px }} .bar {{ display:grid; grid-template-columns:minmax(110px,1.2fr) 4fr auto; gap:10px; align-items:center }}
|
|
337
|
+
.track {{ height:11px; display:flex; background:#1a2d43; border-radius:99px; overflow:hidden }} .fill {{ height:100%; background:linear-gradient(90deg,var(--blue),var(--accent)) }}
|
|
338
|
+
.seg-cache {{ background:var(--blue) }} .seg-uncached {{ background:var(--violet) }} .seg-visible {{ background:var(--accent) }} .seg-reasoning {{ background:var(--amber) }} .seg-other {{ background:var(--red) }}
|
|
339
|
+
.legend {{ display:flex; flex-wrap:wrap; gap:12px; margin:0 0 15px; color:var(--muted); font-size:12px }} .legend i {{ display:inline-block; width:9px; height:9px; border-radius:2px; margin-right:5px }}
|
|
340
|
+
.stat {{ padding:12px; border:1px solid var(--line); border-radius:12px; background:var(--panel2) }} .stat b {{ display:block; font-size:20px }}
|
|
341
|
+
.status {{ display:flex; height:13px; border-radius:99px; overflow:hidden; background:#1a2d43; margin:12px 0 }} .completed {{ background:var(--accent) }} .aborted {{ background:var(--red) }} .progress {{ background:var(--amber) }}
|
|
342
|
+
.empty {{ color:var(--muted); padding:26px 0 }} .definition {{ font-size:12px; margin:12px 0 0 }} footer {{ color:var(--muted); margin-top:26px }}
|
|
343
|
+
details {{ border-top:1px solid var(--line); margin-top:18px; padding-top:14px }} summary {{ cursor:pointer; color:var(--muted) }}
|
|
344
|
+
@media (max-width:850px) {{ .grid {{ grid-template-columns:1fr }} .bar {{ grid-template-columns:minmax(90px,1fr) 2fr auto }} }}
|
|
345
|
+
</style>
|
|
346
|
+
</head>
|
|
347
|
+
<body><main>
|
|
348
|
+
<div class="eyebrow">Local-first AI CLI observability</div>
|
|
349
|
+
<h1>CLI Consumption</h1>
|
|
350
|
+
<p class="subtitle">Understand activity, responsiveness, token composition, and workflows without exporting prompts, responses, or tool arguments.</p>
|
|
351
|
+
<div class="badge" id="privacyBadge">Share-safe dashboard</div>
|
|
352
|
+
<section class="filters">
|
|
353
|
+
<label>Period<select id="period"><option value="all">All history</option><option value="7">Latest 7 days</option><option value="30">Latest 30 days</option><option value="90">Latest 90 days</option><option value="custom">Custom range</option></select></label>
|
|
354
|
+
<label>Provider<select id="provider"></select></label>
|
|
355
|
+
<label>Machine<select id="machine"></select></label>
|
|
356
|
+
<label>Project<select id="project"></select></label>
|
|
357
|
+
<label>Model<select id="model"></select></label>
|
|
358
|
+
</section>
|
|
359
|
+
<section class="custom-dates" id="customDates"><label>From<input type="date" id="from"></label><label>To<input type="date" id="to"></label></section>
|
|
360
|
+
<section class="cards" id="cards"></section>
|
|
361
|
+
<section class="grid">
|
|
362
|
+
<article class="panel"><div class="panel-head"><h2>Activity and token composition</h2><span class="muted" id="activityCaption"></span></div><div class="legend"><span><i class="seg-cache"></i>Cached input</span><span><i class="seg-uncached"></i>Uncached input</span><span><i class="seg-visible"></i>Visible output</span><span><i class="seg-reasoning"></i>Reasoning</span><span><i class="seg-other"></i>Unattributed</span></div><div class="bars" id="activity"></div></article>
|
|
363
|
+
<article class="panel"><div class="panel-head"><h2>Turn performance</h2><span class="muted">p50 / p75 / p95</span></div><div id="performance"></div><p class="definition">Duration is provider-reported. Technical throughput is closed turns per active hour: overlapping intervals are merged in detailed reports, while share-safe reports sum turn durations. It is not a productivity score.</p></article>
|
|
364
|
+
</section>
|
|
365
|
+
<section class="grid">
|
|
366
|
+
<article class="panel"><div class="panel-head"><h2>Models</h2><span class="muted">tokens and efficiency</span></div><div id="models"></div></article>
|
|
367
|
+
<article class="panel"><div class="panel-head"><h2>Tools</h2><span class="muted">names only</span></div><h3>Top tools</h3><div class="bars" id="tools"></div><h3 style="margin-top:20px">Categories</h3><div class="bars" id="toolCategories"></div><h3 style="margin-top:20px">Frequent transitions</h3><div class="bars" id="toolTransitions"></div></article>
|
|
368
|
+
</section>
|
|
369
|
+
<section class="grid">
|
|
370
|
+
<article class="panel"><div class="panel-head"><h2>Workflow complexity</h2><span class="muted">turns, context, delegation</span></div><div class="metric-grid" id="workflow"></div><h3 style="margin-top:20px">Subagent roles</h3><div class="bars" id="agentRoles"></div></article>
|
|
371
|
+
<article class="panel"><div class="panel-head"><h2>Turn outcomes</h2><span class="muted">technical completion</span></div><div id="outcomes"></div><p class="definition">Completed means the provider closed the turn; it does not measure task quality or success.</p></article>
|
|
372
|
+
</section>
|
|
373
|
+
<section class="grid">
|
|
374
|
+
<article class="panel"><div class="panel-head"><h2>Context pressure</h2><span class="muted">latest-call input / context window</span></div><div class="metric-grid" id="contextPressure"></div><h3 style="margin-top:20px">Turn configurations</h3><div class="bars" id="turnConfigurations"></div></article>
|
|
375
|
+
<article class="panel"><div class="panel-head"><h2>Technical work items</h2><span class="muted">content-free intervals</span></div><div class="metric-grid" id="workReliability"></div><h3 style="margin-top:20px">Time by category</h3><div class="bars" id="workKinds"></div></article>
|
|
376
|
+
</section>
|
|
377
|
+
<article class="panel full"><div class="panel-head"><h2>Cohort comparison</h2><label style="min-width:190px">Break down by<select id="cohortDimension"><option value="project">Project</option><option value="model">Model</option><option value="effort">Reasoning effort</option><option value="mode">Collaboration mode</option><option value="delegation">Delegation</option><option value="compaction">Compaction</option></select></label></div><div id="cohorts"></div><p class="definition">Cohort differences are correlations. They do not establish productivity, quality, or causality.</p></article>
|
|
378
|
+
<article class="panel full"><div class="panel-head"><h2>Data quality</h2><span class="muted">coverage and ingestion health</span></div><div class="quality-grid" id="quality"></div></article>
|
|
379
|
+
<article class="panel full"><div class="panel-head"><h2>Conversation explorer</h2><span class="muted" id="conversationCount"></span></div><div id="table"></div><div id="conversationDetail"></div></article>
|
|
380
|
+
<details><summary>Metric definitions and privacy notes</summary><p class="definition">Token events are local usage metadata, not billing data. Cache rate is cached input divided by input. Reasoning share is reasoning output divided by output. Context pressure uses the latest model-call input tokens divided by the reported model context window; it is not cumulative spend. Tokens per turn use completed and aborted turn totals. Subagent tokens may overlap parent totals. Detailed exports retain operationally sensitive project, machine, model, tool, role, and timestamp metadata. Share-safe exports pseudonymize labels, group tools, round dates to days, and omit CSV files.</p></details>
|
|
381
|
+
<footer>Generated as a self-contained file. No network request is made.</footer>
|
|
382
|
+
</main>
|
|
383
|
+
<script>
|
|
384
|
+
const data={payload};
|
|
385
|
+
const $=id=>document.getElementById(id), number=new Intl.NumberFormat(), compact=new Intl.NumberFormat(undefined,{{notation:'compact',maximumFractionDigits:1}});
|
|
386
|
+
const fmt=n=>number.format(Math.round(Number(n)||0)), short=n=>compact.format(Number(n)||0), pct=n=>`${{(Number(n)||0).toFixed(1)}}%`;
|
|
387
|
+
const convByKey=Object.fromEntries(data.conversations.map(c=>[c.key,c]));
|
|
388
|
+
const settingByTurn=Object.fromEntries(data.turnSettings.filter(s=>s.turnKey!==null).map(s=>[s.turnKey,s]));
|
|
389
|
+
const validDate=v=>v&&Number.isFinite(Date.parse(v)), day=v=>validDate(v)?v.slice(0,10):'unknown';
|
|
390
|
+
const total=(rows,key)=>rows.reduce((n,r)=>n+(Number(r[key])||0),0);
|
|
391
|
+
const ratio=(a,b)=>b?100*a/b:0;
|
|
392
|
+
let currentSlice=null,tableSort={{key:'startedAt',direction:-1}};
|
|
393
|
+
function options(id,values){{const e=$(id),current=e.value;e.innerHTML='<option value="">All</option>'+[...new Set(values.filter(Boolean))].sort().map(v=>`<option value="${{escapeHtml(v)}}">${{escapeHtml(v)}}</option>`).join('');e.value=current;}}
|
|
394
|
+
function escapeHtml(v){{return String(v).replace(/[&<>"']/g,c=>({{'&':'&','<':'<','>':'>','"':'"',"'":'''}}[c]));}}
|
|
395
|
+
function percentile(values,p){{const xs=values.map(Number).filter(Number.isFinite).sort((a,b)=>a-b);if(!xs.length)return null;const i=(xs.length-1)*p,lo=Math.floor(i),hi=Math.ceil(i);return xs[lo]+(xs[hi]-xs[lo])*(i-lo);}}
|
|
396
|
+
function rangeFor(period){{const dates=[...data.conversations.map(c=>c.startedAt),...data.modelCalls.map(c=>c.timestamp)].filter(validDate).map(Date.parse);if(!dates.length)return null;const max=new Date(Math.max(...dates));max.setUTCHours(23,59,59,999);let start=null,end=max;if(period==='custom'){{const f=$('from').value,t=$('to').value;start=f?new Date(`${{f}}T00:00:00Z`):null;end=t?new Date(`${{t}}T23:59:59.999Z`):max;}}else if(period!=='all'){{start=new Date(max);start.setUTCDate(start.getUTCDate()-Number(period)+1);start.setUTCHours(0,0,0,0);}}if(!start)return{{start:null,end,previous:null}};const width=end-start;return{{start,end,previous:{{start:new Date(start-width-1),end:new Date(start-1)}}}};}}
|
|
397
|
+
function inRange(value,range){{if(!range||!validDate(value))return !range||!range.start;const time=Date.parse(value);return(!range.start||time>=range.start)&&time<=range.end;}}
|
|
398
|
+
function selected(){{return{{provider:$('provider').value,machine:$('machine').value,project:$('project').value,model:$('model').value,range:rangeFor($('period').value)}};}}
|
|
399
|
+
function baseConversations(f){{return data.conversations.filter(c=>(!f.provider||c.provider===f.provider)&&(!f.machine||c.machine===f.machine)&&(!f.project||c.project===f.project)&&(!f.model||c.models.includes(f.model)));}}
|
|
400
|
+
function slice(f,range=f.range){{const base=baseConversations(f),keys=new Set(base.map(c=>c.key));let calls=data.modelCalls.filter(c=>keys.has(c.conversationKey)&&inRange(c.timestamp,range)&&(!f.model||c.model===f.model));const modelTurns=f.model?new Set(calls.map(c=>c.turnKey).filter(k=>k!==null)):null;const turns=data.turns.filter(t=>keys.has(t.conversationKey)&&inRange(t.startedAt,range)&&(!modelTurns||modelTurns.has(t.key)));const allowedTurns=new Set(turns.map(t=>t.key));calls=calls.filter(c=>c.turnKey===null||allowedTurns.has(c.turnKey));const tools=data.toolCalls.filter(t=>keys.has(t.conversationKey)&&inRange(t.timestamp,range)&&(!f.model||t.turnKey!==null&&allowedTurns.has(t.turnKey)));const work=data.workItems.filter(w=>keys.has(w.conversationKey)&&(!f.model||w.turnKey!==null&&allowedTurns.has(w.turnKey))&&inEpochRange(w.startedAtMs,range));const contexts=data.contextSamples.filter(c=>keys.has(c.conversationKey)&&inRange(c.timestamp,range)&&(!f.model||c.turnKey!==null&&allowedTurns.has(c.turnKey)));const settings=data.turnSettings.filter(s=>keys.has(s.conversationKey)&&s.turnKey!==null&&allowedTurns.has(s.turnKey));const compactions=data.compactions.filter(c=>keys.has(c.conversationKey)&&inRange(c.timestamp,range)&&(!f.model||c.turnKey!==null&&allowedTurns.has(c.turnKey)));const activeConversationKeys=new Set([...turns.map(t=>t.conversationKey),...calls.map(c=>c.conversationKey),...tools.map(t=>t.conversationKey)]);const conversations=base.filter(c=>(inRange(c.startedAt,range)||activeConversationKeys.has(c.key))&&(!f.model||calls.some(x=>x.conversationKey===c.key)));const activeKeys=new Set(conversations.map(c=>c.key));const subagents=data.subagents.filter(s=>s.conversationKey!==null&&activeKeys.has(s.conversationKey)&&inEpochRange(s.createdAtMs,range));return{{conversations,turns,calls,tools,work,contexts,settings,compactions,subagents}};}}
|
|
401
|
+
function inEpochRange(value,range){{if(value===null||value===undefined)return !range||!range.start;const time=Number(value);return(!range||!range.start||time>=range.start.getTime())&&(!range||time<=range.end.getTime());}}
|
|
402
|
+
function activeMs(turns){{if(data.meta.shareSafe)return total(turns,'durationMs');const groups={{}};turns.forEach(t=>{{const c=convByKey[t.conversationKey];if(!c||!validDate(t.startedAt)||!validDate(t.endedAt))return;(groups[c.machine]??=[]).push([Date.parse(t.startedAt),Date.parse(t.endedAt)]);}});let sum=0;Object.values(groups).forEach(intervals=>{{intervals.sort((a,b)=>a[0]-b[0]);let current=null;intervals.forEach(([start,end])=>{{if(!current)current=[start,end];else if(start<=current[1])current[1]=Math.max(current[1],end);else{{sum+=current[1]-current[0];current=[start,end];}}}});if(current)sum+=current[1]-current[0];}});return sum;}}
|
|
403
|
+
function maxConcurrent(turns){{if(data.meta.shareSafe)return null;const byMachine={{}};turns.forEach(t=>{{const c=convByKey[t.conversationKey];if(!c||!validDate(t.startedAt)||!validDate(t.endedAt))return;(byMachine[c.machine]??=[]).push([Date.parse(t.startedAt),1],[Date.parse(t.endedAt),-1]);}});let peak=0;Object.values(byMachine).forEach(points=>{{points.sort((a,b)=>a[0]-b[0]||a[1]-b[1]);let active=0;points.forEach(([,change])=>{{active+=change;peak=Math.max(peak,active);}});}});return peak;}}
|
|
404
|
+
function metrics(s){{const closed=s.turns.filter(t=>t.status==='completed'||t.status==='aborted'),closedKeys=new Set(closed.map(t=>t.key)),closedCalls=s.calls.filter(c=>c.turnKey!==null&&closedKeys.has(c.turnKey)),durations=closed.map(t=>t.durationMs).filter(x=>x!==null),ttfts=closed.map(t=>t.ttftMs).filter(x=>x!==null),turnTokens=closed.map(t=>t.total_tokens),turnTools=closed.map(t=>t.toolCalls),input=total(closedCalls,'input_tokens'),cached=total(closedCalls,'cached_input_tokens'),output=total(closedCalls,'output_tokens'),tokens=total(closedCalls,'total_tokens'),active=activeMs(closed),pressures=s.contexts.map(c=>100*Number(c.inputTokens)/Number(c.contextWindowTokens)).filter(Number.isFinite),activeDays=new Set(s.turns.map(t=>day(t.startedAt)).filter(x=>x!=='unknown')).size;return{{turns:s.turns.length,completed:s.turns.filter(t=>t.status==='completed').length,aborted:s.turns.filter(t=>t.status==='aborted').length,tokens,tokensPerTurn:percentile(turnTokens,.5),toolsPerTurn:percentile(turnTools,.5),cacheRate:ratio(cached,input),durationP50:percentile(durations,.5),durationP75:percentile(durations,.75),durationP95:percentile(durations,.95),ttftP50:percentile(ttfts,.5),ttftP75:percentile(ttfts,.75),ttftP95:percentile(ttfts,.95),tokenP75:percentile(turnTokens,.75),tokenP95:percentile(turnTokens,.95),toolP75:percentile(turnTools,.75),toolP95:percentile(turnTools,.95),abortRate:ratio(s.turns.filter(t=>t.status==='aborted').length,closed.length),reasoningShare:ratio(total(closedCalls,'reasoning_output_tokens'),output),activeMs:active,throughput:active?3600000*closed.length/active:0,pressureP50:percentile(pressures,.5),pressureP95:percentile(pressures,.95),activeDays}};}}
|
|
405
|
+
function delta(current,previous,preference='neutral'){{if(previous===null||previous===undefined||!Number.isFinite(previous)||previous===0)return'';const change=100*(current-previous)/Math.abs(previous),better=preference==='higher'?change>0:preference==='lower'?change<0:null,style=better===null?'neutral':better?'better':'worse';return`<div class="delta ${{style}}">${{change>=0?'+':''}}${{change.toFixed(1)}}% vs previous period</div>`;}}
|
|
406
|
+
function card(label,value,current,previous,preference='neutral'){{return`<div class="card"><span>${{label}}</span><strong>${{value}}</strong>${{delta(current,previous,preference)}}</div>`;}}
|
|
407
|
+
function stat(label,value,detail=''){{return`<div class="stat"><span class="muted">${{label}}</span><b>${{value}}</b>${{detail?`<small class="muted">${{detail}}</small>`:''}}</div>`;}}
|
|
408
|
+
function group(rows,key,value=null){{const out={{}};rows.forEach(r=>{{const label=r[key]||'unknown';out[label]=(out[label]||0)+(value?(Number(r[value])||0):1);}});return Object.entries(out).sort((a,b)=>b[1]-a[1]);}}
|
|
409
|
+
function toolCategory(name){{const categories=['Agent coordination','Shell and processes','Files and workspace','Web','Planning','Media','Integrations','Other'];if(categories.includes(name))return name;if(['spawn_agent','wait_agent','list_agents','send_message','followup_task','interrupt_agent'].includes(name))return'Agent coordination';if(['exec_command','write_stdin','wait'].includes(name))return'Shell and processes';if(['apply_patch','view_image'].includes(name))return'Files and workspace';if(name==='web__run')return'Web';if(['update_plan','create_goal','get_goal','update_goal'].includes(name))return'Planning';if(name.includes('imagegen'))return'Media';if(name.startsWith('mcp__')||name.includes('mcp_resource'))return'Integrations';return'Other';}}
|
|
410
|
+
function drawBars(id,rows,formatter=fmt){{const shown=rows.slice(0,12),max=Math.max(...shown.map(x=>x[1]),1);$(id).innerHTML=shown.map(([k,v])=>`<div class="bar"><span title="${{escapeHtml(k)}}">${{escapeHtml(k)}}</span><div class="track"><div class="fill" style="width:${{100*v/max}}%"></div></div><b>${{formatter(v)}}</b></div>`).join('')||'<div class="empty">No data.</div>';}}
|
|
411
|
+
function drawActivity(calls,range){{const totals={{}};calls.forEach(c=>{{const key=day(c.timestamp),row=totals[key]??={{cached:0,uncached:0,visible:0,reasoning:0,other:0,total:0}};row.cached+=Number(c.cached_input_tokens)||0;row.uncached+=Number(c.uncached_input_tokens)||0;row.visible+=Number(c.visible_output_tokens)||0;row.reasoning+=Number(c.reasoning_output_tokens)||0;row.other+=Number(c.unattributed_tokens)||0;row.total+=Number(c.total_tokens)||0;}});const observed=Object.keys(totals).filter(k=>k!=='unknown').sort(),end=range?.end||new Date(observed.length?`${{observed.at(-1)}}T23:59:59Z`:Date.now()),start=new Date(Math.max(range?.start?.getTime()||-Infinity,end.getTime()-30*86400000)),rows=[];start.setUTCHours(0,0,0,0);for(let cursor=new Date(start);cursor<=end;cursor.setUTCDate(cursor.getUTCDate()+1)){{const label=cursor.toISOString().slice(0,10);rows.push([label,totals[label]||{{cached:0,uncached:0,visible:0,reasoning:0,other:0,total:0}}]);}}const shown=rows.slice(-31),max=Math.max(...shown.map(([,v])=>v.total),1);$('activity').innerHTML=shown.map(([label,v])=>`<div class="bar"><span>${{escapeHtml(label)}}</span><div class="track">${{[['cache','cached'],['uncached','uncached'],['visible','visible'],['reasoning','reasoning'],['other','other']].map(([css,key])=>`<div class="seg-${{css}}" style="width:${{100*v[key]/max}}%" title="${{key}}: ${{fmt(v[key])}}"></div>`).join('')}}</div><b>${{short(v.total)}}</b></div>`).join('')||'<div class="empty">No data.</div>';$('activityCaption').textContent=shown.length?`${{shown.length}} calendar days shown`:'';}}
|
|
412
|
+
function renderModels(calls){{const rows={{}};calls.forEach(c=>{{const r=rows[c.model]??={{calls:0,tokens:0,input:0,cached:0,output:0,reasoning:0,turns:new Set}};r.calls++;r.tokens+=Number(c.total_tokens)||0;r.input+=Number(c.input_tokens)||0;r.cached+=Number(c.cached_input_tokens)||0;r.output+=Number(c.output_tokens)||0;r.reasoning+=Number(c.reasoning_output_tokens)||0;if(c.turnKey!==null)r.turns.add(c.turnKey);}});const entries=Object.entries(rows).sort((a,b)=>b[1].tokens-a[1].tokens);$('models').innerHTML=entries.length?`<table><thead><tr><th>Model</th><th>Tokens</th><th>Tokens / turn</th><th>Usage events</th><th>Cache</th><th>Reasoning</th></tr></thead><tbody>${{entries.map(([name,r])=>`<tr><td>${{escapeHtml(name)}}</td><td>${{fmt(r.tokens)}}</td><td>${{short(r.tokens/(r.turns.size||1))}}</td><td>${{fmt(r.calls)}}</td><td>${{pct(ratio(r.cached,r.input))}}</td><td>${{pct(ratio(r.reasoning,r.output))}}</td></tr>`).join('')}}</tbody></table>`:'<div class="empty">No data.</div>';}}
|
|
413
|
+
function toolTransitions(tools){{const byTurn={{}};tools.filter(t=>t.turnKey!==null).forEach(t=>(byTurn[t.turnKey]??=[]).push(t));const transitions=[];Object.values(byTurn).forEach(rows=>{{rows.sort((a,b)=>a.sequence-b.sequence);for(let i=1;i<rows.length;i++)transitions.push({{transition:`${{rows[i-1].tool}} → ${{rows[i].tool}}`}});}});return group(transitions,'transition');}}
|
|
414
|
+
function renderOutcomes(turns){{const counts={{completed:0,aborted:0,'in-progress':0}};turns.forEach(t=>counts[t.status]=(counts[t.status]||0)+1);const totalTurns=turns.length||1;$('outcomes').innerHTML=`<div class="status"><div class="completed" style="width:${{100*(counts.completed||0)/totalTurns}}%"></div><div class="aborted" style="width:${{100*(counts.aborted||0)/totalTurns}}%"></div><div class="progress" style="width:${{100*(counts['in-progress']||0)/totalTurns}}%"></div></div><div class="metric-grid">${{stat('Completed',fmt(counts.completed||0),pct(100*(counts.completed||0)/totalTurns))}}${{stat('Aborted',fmt(counts.aborted||0),pct(100*(counts.aborted||0)/totalTurns))}}${{stat('In progress',fmt(counts['in-progress']||0),pct(100*(counts['in-progress']||0)/totalTurns))}}${{stat('Closed turns',fmt((counts.completed||0)+(counts.aborted||0)))}}</div>`;}}
|
|
415
|
+
function renderPerformance(m){{$('performance').innerHTML=`<table><thead><tr><th>Metric</th><th>p50</th><th>p75</th><th>p95</th></tr></thead><tbody><tr><td>TTFT</td><td>${{formatDuration(m.ttftP50)}}</td><td>${{formatDuration(m.ttftP75)}}</td><td>${{formatDuration(m.ttftP95)}}</td></tr><tr><td>Turn duration</td><td>${{formatDuration(m.durationP50)}}</td><td>${{formatDuration(m.durationP75)}}</td><td>${{formatDuration(m.durationP95)}}</td></tr><tr><td>Tokens / turn</td><td>${{short(m.tokensPerTurn)}}</td><td>${{short(m.tokenP75)}}</td><td>${{short(m.tokenP95)}}</td></tr><tr><td>Tools / turn</td><td>${{fmt(m.toolsPerTurn)}}</td><td>${{fmt(m.toolP75)}}</td><td>${{fmt(m.toolP95)}}</td></tr></tbody></table>`;}}
|
|
416
|
+
function renderContext(s,m){{const high=s.contexts.filter(c=>100*Number(c.inputTokens)/Number(c.contextWindowTokens)>=80).length,covered=new Set(s.contexts.map(c=>c.turnKey).filter(k=>k!==null)).size;$('contextPressure').innerHTML=stat('Median pressure',pct(m.pressureP50))+stat('p95 pressure',pct(m.pressureP95))+stat('Samples at ≥80%',fmt(high))+stat('Turn coverage',pct(ratio(covered,s.turns.length)))+stat('Compactions',fmt(s.compactions.length),`${{(100*s.compactions.length/Math.max(s.turns.length,1)).toFixed(1)}} / 100 turns`)+stat('Reasoning share',pct(m.reasoningShare));const configs=[];s.settings.forEach(x=>{{if(x.effort)configs.push({{label:`effort: ${{x.effort}}`}});if(x.mode)configs.push({{label:`mode: ${{x.mode}}`}});if(x.tier)configs.push({{label:`tier: ${{x.tier}}`}});}});drawBars('turnConfigurations',group(configs,'label'));}}
|
|
417
|
+
function renderWork(s){{const timed=s.work.filter(w=>w.durationMs!==null),closed=s.work.filter(w=>w.status==='completed'||w.status==='failed'),failed=s.work.filter(w=>w.status==='failed'),durations=timed.map(w=>w.durationMs);$('workReliability').innerHTML=stat('Observed items',fmt(s.work.length))+stat('Duration coverage',pct(ratio(timed.length,s.work.length)))+stat('Technical failure rate',pct(ratio(failed.length,closed.length)))+stat('Median duration',formatDuration(percentile(durations,.5)),`p95 ${{formatDuration(percentile(durations,.95))}}`);drawBars('workKinds',group(timed,'kind','durationMs'),formatDuration);}}
|
|
418
|
+
function delegationStats(s){{const edges=s.subagents.filter(x=>x.childConversationKey!==null),children=new Set(edges.map(x=>x.childConversationKey)),closedChildren=[...children].filter(key=>{{const turns=s.turns.filter(t=>t.conversationKey===key);return turns.length&&turns.every(t=>t.status!=='in-progress');}}),fanouts={{}};edges.forEach(e=>fanouts[e.conversationKey]=(fanouts[e.conversationKey]||0)+1);const adjacency={{}};edges.forEach(e=>(adjacency[e.conversationKey]??=[]).push(e.childConversationKey));function depth(key,path=new Set()){{if(path.has(key))return 0;const next=new Set(path);next.add(key);return 1+Math.max(0,...(adjacency[key]||[]).map(child=>depth(child,next)));}}return{{mapped:edges.length,closed:closedChildren.length,maxFanout:Math.max(0,...Object.values(fanouts)),maxDepth:Math.max(0,...Object.keys(adjacency).map(Number).map(key=>depth(key)-1))}};}}
|
|
419
|
+
function cohortLabel(turn,dimension,s){{const conv=convByKey[turn.conversationKey],setting=settingByTurn[turn.key];if(dimension==='project')return conv?.project||'unknown';if(dimension==='model')return setting?.model||conv?.models?.join(', ')||'unknown';if(dimension==='effort')return setting?.effort||'unknown';if(dimension==='mode')return setting?.mode||'unknown';if(dimension==='delegation')return s.subagents.some(x=>x.conversationKey===turn.conversationKey)?'delegated':'not delegated';if(dimension==='compaction')return s.compactions.some(x=>x.conversationKey===turn.conversationKey)?'compacted':'not compacted';return'unknown';}}
|
|
420
|
+
function renderCohorts(s){{const dimension=$('cohortDimension').value,rows={{}},pressureByTurn={{}};s.contexts.forEach(c=>{{if(c.turnKey===null)return;(pressureByTurn[c.turnKey]??=[]).push(100*Number(c.inputTokens)/Number(c.contextWindowTokens));}});s.turns.filter(t=>t.status==='completed'||t.status==='aborted').forEach(t=>{{const label=cohortLabel(t,dimension,s),r=rows[label]??={{turns:[],durations:[],tokens:[],tools:[],pressures:[],aborted:0}};r.turns.push(t);if(t.durationMs!==null)r.durations.push(t.durationMs);r.tokens.push(t.total_tokens);r.tools.push(t.toolCalls);r.pressures.push(...(pressureByTurn[t.key]||[]));if(t.status==='aborted')r.aborted++;}});const entries=Object.entries(rows).filter(([,r])=>!data.meta.shareSafe||r.turns.length>=5).sort((a,b)=>b[1].turns.length-a[1].turns.length);$('cohorts').innerHTML=entries.length?`<table><thead><tr><th>Cohort</th><th>Closed turns</th><th>Median duration</th><th>Median tokens</th><th>Tools / turn</th><th>Context p95</th><th>Abort rate</th></tr></thead><tbody>${{entries.map(([label,r])=>`<tr><td>${{escapeHtml(label)}}</td><td>${{fmt(r.turns.length)}}</td><td>${{formatDuration(percentile(r.durations,.5))}}</td><td>${{short(percentile(r.tokens,.5))}}</td><td>${{(total(r.tools.map(value=>({{value}})),'value')/r.turns.length).toFixed(1)}}</td><td>${{pct(percentile(r.pressures,.95))}}</td><td>${{pct(ratio(r.aborted,r.turns.length))}}</td></tr>`).join('')}}</tbody></table>`:'<div class="empty">No cohort has enough data for this selection.</div>';}}
|
|
421
|
+
function renderQuality(s,f){{const durationCoverage=ratio(s.turns.filter(t=>t.durationMs!==null).length,s.turns.length),ttftCoverage=ratio(s.turns.filter(t=>t.ttftMs!==null).length,s.turns.length),unknown=ratio(s.calls.filter(c=>!c.model||c.model==='unknown').length,s.calls.length),unattributed=ratio(total(s.calls,'unattributed_tokens'),total(s.calls,'total_tokens')),unmapped=ratio(s.subagents.filter(x=>x.childConversationKey===null).length,s.subagents.length),runs=data.ingestionRuns.filter(r=>(!f.provider||r.provider===f.provider)&&inRange(r.ingestedAt,f.range)),latest=runs.map(r=>r.ingestedAt).filter(Boolean).sort().at(-1),malformed=total(runs,'malformed'),duplicates=total(runs,'duplicates');$('quality').innerHTML=stat('Turn duration coverage',pct(durationCoverage))+stat('TTFT coverage',pct(ttftCoverage))+stat('Unknown model events',pct(unknown))+stat('Unattributed tokens',pct(unattributed))+stat('Unmapped child threads',pct(unmapped))+stat('Malformed records',fmt(malformed))+stat('Deduplicated conversations',fmt(duplicates))+stat('Latest matching ingestion',latest?new Date(latest).toLocaleString():'Unknown');}}
|
|
422
|
+
function sortExplorer(key){{if(tableSort.key===key)tableSort.direction*=-1;else tableSort={{key,direction:key==='startedAt'?-1:1}};if(currentSlice)renderTable(currentSlice.conversations,currentSlice.turns);}}
|
|
423
|
+
function renderTable(conversations,turns){{const statuses={{}};turns.forEach(t=>{{const s=statuses[t.conversationKey]??={{completed:0,aborted:0,progress:0}};if(t.status==='completed')s.completed++;else if(t.status==='aborted')s.aborted++;else s.progress++;}});const outcome=c=>{{const s=statuses[c.key];return s?.aborted?'contains abort':s?.progress?'in progress':s?.completed?'technically closed':'unknown';}},value=(c,key)=>key==='models'?c.models.join(', '):key==='outcome'?outcome(c):c[key];const rows=conversations.slice().sort((a,b)=>{{const av=value(a,tableSort.key)??'',bv=value(b,tableSort.key)??'';return tableSort.direction*(typeof av==='number'&&typeof bv==='number'?av-bv:String(av).localeCompare(String(bv)));}});$('conversationCount').textContent=`${{rows.length}} conversations`;$('conversationDetail').innerHTML='';$('table').innerHTML=rows.length?`<table><thead><tr><th onclick="sortExplorer('startedAt')">Started</th><th onclick="sortExplorer('provider')">Provider</th><th onclick="sortExplorer('machine')">Machine</th><th onclick="sortExplorer('project')">Project</th><th onclick="sortExplorer('models')">Models</th><th onclick="sortExplorer('turns')">Turns</th><th onclick="sortExplorer('outcome')">Technical status</th><th onclick="sortExplorer('compactions')">Compactions</th><th onclick="sortExplorer('total_tokens')">Tokens</th><th onclick="sortExplorer('durationSeconds')">Wall duration</th></tr></thead><tbody>${{rows.map(c=>`<tr style="cursor:pointer" onclick="showConversation(${{c.key}})"><td>${{escapeHtml(c.startedAt||'')}}</td><td>${{escapeHtml(c.provider)}}</td><td>${{escapeHtml(c.machine)}}</td><td>${{escapeHtml(c.project)}}</td><td>${{escapeHtml(c.models.join(', '))}}</td><td>${{fmt(c.turns)}}</td><td>${{outcome(c)}}</td><td>${{fmt(c.compactions)}}</td><td>${{fmt(c.total_tokens)}}</td><td>${{formatDuration((Number(c.durationSeconds)||0)*1000)}}</td></tr>`).join('')}}</tbody></table>`:'<div class="empty">No conversations match these filters.</div>';}}
|
|
424
|
+
function showConversation(key){{if(!currentSlice)return;const c=convByKey[key],turns=currentSlice.turns.filter(t=>t.conversationKey===key),calls=currentSlice.calls.filter(x=>x.conversationKey===key),tools=currentSlice.tools.filter(x=>x.conversationKey===key),work=currentSlice.work.filter(x=>x.conversationKey===key),contexts=currentSlice.contexts.filter(x=>x.conversationKey===key),closed=turns.filter(t=>t.status==='completed'||t.status==='aborted'),durations=closed.map(t=>t.durationMs).filter(x=>x!==null),ttfts=closed.map(t=>t.ttftMs).filter(x=>x!==null),pressures=contexts.map(x=>100*Number(x.inputTokens)/Number(x.contextWindowTokens));$('conversationDetail').innerHTML=`<details open><summary>${{escapeHtml(c.project)}} · ${{escapeHtml(c.startedAt||'')}}</summary><div class="quality-grid" style="margin-top:14px">${{stat('Turns in range',fmt(turns.length))}}${{stat('Tokens in range',fmt(total(calls,'total_tokens')))}}${{stat('Median TTFT',formatDuration(percentile(ttfts,.5)))}}${{stat('Median duration',formatDuration(percentile(durations,.5)))}}${{stat('Context p95',pct(percentile(pressures,.95)))}}${{stat('Technical work items',fmt(work.length))}}</div><h3 style="margin-top:16px">Tools in this conversation</h3><div class="bars">${{group(tools,'tool').slice(0,8).map(([name,count])=>`<div class="bar"><span>${{escapeHtml(name)}}</span><div class="track"><div class="fill" style="width:${{100*count/Math.max(tools.length,1)}}%"></div></div><b>${{fmt(count)}}</b></div>`).join('')||'<div class="empty">No tool calls.</div>'}}</div></details>`;}}
|
|
425
|
+
function formatDuration(ms){{if(ms===null||ms===undefined||!Number.isFinite(Number(ms)))return'—';const seconds=Number(ms)/1000;if(seconds<60)return`${{seconds.toFixed(1)}}s`;if(seconds<3600)return`${{(seconds/60).toFixed(1)}}m`;return`${{(seconds/3600).toFixed(1)}}h`;}}
|
|
426
|
+
function render(){{const f=selected(),s=slice(f),m=metrics(s),previous=f.range?.previous?metrics(slice(f,f.range.previous)):null,closed=s.turns.filter(t=>t.status==='completed'||t.status==='aborted'),delegation=delegationStats(s),peak=maxConcurrent(closed),compacted=new Set(s.compactions.map(c=>c.conversationKey)).size,delegated=new Set(s.subagents.map(x=>x.conversationKey)).size,subTokens=total(s.subagents,'tokens');currentSlice=s;$('cards').innerHTML=card('Closed turns',fmt(m.completed+m.aborted),m.completed+m.aborted,previous?previous.completed+previous.aborted:null)+card('Active days',fmt(m.activeDays),m.activeDays,previous?.activeDays,'higher')+card('Total tokens',short(m.tokens),m.tokens,previous?.tokens)+card('Median tokens / turn',short(m.tokensPerTurn),m.tokensPerTurn,previous?.tokensPerTurn)+card('Median TTFT',formatDuration(m.ttftP50),m.ttftP50,previous?.ttftP50,'lower')+card('Median duration',formatDuration(m.durationP50),m.durationP50,previous?.durationP50,'lower')+card('Technical throughput',m.throughput.toFixed(1)+'/h',m.throughput,previous?.throughput,'higher')+card('Context pressure p95',pct(m.pressureP95),m.pressureP95,previous?.pressureP95,'lower')+card(data.meta.shareSafe?'Summed turn time':'Active time',formatDuration(m.activeMs),m.activeMs,previous?.activeMs);drawActivity(s.calls,f.range);renderPerformance(m);renderModels(s.calls);drawBars('tools',group(s.tools,'tool'));drawBars('toolCategories',group(s.tools.map(t=>({{category:toolCategory(t.tool)}})),'category'));drawBars('toolTransitions',toolTransitions(s.tools));$('workflow').innerHTML=stat('Turns using tools',pct(ratio(s.turns.filter(t=>t.toolCalls>0).length,s.turns.length)))+stat('Compacted conversations',pct(ratio(compacted,s.conversations.length)))+stat('Delegating conversations',pct(ratio(delegated,s.conversations.length)),`${{fmt(s.subagents.length)}} edges`)+stat('Mapped child threads',fmt(delegation.mapped),`${{fmt(delegation.closed)}} technically closed`)+stat('Max delegation fan-out',fmt(delegation.maxFanout))+stat('Max delegation depth',fmt(delegation.maxDepth))+stat('Reported subagent tokens',short(subTokens),'may overlap parent totals')+stat('Peak concurrent turns',peak===null?'Hidden':fmt(peak),data.meta.shareSafe?'exact times rounded':'per-machine peak');drawBars('agentRoles',group(s.subagents,'role'));renderOutcomes(s.turns);renderContext(s,m);renderWork(s);renderCohorts(s);renderQuality(s,f);renderTable(s.conversations,s.turns);}}
|
|
427
|
+
options('provider',data.conversations.map(c=>c.provider));options('machine',data.conversations.map(c=>c.machine));options('project',data.conversations.map(c=>c.project));options('model',data.modelCalls.map(c=>c.model));
|
|
428
|
+
const dates=data.conversations.map(c=>c.startedAt).filter(validDate).sort();if(dates.length){{$('from').value=dates[0].slice(0,10);$('to').value=dates.at(-1).slice(0,10);}}
|
|
429
|
+
if(data.meta.shareSafe)$('privacyBadge').classList.add('visible');
|
|
430
|
+
document.querySelectorAll('select,input').forEach(e=>e.addEventListener('change',()=>{{$('customDates').classList.toggle('visible',$('period').value==='custom');render();}}));render();
|
|
431
|
+
</script></body></html>"""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.engine import Engine
|
|
8
|
+
|
|
9
|
+
from cli_consumption.storage import TABLES, read_table
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def export_csv(engine: Engine, output: Path) -> list[Path]:
|
|
13
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
written: list[Path] = []
|
|
15
|
+
for table_name in TABLES:
|
|
16
|
+
rows = read_table(engine, table_name)
|
|
17
|
+
path = output / f"{table_name}.csv"
|
|
18
|
+
fieldnames = list(TABLES[table_name].__table__.columns.keys())
|
|
19
|
+
with path.open("w", newline="", encoding="utf-8") as handle:
|
|
20
|
+
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
|
21
|
+
writer.writeheader()
|
|
22
|
+
writer.writerows(_serialize(rows))
|
|
23
|
+
written.append(path)
|
|
24
|
+
return written
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _serialize(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
28
|
+
return [
|
|
29
|
+
{key: "" if value is None else value for key, value in row.items()}
|
|
30
|
+
for row in rows
|
|
31
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
TOKEN_FIELDS = (
|
|
7
|
+
"input_tokens",
|
|
8
|
+
"cached_input_tokens",
|
|
9
|
+
"cache_write_input_tokens",
|
|
10
|
+
"output_tokens",
|
|
11
|
+
"reasoning_output_tokens",
|
|
12
|
+
"total_tokens",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def empty_tokens() -> dict[str, int]:
|
|
17
|
+
return {
|
|
18
|
+
**dict.fromkeys(TOKEN_FIELDS, 0),
|
|
19
|
+
"uncached_input_tokens": 0,
|
|
20
|
+
"visible_output_tokens": 0,
|
|
21
|
+
"unattributed_tokens": 0,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class Snapshot:
|
|
27
|
+
"""Provider-neutral usage records ready for persistence or transport."""
|
|
28
|
+
|
|
29
|
+
provider: str
|
|
30
|
+
conversations: list[dict[str, Any]] = field(default_factory=list)
|
|
31
|
+
turns: list[dict[str, Any]] = field(default_factory=list)
|
|
32
|
+
model_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
33
|
+
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
34
|
+
work_items: list[dict[str, Any]] = field(default_factory=list)
|
|
35
|
+
context_samples: list[dict[str, Any]] = field(default_factory=list)
|
|
36
|
+
turn_settings: list[dict[str, Any]] = field(default_factory=list)
|
|
37
|
+
compaction_events: list[dict[str, Any]] = field(default_factory=list)
|
|
38
|
+
subagents: list[dict[str, Any]] = field(default_factory=list)
|
|
39
|
+
malformed_records: int = 0
|
|
40
|
+
duplicate_conversations: int = 0
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
return asdict(self)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, value: dict[str, Any]) -> Snapshot:
|
|
47
|
+
return cls(
|
|
48
|
+
provider=str(value["provider"]),
|
|
49
|
+
conversations=list(value.get("conversations", [])),
|
|
50
|
+
turns=list(value.get("turns", [])),
|
|
51
|
+
model_calls=list(value.get("model_calls", [])),
|
|
52
|
+
tool_calls=list(value.get("tool_calls", [])),
|
|
53
|
+
work_items=list(value.get("work_items", [])),
|
|
54
|
+
context_samples=list(value.get("context_samples", [])),
|
|
55
|
+
turn_settings=list(value.get("turn_settings", [])),
|
|
56
|
+
compaction_events=list(value.get("compaction_events", [])),
|
|
57
|
+
subagents=list(value.get("subagents", [])),
|
|
58
|
+
malformed_records=int(value.get("malformed_records", 0)),
|
|
59
|
+
duplicate_conversations=int(value.get("duplicate_conversations", 0)),
|
|
60
|
+
)
|
cli_consumption/py.typed
ADDED
|
File without changes
|