zeno-cli 0.3.4__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 (69) hide show
  1. zeno_adapters/__init__.py +17 -0
  2. zeno_adapters/_common.py +38 -0
  3. zeno_adapters/anthropic.py +68 -0
  4. zeno_adapters/claude_code.py +101 -0
  5. zeno_adapters/crewai.py +92 -0
  6. zeno_adapters/langgraph.py +49 -0
  7. zeno_adapters/openai.py +108 -0
  8. zeno_cli/__init__.py +1 -0
  9. zeno_cli/_hooks/cc_bridge.py +1016 -0
  10. zeno_cli/doctor.py +535 -0
  11. zeno_cli/hook_install.py +269 -0
  12. zeno_cli/hud/__init__.py +1 -0
  13. zeno_cli/hud/hud_install.py +652 -0
  14. zeno_cli/hud/zeno_attention.py +288 -0
  15. zeno_cli/hud/zeno_cognition.py +457 -0
  16. zeno_cli/hud/zeno_hud.py +496 -0
  17. zeno_cli/interview_invites.py +342 -0
  18. zeno_cli/login.py +241 -0
  19. zeno_cli/main.py +2534 -0
  20. zeno_cli/onboard.py +206 -0
  21. zeno_cli/outreach.py +456 -0
  22. zeno_cli/version.py +67 -0
  23. zeno_cli-0.3.4.dist-info/METADATA +161 -0
  24. zeno_cli-0.3.4.dist-info/RECORD +69 -0
  25. zeno_cli-0.3.4.dist-info/WHEEL +4 -0
  26. zeno_cli-0.3.4.dist-info/entry_points.txt +4 -0
  27. zeno_core/__init__.py +67 -0
  28. zeno_core/analytics.py +193 -0
  29. zeno_core/rtlx_s.py +460 -0
  30. zeno_core/streak.py +178 -0
  31. zeno_core/tlx_s.py +192 -0
  32. zeno_sdk/__init__.py +6 -0
  33. zeno_sdk/_generated/__init__.py +6 -0
  34. zeno_sdk/_generated/client.py +819 -0
  35. zeno_sdk/_migrations/alembic/env.py +33 -0
  36. zeno_sdk/_migrations/alembic/script.py.mako +18 -0
  37. zeno_sdk/_migrations/alembic/versions/0001_initial.py +79 -0
  38. zeno_sdk/_migrations/alembic/versions/0002_cognition_samples.py +53 -0
  39. zeno_sdk/_migrations/alembic/versions/0003_cognition_drivers.py +41 -0
  40. zeno_sdk/_migrations/alembic/versions/0004_transcript_intelligence.py +248 -0
  41. zeno_sdk/_migrations/alembic.ini +35 -0
  42. zeno_sdk/_runtime.py +12 -0
  43. zeno_sdk/adapters/__init__.py +15 -0
  44. zeno_sdk/adapters/anthropic.py +5 -0
  45. zeno_sdk/adapters/claude_code.py +5 -0
  46. zeno_sdk/adapters/crewai.py +5 -0
  47. zeno_sdk/adapters/langgraph.py +5 -0
  48. zeno_sdk/adapters/openai.py +5 -0
  49. zeno_sdk/auth.py +25 -0
  50. zeno_sdk/client.py +87 -0
  51. zeno_sdk/config.py +61 -0
  52. zeno_sdk/daemon.py +72 -0
  53. zeno_sdk/privacy.py +46 -0
  54. zeno_sdk/session.py +179 -0
  55. zeno_sdk/storage.py +487 -0
  56. zeno_sdk/types/__init__.py +121 -0
  57. zeno_session_intel/__init__.py +19 -0
  58. zeno_session_intel/analytics.py +588 -0
  59. zeno_session_intel/compression.py +123 -0
  60. zeno_session_intel/ingest.py +376 -0
  61. zeno_session_intel/model.py +129 -0
  62. zeno_session_intel/parsers/__init__.py +31 -0
  63. zeno_session_intel/parsers/claude_code.py +169 -0
  64. zeno_session_intel/parsers/codex.py +265 -0
  65. zeno_session_intel/parsers/cursor.py +198 -0
  66. zeno_session_intel/prices.py +281 -0
  67. zeno_session_intel/schema.py +277 -0
  68. zeno_session_intel/signals.py +319 -0
  69. zeno_session_intel/taxonomy.py +71 -0
@@ -0,0 +1,588 @@
1
+ """Session-intelligence analytics: ccusage-class rollups over the transcript_* tables.
2
+
3
+ Read-only aggregation the dashboard export scripts and the ``zeno-usage`` CLI both call.
4
+ Ported from agentsview (MIT, see THIRD_PARTY_LICENSES.md): cache-aware cost
5
+ (``usage.go``), session stats + version-locked histogram edges + archetypes
6
+ (``session_stats.go``/``session_stats_buckets.go``/``analytics.go``), tool-mix taxonomy,
7
+ and outcomes. zeno additions: peak-context-pct as a first-class metric, and cache
8
+ economics for any agent with nonzero cache rates (not agentsview's claude-only gate).
9
+
10
+ Stdlib-only and **py3.9-safe** (the dashboard runs the system python3). Percentiles use
11
+ nearest-rank (NOT interpolated - matches agentsview ``analytics.go:455-465``); histograms
12
+ use ``bisect`` over half-open ``[lo, hi)`` edges with a ``+Inf`` last bucket.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import bisect
19
+ import json
20
+ import os
21
+ import sqlite3
22
+ import sys
23
+
24
+ from . import compression, schema
25
+ from .taxonomy import CATEGORIES
26
+
27
+ # version-locked histogram edges (session_stats_buckets.go). Half-open [lo, hi), last +Inf.
28
+ DURATION_EDGES = [0, 1, 5, 20, 60, 120]
29
+ PEAK_CONTEXT_EDGES = [0, 10_000, 50_000, 100_000, 150_000, 200_000]
30
+ TOOLS_PER_TURN_EDGES = [0, 1, 2, 4, 7, 11]
31
+
32
+ _ARCHETYPE_ORDER = ("automation", "marathon", "deep", "standard", "quick")
33
+
34
+
35
+ def _has_tables(con: sqlite3.Connection) -> bool:
36
+ rows = con.execute(
37
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='transcript_sessions'"
38
+ ).fetchall()
39
+ return bool(rows)
40
+
41
+
42
+ def percentile(values: list[float], pct: float) -> float | None:
43
+ """Nearest-rank, zero-indexed, NOT interpolated: sorted[min(int(n*pct), n-1)]."""
44
+ vals = sorted(v for v in values if v is not None)
45
+ n = len(vals)
46
+ if n == 0:
47
+ return None
48
+ return vals[min(int(n * pct), n - 1)]
49
+
50
+
51
+ def histogram(values: list[float], edges: list[float]) -> list[dict]:
52
+ """Half-open [lo, hi) buckets with a final +Inf bucket. Returns labeled counts."""
53
+ counts = [0] * (len(edges)) # one extra bucket for >= last edge (the +Inf bucket)
54
+ for v in values:
55
+ if v is None:
56
+ continue
57
+ idx = bisect.bisect_right(edges, v) - 1
58
+ if idx < 0:
59
+ idx = 0
60
+ if idx >= len(counts):
61
+ idx = len(counts) - 1
62
+ counts[idx] += 1
63
+ out = []
64
+ for i, lo in enumerate(edges):
65
+ hi = edges[i + 1] if i + 1 < len(edges) else None
66
+ out.append({"lo": lo, "hi": hi, "count": counts[i]})
67
+ return out
68
+
69
+
70
+ def archetype(user_message_count: int, is_automated: bool) -> str:
71
+ if is_automated:
72
+ return "automation"
73
+ if user_message_count <= 5:
74
+ return "quick"
75
+ if user_message_count <= 15:
76
+ return "standard"
77
+ if user_message_count <= 50:
78
+ return "deep"
79
+ return "marathon"
80
+
81
+
82
+ def _duration_min(started: str | None, ended: str | None) -> float | None:
83
+ if not started or not ended:
84
+ return None
85
+ try:
86
+ from datetime import datetime
87
+
88
+ s = datetime.fromisoformat(started.replace("Z", "+00:00"))
89
+ e = datetime.fromisoformat(ended.replace("Z", "+00:00"))
90
+ mins = round((e - s).total_seconds() / 60.0, 2)
91
+ # reject negatives from out-of-order / clock-skewed timestamps so they do not
92
+ # skew percentiles or land in the [0,1) bucket
93
+ return mins if mins >= 0 else None
94
+ except Exception:
95
+ return None
96
+
97
+
98
+ def _q(con: sqlite3.Connection, sql: str, params: tuple = ()) -> list:
99
+ try:
100
+ return con.execute(sql, params).fetchall()
101
+ except Exception:
102
+ return []
103
+
104
+
105
+ def cost_totals(con: sqlite3.Connection) -> dict:
106
+ """Cache-aware token/cost totals + cache hit ratio + cache savings (ccusage-class).
107
+
108
+ Cache savings joins the usage ledger to model_pricing and is NOT clamped (negative on
109
+ write-heavy sessions is itself the finding). Computed for any agent with nonzero cache
110
+ rates - not gated to claude.
111
+ """
112
+ row = _q(
113
+ con,
114
+ "SELECT COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), "
115
+ "COALESCE(SUM(cache_creation_input_tokens),0), COALESCE(SUM(cache_read_input_tokens),0), "
116
+ "COALESCE(SUM(cost_usd),0), COUNT(*), SUM(CASE WHEN cost_status='' THEN 1 ELSE 0 END) "
117
+ "FROM token_usage_events",
118
+ )
119
+ if not row:
120
+ return {}
121
+ inp, out, cc, cr, cost, n, unpriced = row[0]
122
+ cache_in = cr + cc
123
+ total_in = inp + cache_in
124
+ cache_hit_ratio = round(cr / total_in, 4) if total_in else 0.0
125
+ savings_row = _q(
126
+ con,
127
+ "SELECT COALESCE(SUM("
128
+ " e.cache_read_input_tokens * (p.input_per_mtok - p.cache_read_per_mtok) "
129
+ "+ e.cache_creation_input_tokens * (p.input_per_mtok - p.cache_creation_per_mtok)"
130
+ ") / 1000000.0, 0) "
131
+ "FROM token_usage_events e JOIN model_pricing p ON p.model_pattern = e.model",
132
+ )
133
+ savings = round(savings_row[0][0], 4) if savings_row else 0.0
134
+ return {
135
+ "events": int(n or 0),
136
+ "unpriced_events": int(unpriced or 0),
137
+ "input_tokens": int(inp),
138
+ "output_tokens": int(out),
139
+ "cache_creation_tokens": int(cc),
140
+ "cache_read_tokens": int(cr),
141
+ "total_tokens": int(total_in + out),
142
+ "cost_usd": round(cost, 4),
143
+ "cache_hit_ratio": cache_hit_ratio,
144
+ "cache_savings_usd": savings,
145
+ }
146
+
147
+
148
+ def by_model(con: sqlite3.Connection) -> list[dict]:
149
+ rows = _q(
150
+ con,
151
+ "SELECT COALESCE(NULLIF(model,''),'(unpriced)'), COUNT(DISTINCT session_id), "
152
+ "COALESCE(SUM(cost_usd),0), "
153
+ "COALESCE(SUM(input_tokens+output_tokens+cache_read_input_tokens+"
154
+ "cache_creation_input_tokens),0) "
155
+ "FROM token_usage_events GROUP BY 1 ORDER BY SUM(cost_usd) DESC",
156
+ )
157
+ return [
158
+ {"model": m, "sessions": s, "cost_usd": round(c, 4), "total_tokens": int(t)}
159
+ for m, s, c, t in rows
160
+ ]
161
+
162
+
163
+ def by_day(con: sqlite3.Connection) -> list[dict]:
164
+ rows = _q(
165
+ con,
166
+ "SELECT substr(occurred_at,1,10) d, COUNT(DISTINCT session_id), COALESCE(SUM(cost_usd),0) "
167
+ "FROM token_usage_events WHERE occurred_at IS NOT NULL GROUP BY d ORDER BY d",
168
+ )
169
+ return [{"date": d, "sessions": s, "cost_usd": round(c, 4)} for d, s, c in rows if d]
170
+
171
+
172
+ def tool_mix(con: sqlite3.Connection) -> list[dict]:
173
+ rows = _q(
174
+ con,
175
+ "SELECT category, COUNT(*), COALESCE(SUM(is_error),0) "
176
+ "FROM transcript_tool_calls GROUP BY category",
177
+ )
178
+ by_cat = {c: (n, e) for c, n, e in rows}
179
+ total = sum(n for n, _ in by_cat.values()) or 1
180
+ out = []
181
+ for cat in CATEGORIES:
182
+ n, e = by_cat.get(cat, (0, 0))
183
+ if n == 0:
184
+ continue
185
+ out.append({"category": cat, "count": n, "errors": e, "pct": round(n / total * 1000) / 10})
186
+ out.sort(key=lambda r: r["count"], reverse=True)
187
+ return out
188
+
189
+
190
+ def _session_rows(con: sqlite3.Connection) -> list[dict]:
191
+ cols = [
192
+ "id",
193
+ "agent",
194
+ "project",
195
+ "started_at",
196
+ "ended_at",
197
+ "message_count",
198
+ "user_message_count",
199
+ "is_automated",
200
+ "outcome",
201
+ "outcome_confidence",
202
+ "health_score",
203
+ "health_grade",
204
+ "peak_context_tokens",
205
+ "context_pressure_max",
206
+ "total_output_tokens",
207
+ ]
208
+ rows = _q(con, f"SELECT {','.join(cols)} FROM transcript_sessions")
209
+ return [{c: r[i] for i, c in enumerate(cols)} for r in rows]
210
+
211
+
212
+ def session_analytics(con: sqlite3.Connection) -> dict:
213
+ sessions = _session_rows(con)
214
+ n = len(sessions)
215
+ arche: dict[str, int] = {}
216
+ outcomes: dict[str, int] = {}
217
+ grades: dict[str, int] = {"A": 0, "B": 0, "C": 0, "D": 0, "F": 0}
218
+ durations: list[float] = []
219
+ peaks: list[float] = []
220
+ pressures: list[float] = []
221
+ scored = 0
222
+ score_sum = 0
223
+ # tool calls per session for the tools-per-turn histogram
224
+ tc_rows = _q(con, "SELECT session_id, COUNT(*) FROM transcript_tool_calls GROUP BY session_id")
225
+ tc_by_session = {sid: c for sid, c in tc_rows}
226
+
227
+ for s in sessions:
228
+ a = archetype(int(s["user_message_count"] or 0), bool(s["is_automated"]))
229
+ arche[a] = arche.get(a, 0) + 1
230
+ oc = s["outcome"] or "unknown"
231
+ outcomes[oc] = outcomes.get(oc, 0) + 1
232
+ if s["health_score"] is not None:
233
+ scored += 1
234
+ score_sum += int(s["health_score"])
235
+ g = s["health_grade"] or ""
236
+ if g in grades:
237
+ grades[g] += 1
238
+ d = _duration_min(s["started_at"], s["ended_at"])
239
+ if d is not None:
240
+ durations.append(d)
241
+ if s["peak_context_tokens"]:
242
+ peaks.append(float(s["peak_context_tokens"]))
243
+ if s["context_pressure_max"] is not None:
244
+ pressures.append(float(s["context_pressure_max"]))
245
+
246
+ primary = ""
247
+ if arche:
248
+ primary = sorted(
249
+ arche,
250
+ key=lambda k: (-arche[k], _ARCHETYPE_ORDER.index(k) if k in _ARCHETYPE_ORDER else 99),
251
+ )[0]
252
+ return {
253
+ "session_count": n,
254
+ "archetypes": [
255
+ {"archetype": k, "count": v} for k, v in sorted(arche.items(), key=lambda kv: -kv[1])
256
+ ],
257
+ "primary_archetype": primary,
258
+ "outcomes": [
259
+ {"outcome": k, "count": v} for k, v in sorted(outcomes.items(), key=lambda kv: -kv[1])
260
+ ],
261
+ "health": {
262
+ "scored": scored,
263
+ "mean": round(score_sum / scored, 1) if scored else None,
264
+ "grades": grades,
265
+ },
266
+ "duration_distribution": histogram(durations, DURATION_EDGES),
267
+ "duration_p50_min": percentile(durations, 0.5),
268
+ "peak_context_distribution": histogram(peaks, PEAK_CONTEXT_EDGES),
269
+ "peak_context_p90": percentile(peaks, 0.9),
270
+ "context_pressure_p90": percentile(pressures, 0.9),
271
+ "tools_per_session_distribution": histogram(
272
+ [float(v) for v in tc_by_session.values()], TOOLS_PER_TURN_EDGES
273
+ ),
274
+ }
275
+
276
+
277
+ def recent_sessions(con: sqlite3.Connection, limit: int = 12) -> list[dict]:
278
+ cols = [
279
+ "id",
280
+ "agent",
281
+ "project",
282
+ "started_at",
283
+ "ended_at",
284
+ "message_count",
285
+ "user_message_count",
286
+ "is_automated",
287
+ "outcome",
288
+ "health_score",
289
+ "health_grade",
290
+ "peak_context_tokens",
291
+ "context_pressure_max",
292
+ ]
293
+ rows = _q(
294
+ con,
295
+ f"SELECT {','.join(cols)} FROM transcript_sessions ORDER BY ended_at DESC, id LIMIT ?",
296
+ (limit,),
297
+ )
298
+ cost_rows = _q(
299
+ con,
300
+ "SELECT session_id, COALESCE(SUM(cost_usd),0) FROM token_usage_events GROUP BY session_id",
301
+ )
302
+ cost_by = {sid: c for sid, c in cost_rows}
303
+ tc_rows = _q(con, "SELECT session_id, COUNT(*) FROM transcript_tool_calls GROUP BY session_id")
304
+ tc_by = {sid: c for sid, c in tc_rows}
305
+ out = []
306
+ for r in rows:
307
+ s = {c: r[i] for i, c in enumerate(cols)}
308
+ out.append(
309
+ {
310
+ "id": s["id"],
311
+ "agent": s["agent"],
312
+ "project": s["project"],
313
+ "started_at": s["started_at"],
314
+ "duration_min": _duration_min(s["started_at"], s["ended_at"]),
315
+ "message_count": s["message_count"],
316
+ "tool_calls": tc_by.get(s["id"], 0),
317
+ "cost_usd": round(cost_by.get(s["id"], 0.0), 4),
318
+ "health_score": s["health_score"],
319
+ "health_grade": s["health_grade"],
320
+ "outcome": s["outcome"],
321
+ "archetype": archetype(int(s["user_message_count"] or 0), bool(s["is_automated"])),
322
+ "peak_context_tokens": s["peak_context_tokens"],
323
+ "peak_context_pct": (
324
+ round(s["context_pressure_max"] * 100, 1)
325
+ if s["context_pressure_max"] is not None
326
+ else None
327
+ ),
328
+ }
329
+ )
330
+ return out
331
+
332
+
333
+ def search(con: sqlite3.Connection, query: str, limit: int = 20) -> list[dict]:
334
+ """Full-text search transcript messages via the FTS5 index, bm25-ranked.
335
+
336
+ Returns rows of {id, session_id, ordinal, role, timestamp, snippet, rank}
337
+ with the matched terms wrapped in [..] by snippet(). Ordered most-relevant
338
+ first: SQLite bm25() returns NEGATIVE scores (best match most negative), so
339
+ plain ascending ORDER BY is correct (no DESC, no negation).
340
+
341
+ Never raises (mirrors ``_q``): returns [] when the query is empty, the
342
+ SQLite build lacks fts5, or the FTS5 query syntax is invalid. The user term
343
+ is wrapped as a quoted phrase so raw input containing FTS5 operators
344
+ (AND / OR / NEAR / unbalanced quotes) is matched literally instead of
345
+ raising. py3.9-safe.
346
+ """
347
+ query = (query or "").strip()
348
+ if not query or not schema.fts5_available(con):
349
+ return []
350
+ fts = schema.FTS_TABLE
351
+ phrase = '"' + query.replace('"', '""') + '"'
352
+ rows = _q(
353
+ con,
354
+ f"SELECT m.id, m.session_id, m.ordinal, m.role, m.timestamp, "
355
+ f"snippet({fts}, 0, '[', ']', '...', 10) AS snippet, bm25({fts}) AS rank "
356
+ f"FROM {fts} JOIN transcript_messages m ON m.id = {fts}.rowid "
357
+ f"WHERE {fts} MATCH ? ORDER BY bm25({fts}) LIMIT ?",
358
+ (phrase, limit),
359
+ )
360
+ return [
361
+ {
362
+ "id": r[0],
363
+ "session_id": r[1],
364
+ "ordinal": r[2],
365
+ "role": r[3],
366
+ "timestamp": r[4],
367
+ "snippet": r[5],
368
+ "rank": r[6],
369
+ }
370
+ for r in rows
371
+ ]
372
+
373
+
374
+ def _empty_payload() -> dict:
375
+ """Full-shaped empty payload so the dashboard's TS type + spread-merge stay safe even
376
+ when there is no capture yet (mirrors the exporter's always-emit-full-shape pattern)."""
377
+ return {
378
+ "present": False,
379
+ "totals": {
380
+ "sessions": 0,
381
+ "messages": 0,
382
+ "tool_calls": 0,
383
+ "agents": [],
384
+ "events": 0,
385
+ "unpriced_events": 0,
386
+ "input_tokens": 0,
387
+ "output_tokens": 0,
388
+ "cache_creation_tokens": 0,
389
+ "cache_read_tokens": 0,
390
+ "total_tokens": 0,
391
+ "cost_usd": 0.0,
392
+ "cache_hit_ratio": 0.0,
393
+ "cache_savings_usd": 0.0,
394
+ },
395
+ "by_model": [],
396
+ "by_day": [],
397
+ "tool_mix": [],
398
+ "sessions": {
399
+ "session_count": 0,
400
+ "archetypes": [],
401
+ "primary_archetype": "",
402
+ "outcomes": [],
403
+ "health": {
404
+ "scored": 0,
405
+ "mean": None,
406
+ "grades": {"A": 0, "B": 0, "C": 0, "D": 0, "F": 0},
407
+ },
408
+ "duration_distribution": [],
409
+ "duration_p50_min": None,
410
+ "peak_context_distribution": [],
411
+ "peak_context_p90": None,
412
+ "context_pressure_p90": None,
413
+ "tools_per_session_distribution": [],
414
+ },
415
+ "recent_sessions": [],
416
+ "compression": {
417
+ "present": False,
418
+ "analyzed_chunks": 0,
419
+ "total_tokens": 0,
420
+ "savable_tokens": 0,
421
+ "savable_pct": 0.0,
422
+ "by_type": [],
423
+ },
424
+ }
425
+
426
+
427
+ COMPRESSION_MIN_CONTENT_LEN = 500 # only sizable chunks are worth (theoretically) compressing
428
+ COMPRESSION_MAX_CHUNKS = 20_000 # bound the offline scan; the largest chunks dominate savings
429
+
430
+
431
+ def compression_savings(con: sqlite3.Connection) -> dict:
432
+ """Directional 'tokens you could have saved' estimate across captured content,
433
+ segmented by type. Read-only; this NEVER compresses anything (the agent request
434
+ path is untouched - see compression.py / the zeno-compression-decision memo).
435
+
436
+ Excludes assistant-authored text: the metric is about tool outputs + pasted data,
437
+ not the model's own prose. Code, tracebacks, and diffs always score 0 savable
438
+ (lossy compression corrupts exact tokens). Scans the largest non-assistant chunks;
439
+ fetches only a bounded head + interior + tail SAMPLE of each (classification is
440
+ sample-based) and derives the token count from the stored content_length - so a
441
+ large DB never materializes megabytes of content into memory.
442
+ """
443
+ rows = _q(
444
+ con,
445
+ "SELECT substr(content, 1, 2000) || x'0a' || "
446
+ "substr(content, max(1, content_length / 2 - 500), 1000) || x'0a' || "
447
+ "substr(content, -2000), content_length "
448
+ "FROM transcript_messages "
449
+ "WHERE role != 'assistant' AND content_length >= ? "
450
+ "ORDER BY content_length DESC LIMIT ?",
451
+ (COMPRESSION_MIN_CONTENT_LEN, COMPRESSION_MAX_CHUNKS),
452
+ )
453
+ agg: dict[str, dict[str, int]] = {}
454
+ total_tokens = 0
455
+ savable = 0
456
+ for sample, content_length in rows:
457
+ ctype = compression.classify(sample or "")
458
+ tokens = int((content_length or 0) / compression.CHARS_PER_TOKEN)
459
+ savable_tokens = int(tokens * compression.COMPRESSIBLE_FRACTION[ctype])
460
+ bucket = agg.setdefault(ctype, {"tokens": 0, "savable_tokens": 0, "chunks": 0})
461
+ bucket["tokens"] += tokens
462
+ bucket["savable_tokens"] += savable_tokens
463
+ bucket["chunks"] += 1
464
+ total_tokens += tokens
465
+ savable += savable_tokens
466
+ by_type = [
467
+ {"type": t, **vals}
468
+ for t, vals in sorted(agg.items(), key=lambda kv: kv[1]["savable_tokens"], reverse=True)
469
+ ]
470
+ return {
471
+ "present": bool(rows),
472
+ "analyzed_chunks": len(rows),
473
+ "total_tokens": total_tokens,
474
+ "savable_tokens": savable,
475
+ "savable_pct": round(100.0 * savable / total_tokens, 1) if total_tokens else 0.0,
476
+ "by_type": by_type,
477
+ }
478
+
479
+
480
+ def _fmt_tok(n: int) -> str:
481
+ """Compact token count: 1.2M / 34k / 512."""
482
+ if n >= 1_000_000:
483
+ return f"{n / 1_000_000:.1f}M"
484
+ if n >= 1_000:
485
+ return f"{n / 1_000:.0f}k"
486
+ return str(int(n))
487
+
488
+
489
+ def render_compression_text(comp: dict) -> str:
490
+ """Human-readable compression-savings summary for ``zeno usage --compression``."""
491
+ if not comp.get("present"):
492
+ return "Compression savings: no sizable tool/data content captured yet."
493
+ lines = [
494
+ "Compression savings (directional - this measures only, never compresses):",
495
+ f" {comp['savable_pct']}% of {_fmt_tok(comp['total_tokens'])} captured non-assistant "
496
+ f"tokens are safely compressible (~{_fmt_tok(comp['savable_tokens'])}).",
497
+ f" scanned {comp['analyzed_chunks']} chunks, by type:",
498
+ ]
499
+ for b in comp["by_type"]:
500
+ tag = "" if b["savable_tokens"] > 0 else " (never compressed - code/traceback)"
501
+ lines.append(
502
+ f" {b['type']:<10}{_fmt_tok(b['savable_tokens']):>8} savable "
503
+ f"of {_fmt_tok(b['tokens'])} ({b['chunks']} chunks){tag}"
504
+ )
505
+ return "\n".join(lines)
506
+
507
+
508
+ def build(con: sqlite3.Connection) -> dict:
509
+ """Assemble the full session-intelligence payload the dashboard embeds. Never raises;
510
+ returns a full-shaped ``present: False`` payload if the tables are absent or empty."""
511
+ out = _empty_payload()
512
+ if not _has_tables(con):
513
+ return out
514
+ totals_sessions = _q(con, "SELECT COUNT(*) FROM transcript_sessions")
515
+ if not totals_sessions or not totals_sessions[0][0]:
516
+ return out
517
+ out["present"] = True
518
+ out["totals"] = {
519
+ "sessions": totals_sessions[0][0],
520
+ "messages": _q(con, "SELECT COUNT(*) FROM transcript_messages")[0][0],
521
+ "tool_calls": _q(con, "SELECT COUNT(*) FROM transcript_tool_calls")[0][0],
522
+ "agents": [
523
+ {"agent": a, "sessions": c}
524
+ for a, c in _q(
525
+ con,
526
+ "SELECT agent, COUNT(*) FROM transcript_sessions "
527
+ "GROUP BY agent ORDER BY COUNT(*) DESC",
528
+ )
529
+ ],
530
+ **cost_totals(con),
531
+ }
532
+ out["by_model"] = by_model(con)
533
+ out["by_day"] = by_day(con)
534
+ out["tool_mix"] = tool_mix(con)
535
+ out["sessions"] = session_analytics(con)
536
+ out["recent_sessions"] = recent_sessions(con)
537
+ out["compression"] = compression_savings(con)
538
+ return out
539
+
540
+
541
+ def main(argv: list[str] | None = None) -> int:
542
+ ap = argparse.ArgumentParser(prog="zeno-usage", description="Session-intelligence rollups")
543
+ base = os.environ.get("ZENO_HOME") or os.path.join(os.path.expanduser("~"), ".zeno")
544
+ default_db = os.environ.get("ZENO_DB_PATH") or os.path.join(base, "zeno.db")
545
+ ap.add_argument("--db", default=default_db)
546
+ ap.add_argument(
547
+ "--search",
548
+ default=None,
549
+ metavar="QUERY",
550
+ help="Full-text search transcript messages (bm25-ranked snippets) instead of rollups.",
551
+ )
552
+ ap.add_argument(
553
+ "--limit",
554
+ type=int,
555
+ default=20,
556
+ help="Max rows for --search (default 20).",
557
+ )
558
+ ap.add_argument(
559
+ "--compression",
560
+ action="store_true",
561
+ help="Print the compression-savings metric (human-readable) instead of the JSON rollup.",
562
+ )
563
+ args = ap.parse_args(argv)
564
+ try:
565
+ con = sqlite3.connect(f"file:{args.db}?mode=ro", uri=True)
566
+ except Exception:
567
+ json.dump({"present": False}, sys.stdout)
568
+ return 0
569
+ try:
570
+ if args.compression:
571
+ sys.stdout.write(render_compression_text(compression_savings(con)) + "\n")
572
+ else:
573
+ if args.search is not None:
574
+ payload = {
575
+ "query": args.search,
576
+ "results": search(con, args.search, limit=args.limit),
577
+ }
578
+ json.dump(payload, sys.stdout, indent=2)
579
+ else:
580
+ json.dump(build(con), sys.stdout, indent=2)
581
+ sys.stdout.write("\n")
582
+ finally:
583
+ con.close()
584
+ return 0
585
+
586
+
587
+ if __name__ == "__main__":
588
+ raise SystemExit(main())