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.
- zeno_adapters/__init__.py +17 -0
- zeno_adapters/_common.py +38 -0
- zeno_adapters/anthropic.py +68 -0
- zeno_adapters/claude_code.py +101 -0
- zeno_adapters/crewai.py +92 -0
- zeno_adapters/langgraph.py +49 -0
- zeno_adapters/openai.py +108 -0
- zeno_cli/__init__.py +1 -0
- zeno_cli/_hooks/cc_bridge.py +1016 -0
- zeno_cli/doctor.py +535 -0
- zeno_cli/hook_install.py +269 -0
- zeno_cli/hud/__init__.py +1 -0
- zeno_cli/hud/hud_install.py +652 -0
- zeno_cli/hud/zeno_attention.py +288 -0
- zeno_cli/hud/zeno_cognition.py +457 -0
- zeno_cli/hud/zeno_hud.py +496 -0
- zeno_cli/interview_invites.py +342 -0
- zeno_cli/login.py +241 -0
- zeno_cli/main.py +2534 -0
- zeno_cli/onboard.py +206 -0
- zeno_cli/outreach.py +456 -0
- zeno_cli/version.py +67 -0
- zeno_cli-0.3.4.dist-info/METADATA +161 -0
- zeno_cli-0.3.4.dist-info/RECORD +69 -0
- zeno_cli-0.3.4.dist-info/WHEEL +4 -0
- zeno_cli-0.3.4.dist-info/entry_points.txt +4 -0
- zeno_core/__init__.py +67 -0
- zeno_core/analytics.py +193 -0
- zeno_core/rtlx_s.py +460 -0
- zeno_core/streak.py +178 -0
- zeno_core/tlx_s.py +192 -0
- zeno_sdk/__init__.py +6 -0
- zeno_sdk/_generated/__init__.py +6 -0
- zeno_sdk/_generated/client.py +819 -0
- zeno_sdk/_migrations/alembic/env.py +33 -0
- zeno_sdk/_migrations/alembic/script.py.mako +18 -0
- zeno_sdk/_migrations/alembic/versions/0001_initial.py +79 -0
- zeno_sdk/_migrations/alembic/versions/0002_cognition_samples.py +53 -0
- zeno_sdk/_migrations/alembic/versions/0003_cognition_drivers.py +41 -0
- zeno_sdk/_migrations/alembic/versions/0004_transcript_intelligence.py +248 -0
- zeno_sdk/_migrations/alembic.ini +35 -0
- zeno_sdk/_runtime.py +12 -0
- zeno_sdk/adapters/__init__.py +15 -0
- zeno_sdk/adapters/anthropic.py +5 -0
- zeno_sdk/adapters/claude_code.py +5 -0
- zeno_sdk/adapters/crewai.py +5 -0
- zeno_sdk/adapters/langgraph.py +5 -0
- zeno_sdk/adapters/openai.py +5 -0
- zeno_sdk/auth.py +25 -0
- zeno_sdk/client.py +87 -0
- zeno_sdk/config.py +61 -0
- zeno_sdk/daemon.py +72 -0
- zeno_sdk/privacy.py +46 -0
- zeno_sdk/session.py +179 -0
- zeno_sdk/storage.py +487 -0
- zeno_sdk/types/__init__.py +121 -0
- zeno_session_intel/__init__.py +19 -0
- zeno_session_intel/analytics.py +588 -0
- zeno_session_intel/compression.py +123 -0
- zeno_session_intel/ingest.py +376 -0
- zeno_session_intel/model.py +129 -0
- zeno_session_intel/parsers/__init__.py +31 -0
- zeno_session_intel/parsers/claude_code.py +169 -0
- zeno_session_intel/parsers/codex.py +265 -0
- zeno_session_intel/parsers/cursor.py +198 -0
- zeno_session_intel/prices.py +281 -0
- zeno_session_intel/schema.py +277 -0
- zeno_session_intel/signals.py +319 -0
- 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())
|