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
zeno_sdk/storage.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import aiosqlite
|
|
12
|
+
from alembic import command
|
|
13
|
+
from alembic.config import Config
|
|
14
|
+
|
|
15
|
+
from .types import AgentRun, BabysittingTaxPoint, LoadProbe, Session, SupervisionEvent
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def utcnow() -> datetime:
|
|
19
|
+
return datetime.now(tz=UTC)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class SessionStats:
|
|
24
|
+
session_id: str
|
|
25
|
+
avg_n_agents: float
|
|
26
|
+
intervention_rate: float
|
|
27
|
+
accept_rate: float
|
|
28
|
+
idle_ratio: float
|
|
29
|
+
composite_load: float | None
|
|
30
|
+
output_quality: float | None
|
|
31
|
+
# Behavioral anchors for RTLX-S Stage 2a discriminant-validity check
|
|
32
|
+
# (research 3, 2026-06-10). Each is a raw count or seconds; the API
|
|
33
|
+
# passes them through to the rtlxs_responses row.
|
|
34
|
+
interrupt_count: int = 0
|
|
35
|
+
agent_turns_count: int = 0
|
|
36
|
+
verification_seconds: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Per-gap cap on inter-run intervals when estimating verification_seconds.
|
|
40
|
+
# A gap longer than this is presumed AFK (coffee, break, context switch) and
|
|
41
|
+
# clipped to the cap so a single forgotten terminal does not balloon the
|
|
42
|
+
# reviewer-time estimate. 10 minutes is generous for a real verification
|
|
43
|
+
# pause and conservative for an AFK boundary; tune at Stage 2b if needed.
|
|
44
|
+
_VERIFICATION_GAP_CAP_SECONDS: int = 600
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ZenoStorage:
|
|
48
|
+
def __init__(self, db_path: Path) -> None:
|
|
49
|
+
self.db_path = db_path
|
|
50
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
async def migrate(self) -> None:
|
|
53
|
+
await asyncio.to_thread(self._upgrade_to_head, self._alembic_config())
|
|
54
|
+
|
|
55
|
+
def _alembic_config(self) -> Config:
|
|
56
|
+
# Resolve the alembic scripts in BOTH layouts: the uv-workspace src tree
|
|
57
|
+
# (scripts two levels up at packages/sdk-python/alembic) and a built wheel
|
|
58
|
+
# (scripts bundled inside the package at zeno_sdk/_migrations via the
|
|
59
|
+
# zeno-cli force-include). Package-relative first, workspace as fallback;
|
|
60
|
+
# otherwise an installed `zeno status` cannot find the migrations.
|
|
61
|
+
bundled = Path(__file__).resolve().parent / "_migrations"
|
|
62
|
+
root = bundled if (bundled / "alembic").is_dir() else Path(__file__).resolve().parents[2]
|
|
63
|
+
config = Config(str(root / "alembic.ini"))
|
|
64
|
+
config.set_main_option("script_location", str(root / "alembic"))
|
|
65
|
+
config.set_main_option("sqlalchemy.url", f"sqlite:///{self.db_path}")
|
|
66
|
+
return config
|
|
67
|
+
|
|
68
|
+
def _upgrade_to_head(self, config: Config) -> None:
|
|
69
|
+
"""Apply migrations to head, self-healing a DB whose tables exist but
|
|
70
|
+
whose alembic_version is unset.
|
|
71
|
+
|
|
72
|
+
A migrate() interrupted mid-run leaves the DB wedged: SQLite auto-commits
|
|
73
|
+
each CREATE TABLE immediately, but alembic writes the version stamp only
|
|
74
|
+
after a whole revision succeeds. Kill the process in between and the
|
|
75
|
+
tables exist while alembic_version is empty, so a plain `alembic upgrade`
|
|
76
|
+
replays already-applied revisions and dies on "table ... already exists" -
|
|
77
|
+
which broke `zeno status` and every command that opens storage.
|
|
78
|
+
|
|
79
|
+
Recover without dropping data: find the latest revision whose tables are
|
|
80
|
+
ALL already present, stamp that revision, then `upgrade head` applies only
|
|
81
|
+
the genuinely-missing ones. Covers both a fully-built DB that lost its
|
|
82
|
+
stamp (stamp head, upgrade is a no-op) and a chain interrupted at any
|
|
83
|
+
revision (stamp the last complete one, apply the rest). The expensive
|
|
84
|
+
replay only runs on the rare unstamped-recovery path, never on a normally
|
|
85
|
+
stamped DB.
|
|
86
|
+
"""
|
|
87
|
+
import sqlite3
|
|
88
|
+
|
|
89
|
+
if self.db_path.exists():
|
|
90
|
+
conn = sqlite3.connect(self.db_path)
|
|
91
|
+
try:
|
|
92
|
+
existing = {
|
|
93
|
+
row[0]
|
|
94
|
+
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
95
|
+
if not row[0].startswith("sqlite_")
|
|
96
|
+
}
|
|
97
|
+
if "alembic_version" in existing:
|
|
98
|
+
stamped = conn.execute("SELECT COUNT(*) FROM alembic_version").fetchone()[0] > 0
|
|
99
|
+
else:
|
|
100
|
+
stamped = False
|
|
101
|
+
finally:
|
|
102
|
+
conn.close()
|
|
103
|
+
data_tables = existing - {"alembic_version"}
|
|
104
|
+
if data_tables and not stamped:
|
|
105
|
+
target = self._latest_satisfied_revision(config, existing)
|
|
106
|
+
if target is not None:
|
|
107
|
+
command.stamp(config, target)
|
|
108
|
+
command.upgrade(config, "head")
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _latest_satisfied_revision(config: Config, existing_tables: set[str]) -> str | None:
|
|
112
|
+
"""Return the newest revision whose cumulative table set is fully present
|
|
113
|
+
in ``existing_tables`` (so ``upgrade head`` then applies only the missing
|
|
114
|
+
revisions). Returns None if not even the base revision's tables are
|
|
115
|
+
present. Replays the chain on a throwaway DB to learn each revision's
|
|
116
|
+
cumulative table set; migrations are additive, so the sets are nested and
|
|
117
|
+
the first unsatisfied revision ends the search.
|
|
118
|
+
"""
|
|
119
|
+
import sqlite3
|
|
120
|
+
import tempfile
|
|
121
|
+
|
|
122
|
+
from alembic.script import ScriptDirectory
|
|
123
|
+
|
|
124
|
+
script = ScriptDirectory.from_config(config)
|
|
125
|
+
revisions = [rev.revision for rev in reversed(list(script.walk_revisions()))]
|
|
126
|
+
|
|
127
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
|
128
|
+
tmp.close()
|
|
129
|
+
probe_path = Path(tmp.name)
|
|
130
|
+
try:
|
|
131
|
+
probe = Config(config.config_file_name)
|
|
132
|
+
probe.set_main_option("script_location", config.get_main_option("script_location"))
|
|
133
|
+
probe.set_main_option("sqlalchemy.url", f"sqlite:///{probe_path}")
|
|
134
|
+
target: str | None = None
|
|
135
|
+
for revision in revisions:
|
|
136
|
+
command.upgrade(probe, revision)
|
|
137
|
+
conn = sqlite3.connect(probe_path)
|
|
138
|
+
try:
|
|
139
|
+
rev_tables = {
|
|
140
|
+
row[0]
|
|
141
|
+
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
142
|
+
if not row[0].startswith("sqlite_")
|
|
143
|
+
} - {"alembic_version"}
|
|
144
|
+
finally:
|
|
145
|
+
conn.close()
|
|
146
|
+
if rev_tables <= existing_tables:
|
|
147
|
+
target = revision
|
|
148
|
+
else:
|
|
149
|
+
break
|
|
150
|
+
return target
|
|
151
|
+
finally:
|
|
152
|
+
probe_path.unlink(missing_ok=True)
|
|
153
|
+
|
|
154
|
+
async def insert_session(self, session: Session) -> None:
|
|
155
|
+
await self._execute(
|
|
156
|
+
"""
|
|
157
|
+
INSERT OR REPLACE INTO sessions (
|
|
158
|
+
id, start_at, end_at, agent_count_max, harness, project_id, synced_at
|
|
159
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
160
|
+
""",
|
|
161
|
+
(
|
|
162
|
+
str(session.id),
|
|
163
|
+
session.start_at.isoformat(),
|
|
164
|
+
session.end_at.isoformat() if session.end_at else None,
|
|
165
|
+
session.agent_count_max,
|
|
166
|
+
session.harness,
|
|
167
|
+
str(session.project_id),
|
|
168
|
+
None,
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def insert_agent_run(self, run: AgentRun) -> None:
|
|
173
|
+
await self._execute(
|
|
174
|
+
"""
|
|
175
|
+
INSERT OR REPLACE INTO agent_runs (
|
|
176
|
+
id, session_id, harness, model, started_at, ended_at, outcome, synced_at
|
|
177
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
178
|
+
""",
|
|
179
|
+
(
|
|
180
|
+
str(run.id),
|
|
181
|
+
str(run.session_id),
|
|
182
|
+
run.harness,
|
|
183
|
+
run.model,
|
|
184
|
+
run.started_at.isoformat(),
|
|
185
|
+
run.ended_at.isoformat() if run.ended_at else None,
|
|
186
|
+
run.outcome,
|
|
187
|
+
None,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async def insert_event(self, event: SupervisionEvent) -> None:
|
|
192
|
+
await self._execute(
|
|
193
|
+
"""
|
|
194
|
+
INSERT OR REPLACE INTO supervision_events (
|
|
195
|
+
id, session_id, agent_run_id, type, timestamp,
|
|
196
|
+
latency_ms_to_decide, metadata_json, synced_at
|
|
197
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
198
|
+
""",
|
|
199
|
+
(
|
|
200
|
+
str(event.id),
|
|
201
|
+
str(event.session_id),
|
|
202
|
+
str(event.agent_run_id) if event.agent_run_id else None,
|
|
203
|
+
event.type,
|
|
204
|
+
event.timestamp.isoformat(),
|
|
205
|
+
event.latency_ms_to_decide,
|
|
206
|
+
json.dumps(event.metadata or {}, sort_keys=True),
|
|
207
|
+
None,
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
async def insert_load_probe(self, probe: LoadProbe) -> None:
|
|
212
|
+
await self._execute(
|
|
213
|
+
"""
|
|
214
|
+
INSERT OR REPLACE INTO load_probes (
|
|
215
|
+
id, session_id, prompted_at, responded_at, skipped, subscales_json, synced_at
|
|
216
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
217
|
+
""",
|
|
218
|
+
(
|
|
219
|
+
str(probe.id),
|
|
220
|
+
str(probe.session_id),
|
|
221
|
+
probe.prompted_at.isoformat(),
|
|
222
|
+
probe.responded_at.isoformat() if probe.responded_at else None,
|
|
223
|
+
int(probe.skipped),
|
|
224
|
+
json.dumps(
|
|
225
|
+
probe.subscales.model_dump(exclude_none=True) if probe.subscales else None
|
|
226
|
+
),
|
|
227
|
+
None,
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
async def upsert_tax_point(self, point: BabysittingTaxPoint) -> None:
|
|
232
|
+
await self._execute(
|
|
233
|
+
"""
|
|
234
|
+
INSERT OR REPLACE INTO babysitting_tax_points (
|
|
235
|
+
id, session_id, n_agents_active, composite_load, output_quality, synced_at
|
|
236
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
237
|
+
""",
|
|
238
|
+
(
|
|
239
|
+
str(point.id),
|
|
240
|
+
str(point.session_id),
|
|
241
|
+
point.n_agents_active,
|
|
242
|
+
point.composite_load,
|
|
243
|
+
point.output_quality,
|
|
244
|
+
None,
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
async def get_events_for_session(self, session_id: str) -> list[dict[str, Any]]:
|
|
249
|
+
rows = await self._fetchall(
|
|
250
|
+
"""
|
|
251
|
+
SELECT id, session_id, agent_run_id, type, timestamp,
|
|
252
|
+
latency_ms_to_decide, metadata_json
|
|
253
|
+
FROM supervision_events
|
|
254
|
+
WHERE session_id = ?
|
|
255
|
+
ORDER BY timestamp ASC
|
|
256
|
+
""",
|
|
257
|
+
(session_id,),
|
|
258
|
+
)
|
|
259
|
+
return [
|
|
260
|
+
{
|
|
261
|
+
"id": row[0],
|
|
262
|
+
"session_id": row[1],
|
|
263
|
+
"agent_run_id": row[2],
|
|
264
|
+
"type": row[3],
|
|
265
|
+
"timestamp": row[4],
|
|
266
|
+
"latency_ms_to_decide": row[5],
|
|
267
|
+
"metadata": json.loads(row[6] or "{}"),
|
|
268
|
+
}
|
|
269
|
+
for row in rows
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
async def get_tax_curve(self, project_id: str) -> list[dict[str, Any]]:
|
|
273
|
+
rows = await self._fetchall(
|
|
274
|
+
"""
|
|
275
|
+
SELECT b.n_agents_active, AVG(b.composite_load), AVG(b.output_quality)
|
|
276
|
+
FROM babysitting_tax_points b
|
|
277
|
+
JOIN sessions s ON s.id = b.session_id
|
|
278
|
+
WHERE s.project_id = ?
|
|
279
|
+
GROUP BY b.n_agents_active
|
|
280
|
+
ORDER BY b.n_agents_active ASC
|
|
281
|
+
""",
|
|
282
|
+
(project_id,),
|
|
283
|
+
)
|
|
284
|
+
return [
|
|
285
|
+
{
|
|
286
|
+
"n_agents_active": row[0],
|
|
287
|
+
"composite_load": float(row[1]),
|
|
288
|
+
"output_quality": float(row[2]) if row[2] is not None else None,
|
|
289
|
+
}
|
|
290
|
+
for row in rows
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
async def get_session_points(self, project_id: str) -> list[dict[str, Any]]:
|
|
294
|
+
rows = await self._fetchall(
|
|
295
|
+
"""
|
|
296
|
+
SELECT b.session_id, b.n_agents_active, b.composite_load, b.output_quality, s.end_at
|
|
297
|
+
FROM babysitting_tax_points b
|
|
298
|
+
JOIN sessions s ON s.id = b.session_id
|
|
299
|
+
WHERE s.project_id = ?
|
|
300
|
+
ORDER BY s.end_at ASC
|
|
301
|
+
""",
|
|
302
|
+
(project_id,),
|
|
303
|
+
)
|
|
304
|
+
return [
|
|
305
|
+
{
|
|
306
|
+
"session_id": row[0],
|
|
307
|
+
"n_agents_active": float(row[1]),
|
|
308
|
+
"composite_load": float(row[2]),
|
|
309
|
+
"output_quality": float(row[3]) if row[3] is not None else None,
|
|
310
|
+
"ended_at": row[4],
|
|
311
|
+
}
|
|
312
|
+
for row in rows
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
async def latest_session_id(self, project_id: str) -> str | None:
|
|
316
|
+
row = await self._fetchone(
|
|
317
|
+
"""
|
|
318
|
+
SELECT id
|
|
319
|
+
FROM sessions
|
|
320
|
+
WHERE project_id = ?
|
|
321
|
+
ORDER BY end_at DESC
|
|
322
|
+
LIMIT 1
|
|
323
|
+
""",
|
|
324
|
+
(project_id,),
|
|
325
|
+
)
|
|
326
|
+
if row is None:
|
|
327
|
+
return None
|
|
328
|
+
return str(row[0])
|
|
329
|
+
|
|
330
|
+
async def compute_session_stats(self, session_id: str) -> SessionStats:
|
|
331
|
+
event_rows = await self._fetchall(
|
|
332
|
+
"""
|
|
333
|
+
SELECT type FROM supervision_events WHERE session_id = ?
|
|
334
|
+
""",
|
|
335
|
+
(session_id,),
|
|
336
|
+
)
|
|
337
|
+
n_events = len(event_rows)
|
|
338
|
+
interventions = sum(1 for row in event_rows if row[0] == "intervene")
|
|
339
|
+
accepts = sum(1 for row in event_rows if row[0] == "accept")
|
|
340
|
+
idle = sum(1 for row in event_rows if row[0] in {"idle_start", "idle_end"})
|
|
341
|
+
|
|
342
|
+
session_row = await self._fetchone(
|
|
343
|
+
"SELECT agent_count_max FROM sessions WHERE id = ?",
|
|
344
|
+
(session_id,),
|
|
345
|
+
)
|
|
346
|
+
run_count_row = await self._fetchone(
|
|
347
|
+
"SELECT COUNT(*) FROM agent_runs WHERE session_id = ?",
|
|
348
|
+
(session_id,),
|
|
349
|
+
)
|
|
350
|
+
persisted_agents = float(session_row[0]) if session_row else 0.0
|
|
351
|
+
observed_agents = float(run_count_row[0]) if run_count_row else 0.0
|
|
352
|
+
avg_n_agents = max(persisted_agents, observed_agents)
|
|
353
|
+
|
|
354
|
+
# Behavioral anchors for RTLX-S Stage 2a (research 3, 2026-06-10).
|
|
355
|
+
# verification_seconds is the sum of inter-run gaps (between the end
|
|
356
|
+
# of one agent_run and the start of the next), capped per gap to
|
|
357
|
+
# avoid counting AFK time. Stage 2 validation tests whether this
|
|
358
|
+
# proxy correlates positively with supervision_load.
|
|
359
|
+
agent_turns_count = int(observed_agents)
|
|
360
|
+
gap_rows = await self._fetchall(
|
|
361
|
+
"""
|
|
362
|
+
SELECT started_at, ended_at FROM agent_runs
|
|
363
|
+
WHERE session_id = ? AND ended_at IS NOT NULL
|
|
364
|
+
ORDER BY started_at ASC
|
|
365
|
+
""",
|
|
366
|
+
(session_id,),
|
|
367
|
+
)
|
|
368
|
+
verification_seconds = 0
|
|
369
|
+
last_ended_at: datetime | None = None
|
|
370
|
+
for started_raw, ended_raw in gap_rows:
|
|
371
|
+
try:
|
|
372
|
+
started = datetime.fromisoformat(started_raw)
|
|
373
|
+
ended = datetime.fromisoformat(ended_raw)
|
|
374
|
+
except (TypeError, ValueError):
|
|
375
|
+
continue
|
|
376
|
+
if last_ended_at is not None:
|
|
377
|
+
gap = (started - last_ended_at).total_seconds()
|
|
378
|
+
if gap > 0:
|
|
379
|
+
verification_seconds += int(min(gap, _VERIFICATION_GAP_CAP_SECONDS))
|
|
380
|
+
last_ended_at = ended
|
|
381
|
+
|
|
382
|
+
load_row = await self._fetchone(
|
|
383
|
+
"""
|
|
384
|
+
SELECT subscales_json
|
|
385
|
+
FROM load_probes
|
|
386
|
+
WHERE session_id = ?
|
|
387
|
+
ORDER BY prompted_at DESC
|
|
388
|
+
LIMIT 1
|
|
389
|
+
""",
|
|
390
|
+
(session_id,),
|
|
391
|
+
)
|
|
392
|
+
composite_load: float | None = None
|
|
393
|
+
if load_row and load_row[0]:
|
|
394
|
+
payload = json.loads(load_row[0])
|
|
395
|
+
if isinstance(payload, dict):
|
|
396
|
+
# Average only numeric subscale values; tolerate nulls / mixed
|
|
397
|
+
# instruments (RTLX-S 5-item vs legacy 10-item) in the column.
|
|
398
|
+
vals = [v for v in payload.values() if isinstance(v, (int, float))]
|
|
399
|
+
composite_load = sum(vals) / len(vals) if vals else None
|
|
400
|
+
|
|
401
|
+
run_rows = await self._fetchall(
|
|
402
|
+
"SELECT outcome FROM agent_runs WHERE session_id = ?",
|
|
403
|
+
(session_id,),
|
|
404
|
+
)
|
|
405
|
+
total_runs = len(run_rows)
|
|
406
|
+
quality_score = None
|
|
407
|
+
if total_runs:
|
|
408
|
+
quality_score = (
|
|
409
|
+
sum(1 for row in run_rows if row[0] in {"accepted", "merged"}) / total_runs
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
return SessionStats(
|
|
413
|
+
session_id=session_id,
|
|
414
|
+
avg_n_agents=avg_n_agents,
|
|
415
|
+
intervention_rate=interventions / n_events if n_events else 0.0,
|
|
416
|
+
accept_rate=accepts / n_events if n_events else 0.0,
|
|
417
|
+
idle_ratio=idle / n_events if n_events else 0.0,
|
|
418
|
+
composite_load=composite_load,
|
|
419
|
+
output_quality=quality_score,
|
|
420
|
+
interrupt_count=interventions,
|
|
421
|
+
agent_turns_count=agent_turns_count,
|
|
422
|
+
verification_seconds=verification_seconds,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
async def find_recent_activity_session_id(self, *, window_minutes: int = 120) -> str | None:
|
|
426
|
+
"""Most-recent session_id with at least one agent_run, in window.
|
|
427
|
+
|
|
428
|
+
Used by the survey CLI to attach behavioral anchors to the RTLX-S
|
|
429
|
+
row (research 3, 2026-06-10). The survey command opens a fresh
|
|
430
|
+
session for its own bookkeeping, so the work-session telemetry has
|
|
431
|
+
to be looked up explicitly. Defaults to a 2-hour window so a survey
|
|
432
|
+
immediately after a long work session still resolves cleanly; tune
|
|
433
|
+
if a stale lookup ever attaches anchors to the wrong session.
|
|
434
|
+
|
|
435
|
+
Returns None if no qualifying session is found.
|
|
436
|
+
"""
|
|
437
|
+
cutoff = datetime.now(tz=UTC).timestamp() - window_minutes * 60
|
|
438
|
+
row = await self._fetchone(
|
|
439
|
+
"""
|
|
440
|
+
SELECT s.id
|
|
441
|
+
FROM sessions s
|
|
442
|
+
JOIN agent_runs r ON r.session_id = s.id
|
|
443
|
+
WHERE s.start_at >= ?
|
|
444
|
+
GROUP BY s.id
|
|
445
|
+
HAVING COUNT(r.id) > 0
|
|
446
|
+
ORDER BY MAX(r.started_at) DESC
|
|
447
|
+
LIMIT 1
|
|
448
|
+
""",
|
|
449
|
+
(datetime.fromtimestamp(cutoff, tz=UTC).isoformat(),),
|
|
450
|
+
)
|
|
451
|
+
if row is None:
|
|
452
|
+
return None
|
|
453
|
+
return str(row[0])
|
|
454
|
+
|
|
455
|
+
async def list_tables(self) -> list[str]:
|
|
456
|
+
rows = await self._fetchall(
|
|
457
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
|
|
458
|
+
(),
|
|
459
|
+
)
|
|
460
|
+
return [row[0] for row in rows]
|
|
461
|
+
|
|
462
|
+
async def _execute(self, query: str, params: tuple[Any, ...]) -> None:
|
|
463
|
+
async with aiosqlite.connect(self.db_path) as conn:
|
|
464
|
+
await conn.execute(query, params)
|
|
465
|
+
await conn.commit()
|
|
466
|
+
|
|
467
|
+
async def _fetchall(self, query: str, params: tuple[Any, ...]) -> list[tuple[Any, ...]]:
|
|
468
|
+
async with aiosqlite.connect(self.db_path) as conn:
|
|
469
|
+
cursor = await conn.execute(query, params)
|
|
470
|
+
rows = await cursor.fetchall()
|
|
471
|
+
return rows
|
|
472
|
+
|
|
473
|
+
async def _fetchone(self, query: str, params: tuple[Any, ...]) -> tuple[Any, ...] | None:
|
|
474
|
+
async with aiosqlite.connect(self.db_path) as conn:
|
|
475
|
+
cursor = await conn.execute(query, params)
|
|
476
|
+
return await cursor.fetchone()
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def make_tax_point(session_id: str, stats: SessionStats) -> BabysittingTaxPoint:
|
|
480
|
+
composite = stats.composite_load if stats.composite_load is not None else 0.0
|
|
481
|
+
return BabysittingTaxPoint(
|
|
482
|
+
id=uuid.uuid4(),
|
|
483
|
+
session_id=uuid.UUID(session_id),
|
|
484
|
+
n_agents_active=max(0, int(round(stats.avg_n_agents))),
|
|
485
|
+
composite_load=composite,
|
|
486
|
+
output_quality=stats.output_quality,
|
|
487
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AgentRun(BaseModel):
|
|
11
|
+
model_config = ConfigDict(extra="forbid")
|
|
12
|
+
id: UUID
|
|
13
|
+
session_id: UUID
|
|
14
|
+
harness: str
|
|
15
|
+
model: str
|
|
16
|
+
started_at: datetime
|
|
17
|
+
ended_at: datetime | None = None
|
|
18
|
+
outcome: Literal["accepted", "rejected", "edited", "discarded", "merged", "unknown"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BabysittingTaxPoint(BaseModel):
|
|
22
|
+
model_config = ConfigDict(extra="forbid")
|
|
23
|
+
id: UUID
|
|
24
|
+
session_id: UUID
|
|
25
|
+
n_agents_active: int = Field(ge=0)
|
|
26
|
+
composite_load: float = Field(ge=0)
|
|
27
|
+
output_quality: float | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LoadProbeSubscales(BaseModel):
|
|
31
|
+
model_config = ConfigDict(extra="forbid")
|
|
32
|
+
mental_demand: int | None = Field(default=None, ge=0, le=100)
|
|
33
|
+
effort: int | None = Field(default=None, ge=0, le=100)
|
|
34
|
+
frustration: int | None = Field(default=None, ge=0, le=100)
|
|
35
|
+
supervision_load: int | None = Field(default=None, ge=0, le=100)
|
|
36
|
+
execution_load: int | None = Field(default=None, ge=0, le=100)
|
|
37
|
+
physical_demand: int | None = Field(default=None, ge=0, le=100)
|
|
38
|
+
temporal_demand: int | None = Field(default=None, ge=0, le=100)
|
|
39
|
+
performance: int | None = Field(default=None, ge=0, le=100)
|
|
40
|
+
trust_calibration_demand: int | None = Field(default=None, ge=0, le=100)
|
|
41
|
+
oversight_demand: int | None = Field(default=None, ge=0, le=100)
|
|
42
|
+
integration_demand: int | None = Field(default=None, ge=0, le=100)
|
|
43
|
+
intervention_decision_demand: int | None = Field(default=None, ge=0, le=100)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LoadProbe(BaseModel):
|
|
47
|
+
model_config = ConfigDict(extra="forbid")
|
|
48
|
+
id: UUID
|
|
49
|
+
session_id: UUID
|
|
50
|
+
prompted_at: datetime
|
|
51
|
+
responded_at: datetime | None = None
|
|
52
|
+
skipped: bool
|
|
53
|
+
subscales: LoadProbeSubscales | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OutcomeMetric(BaseModel):
|
|
57
|
+
model_config = ConfigDict(extra="forbid")
|
|
58
|
+
id: UUID
|
|
59
|
+
agent_run_id: UUID
|
|
60
|
+
tests_passed: bool
|
|
61
|
+
merged: bool
|
|
62
|
+
reverted: bool
|
|
63
|
+
time_to_resolve_ms: int | None = Field(default=None, ge=0)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Project(BaseModel):
|
|
67
|
+
model_config = ConfigDict(extra="forbid")
|
|
68
|
+
id: UUID
|
|
69
|
+
name: str
|
|
70
|
+
created_at: datetime
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Session(BaseModel):
|
|
74
|
+
model_config = ConfigDict(extra="forbid")
|
|
75
|
+
id: UUID
|
|
76
|
+
start_at: datetime
|
|
77
|
+
end_at: datetime | None = None
|
|
78
|
+
agent_count_max: int = Field(ge=1)
|
|
79
|
+
harness: str
|
|
80
|
+
project_id: UUID
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SupervisionEvent(BaseModel):
|
|
84
|
+
model_config = ConfigDict(extra="forbid")
|
|
85
|
+
id: UUID
|
|
86
|
+
session_id: UUID
|
|
87
|
+
agent_run_id: UUID | None = None
|
|
88
|
+
type: Literal[
|
|
89
|
+
"accept",
|
|
90
|
+
"reject",
|
|
91
|
+
"edit",
|
|
92
|
+
"intervene",
|
|
93
|
+
"switch_focus",
|
|
94
|
+
"idle_start",
|
|
95
|
+
"idle_end",
|
|
96
|
+
"agent_spawn",
|
|
97
|
+
"agent_terminate",
|
|
98
|
+
]
|
|
99
|
+
timestamp: datetime
|
|
100
|
+
latency_ms_to_decide: int | None = Field(default=None, ge=0)
|
|
101
|
+
metadata: dict[str, Any] | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class User(BaseModel):
|
|
105
|
+
model_config = ConfigDict(extra="forbid")
|
|
106
|
+
id: UUID
|
|
107
|
+
email: str
|
|
108
|
+
tier: Literal["free", "pro", "team"]
|
|
109
|
+
created_at: datetime
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
__all__ = [
|
|
113
|
+
"AgentRun",
|
|
114
|
+
"BabysittingTaxPoint",
|
|
115
|
+
"LoadProbe",
|
|
116
|
+
"OutcomeMetric",
|
|
117
|
+
"Project",
|
|
118
|
+
"Session",
|
|
119
|
+
"SupervisionEvent",
|
|
120
|
+
"User",
|
|
121
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""zeno-session-intel: passive multi-tool coding-agent session intelligence.
|
|
2
|
+
|
|
3
|
+
A read-only ingester that parses coding-agent session files (Claude Code, Codex)
|
|
4
|
+
into zeno's local SQLite using an agentsview-shaped schema, plus ccusage-class
|
|
5
|
+
analytics (cache-aware token/cost, tool-mix, session archetypes, peak-context).
|
|
6
|
+
|
|
7
|
+
Stdlib-only, zero runtime dependencies: importable by the SDK/CLI, by tests, and
|
|
8
|
+
by the dashboard's stdlib export scripts alike. Writes via the canonical schema in
|
|
9
|
+
``schema.py`` (mirrored by alembic ``0004_transcript_intelligence`` and guarded by a
|
|
10
|
+
schema-drift test). The capture is ADDITIVE: it sits alongside zeno's per-turn
|
|
11
|
+
cognition signal (the cc-bridge hook), never replacing it.
|
|
12
|
+
|
|
13
|
+
Session-intelligence schema + analytics design is adapted from agentsview
|
|
14
|
+
(MIT-licensed). See THIRD_PARTY_LICENSES.md.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__all__ = ["__version__"]
|
|
18
|
+
|
|
19
|
+
__version__ = "0.0.0"
|