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
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"