tokenol 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {tokenol-0.2.0 → tokenol-0.3.0}/CHANGELOG.md +29 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/PKG-INFO +1 -1
- {tokenol-0.2.0 → tokenol-0.3.0}/pyproject.toml +1 -1
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/__init__.py +1 -1
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/ingest/parser.py +0 -1
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/model/events.py +0 -3
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/app.py +40 -19
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/state.py +74 -11
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/app.js +6 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/index.html +1 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/styles.css +9 -0
- tokenol-0.3.0/src/tokenol/serve/streaming.py +245 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_serve_app.py +8 -4
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_serve_state.py +66 -1
- tokenol-0.3.0/tests/test_serve_streaming.py +345 -0
- tokenol-0.2.0/src/tokenol/serve/streaming.py +0 -63
- tokenol-0.2.0/tests/test_serve_streaming.py +0 -188
- {tokenol-0.2.0 → tokenol-0.3.0}/.claude/settings.local.json +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/.claudesignore +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/.github/workflows/ci.yml +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/.gitignore +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/README.md +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/docs/ASSUMPTIONS.md +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/docs/METRICS.md +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/docs/screenshots/breakdown_bottom.jpg +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/docs/screenshots/breakdown_top.jpg +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/docs/screenshots/main.jpg +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/docs/screenshots/project.jpg +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/docs/screenshots/session_bottom.jpg +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/docs/screenshots/session_top.jpg +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/docs/superpowers/plans/2026-04-24-breakdown-pr1.md +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/docs/superpowers/plans/2026-04-24-breakdown-pr2.md +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/docs/superpowers/plans/2026-04-24-breakdown-pr3.md +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/docs/superpowers/specs/2026-04-24-breakdown-tab-design.md +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/assumptions.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/cli.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/enums.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/ingest/__init__.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/ingest/builder.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/ingest/discovery.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/ingest/schema.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/__init__.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/context.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/cost.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/history.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/patterns.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/rollups.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/thresholds.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/verdicts.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/windows.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/model/__init__.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/model/pricing.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/model/registry.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/report/__init__.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/report/text.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/__init__.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/prefs.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/session_detail.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/breakdown.html +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/breakdown.js +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/chart.js +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/components.js +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/day.html +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/day.js +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/model.html +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/model.js +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/project.html +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/project.js +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/session.html +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/session.js +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/tool.html +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/tool.js +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/conftest.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/basic.jsonl +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/dedup.jsonl +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/gemini.jsonl +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/interrupted.jsonl +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/missing_ids.jsonl +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/multi.jsonl +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/sidechain.jsonl +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_cli.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_context_metrics.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_dedup.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_history_and_thresholds.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_metrics.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_parser.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_patterns.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_serve_prefs.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_session_detail.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_verdicts.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_windows.py +0 -0
- {tokenol-0.2.0 → tokenol-0.3.0}/uv.lock +0 -0
|
@@ -4,6 +4,35 @@ All notable changes to tokenol are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.0] — 2026-04-27
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- **`tokenol serve` resource use slashed.** On a real session-history workload,
|
|
11
|
+
steady-state RSS drops ~8× (from ~4 GiB to ~500 MiB) and idle CPU falls to
|
|
12
|
+
near zero between heartbeats. Multi-tab dashboards now share a single
|
|
13
|
+
background producer, so adding tabs does not multiply server CPU.
|
|
14
|
+
- The SSE stream (`/api/stream`) is driven by `SnapshotBroadcaster`: one task
|
|
15
|
+
per `period` fans payloads out to N subscribers, each maintaining its own
|
|
16
|
+
shallow-diff state. The wire format is unchanged.
|
|
17
|
+
- The producer now gates rebuilds on JSONL file `(path, size, mtime_ns)`
|
|
18
|
+
changes, with a configurable heartbeat (default 60 s) so time-windowed
|
|
19
|
+
panels (`recent_activity`, day boundaries) stay reasonably fresh. Trade-off:
|
|
20
|
+
panels may lag wall-clock by up to the heartbeat between file writes.
|
|
21
|
+
- `ParseCache` now memoizes derived `(turns, sessions, fired)` keyed on the
|
|
22
|
+
active file-key set; idle ticks skip the per-tick `_build_turns_and_sessions`
|
|
23
|
+
rebuild entirely.
|
|
24
|
+
- `_build_turns_and_sessions` now returns the per-build assumption-fired
|
|
25
|
+
`Counter` instead of mutating the global `assumption_recorder`.
|
|
26
|
+
- `create_app` migrated from the deprecated `@app.on_event("shutdown")` to a
|
|
27
|
+
lifespan context manager.
|
|
28
|
+
|
|
29
|
+
### Removed
|
|
30
|
+
- **`RawEvent.raw` field.** Was populated by the parser with the full JSON
|
|
31
|
+
dict (message bodies, tool I/O) for "extensibility" but read by no
|
|
32
|
+
downstream code. Removing it is the dominant memory win. Code that wants
|
|
33
|
+
raw JSON should re-read from disk (as `serve/session_detail.py` already
|
|
34
|
+
does).
|
|
35
|
+
|
|
7
36
|
## [0.2.0] — 2026-04-25
|
|
8
37
|
|
|
9
38
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tokenol
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Audit Claude Code JSONL session logs: track cost, cache health, context blow-ups, and 5h-window pressure.
|
|
5
5
|
Project-URL: Homepage, https://github.com/farhanferoz/tokenol
|
|
6
6
|
Project-URL: Issues, https://github.com/farhanferoz/tokenol/issues
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tokenol"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Audit Claude Code JSONL session logs: track cost, cache health, context blow-ups, and 5h-window pressure."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
from collections import Counter
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
from datetime import date, datetime, timezone
|
|
9
10
|
from pathlib import Path
|
|
@@ -37,6 +38,7 @@ from tokenol.serve.state import (
|
|
|
37
38
|
encode_cwd,
|
|
38
39
|
range_since,
|
|
39
40
|
)
|
|
41
|
+
from tokenol.serve.streaming import SnapshotBroadcaster
|
|
40
42
|
|
|
41
43
|
_WINDOW_MINUTES: dict[str, int] = {"15m": 15, "60m": 60, "4h": 240, "24h": 1440}
|
|
42
44
|
_KNOWN_PREFS_KEYS: frozenset[str] = frozenset({"tick_seconds", "reference_usd", "thresholds"})
|
|
@@ -121,12 +123,29 @@ def create_app(
|
|
|
121
123
|
_prefs_path = prefs_path or default_path()
|
|
122
124
|
prefs = Preferences.load(_prefs_path)
|
|
123
125
|
|
|
124
|
-
|
|
126
|
+
parse_cache = ParseCache()
|
|
127
|
+
broadcaster = SnapshotBroadcaster(
|
|
128
|
+
parse_cache=parse_cache,
|
|
129
|
+
all_projects=config.all_projects,
|
|
130
|
+
get_reference_usd=lambda: prefs.reference_usd,
|
|
131
|
+
get_tick_seconds=lambda: prefs.tick_seconds,
|
|
132
|
+
get_thresholds=lambda: prefs.thresholds,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@asynccontextmanager
|
|
136
|
+
async def lifespan(_app: FastAPI):
|
|
137
|
+
try:
|
|
138
|
+
yield
|
|
139
|
+
finally:
|
|
140
|
+
await broadcaster.shutdown()
|
|
141
|
+
|
|
142
|
+
app = FastAPI(title="tokenol", lifespan=lifespan)
|
|
125
143
|
app.state.config = config
|
|
126
144
|
app.state.prefs = prefs
|
|
127
145
|
app.state.prefs_path = _prefs_path
|
|
128
|
-
app.state.parse_cache =
|
|
146
|
+
app.state.parse_cache = parse_cache
|
|
129
147
|
app.state.snapshot_result = None
|
|
148
|
+
app.state.broadcaster = broadcaster
|
|
130
149
|
|
|
131
150
|
if STATIC_DIR.exists():
|
|
132
151
|
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR)), name="assets")
|
|
@@ -220,21 +239,10 @@ def create_app(
|
|
|
220
239
|
|
|
221
240
|
@app.get("/api/stream")
|
|
222
241
|
async def api_stream(request: Request, period: str = "today"):
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
cfg: ServerConfig = request.app.state.config
|
|
226
|
-
prefs: Preferences = request.app.state.prefs
|
|
227
|
-
cache: ParseCache = request.app.state.parse_cache
|
|
242
|
+
broadcaster: SnapshotBroadcaster = request.app.state.broadcaster
|
|
228
243
|
|
|
229
244
|
async def event_generator():
|
|
230
|
-
async for chunk in
|
|
231
|
-
parse_cache=cache,
|
|
232
|
-
all_projects=cfg.all_projects,
|
|
233
|
-
reference_usd=prefs.reference_usd,
|
|
234
|
-
get_tick_seconds=lambda: prefs.tick_seconds,
|
|
235
|
-
period=period,
|
|
236
|
-
thresholds=prefs.thresholds,
|
|
237
|
-
):
|
|
245
|
+
async for chunk in broadcaster.subscribe(period):
|
|
238
246
|
if await request.is_disconnected():
|
|
239
247
|
break
|
|
240
248
|
yield chunk
|
|
@@ -275,6 +283,13 @@ def create_app(
|
|
|
275
283
|
if range not in ("7d", "30d", "90d", "all"):
|
|
276
284
|
raise HTTPException(status_code=400, detail="range must be 7d, 30d, 90d, or all")
|
|
277
285
|
result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
|
|
286
|
+
# Fall back silently to the longest available window when the requested range
|
|
287
|
+
# exceeds the data we have — return 200 with a `note` so the UI can caption it.
|
|
288
|
+
# Returning 400 here forced clients to special-case "policy" failures even though
|
|
289
|
+
# the request itself is well-formed.
|
|
290
|
+
effective_range = range
|
|
291
|
+
note: str | None = None
|
|
292
|
+
have_days: int | None = None
|
|
278
293
|
if range != "all" and result.turns:
|
|
279
294
|
today = date.today()
|
|
280
295
|
since = range_since(range, today)
|
|
@@ -282,11 +297,17 @@ def create_app(
|
|
|
282
297
|
earliest = min(t.timestamp.date() for t in result.turns)
|
|
283
298
|
if earliest > since:
|
|
284
299
|
have_days = (today - earliest).days + 1
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
300
|
+
effective_range = "all"
|
|
301
|
+
note = (
|
|
302
|
+
f"Only {have_days} days of history available — "
|
|
303
|
+
f"showing all data instead of {range}."
|
|
288
304
|
)
|
|
289
|
-
|
|
305
|
+
panel = build_daily_panel(result.turns, result.sessions, effective_range, metric, project, model)
|
|
306
|
+
if note is not None:
|
|
307
|
+
panel["requested_range"] = range
|
|
308
|
+
panel["have_days"] = have_days
|
|
309
|
+
panel["note"] = note
|
|
310
|
+
return JSONResponse(panel)
|
|
290
311
|
|
|
291
312
|
@app.get("/api/models")
|
|
292
313
|
async def api_models(request: Request, range: str = "today"):
|
|
@@ -19,7 +19,6 @@ from dataclasses import dataclass, field
|
|
|
19
19
|
from datetime import date, datetime, timedelta, timezone
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
|
|
22
|
-
from tokenol import assumptions as assumption_recorder
|
|
23
22
|
from tokenol.enums import AssumptionTag, BlowUpVerdict
|
|
24
23
|
from tokenol.ingest.discovery import find_jsonl_files, get_config_dirs
|
|
25
24
|
from tokenol.ingest.parser import dedup_key, parse_file
|
|
@@ -51,10 +50,17 @@ class ParseCache:
|
|
|
51
50
|
"""JSONL parse cache keyed by (path_str, size, mtime_ns).
|
|
52
51
|
|
|
53
52
|
All public methods are thread-safe via an internal lock.
|
|
53
|
+
|
|
54
|
+
Also memoizes the derived (turns, sessions) tuple keyed on the active set of
|
|
55
|
+
parse keys: when no JSONL file changed since the last build, build_snapshot_full
|
|
56
|
+
can skip the O(total events) re-derivation. The memo is invalidated automatically
|
|
57
|
+
on any get_or_parse miss or any purge that drops a key.
|
|
54
58
|
"""
|
|
55
59
|
|
|
56
60
|
_store: dict[tuple[str, int, int], list[RawEvent]] = field(default_factory=dict)
|
|
57
61
|
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
62
|
+
_derived_keys: frozenset[tuple[str, int, int]] | None = None
|
|
63
|
+
_derived: tuple[list[Turn], list[Session], Counter[AssumptionTag]] | None = None
|
|
58
64
|
|
|
59
65
|
def get_or_parse(self, path: Path) -> tuple[tuple[str, int, int], list[RawEvent]]:
|
|
60
66
|
"""Return (cache_key, events). Parses only when (size, mtime_ns) changes."""
|
|
@@ -63,6 +69,9 @@ class ParseCache:
|
|
|
63
69
|
with self._lock:
|
|
64
70
|
if key not in self._store:
|
|
65
71
|
self._store[key] = list(parse_file(path))
|
|
72
|
+
# New file content invalidates any derived memo.
|
|
73
|
+
self._derived_keys = None
|
|
74
|
+
self._derived = None
|
|
66
75
|
return key, self._store[key]
|
|
67
76
|
|
|
68
77
|
def purge(self, keep_keys: set[tuple[str, int, int]]) -> None:
|
|
@@ -71,6 +80,36 @@ class ParseCache:
|
|
|
71
80
|
stale = [k for k in self._store if k not in keep_keys]
|
|
72
81
|
for k in stale:
|
|
73
82
|
del self._store[k]
|
|
83
|
+
if stale:
|
|
84
|
+
self._derived_keys = None
|
|
85
|
+
self._derived = None
|
|
86
|
+
|
|
87
|
+
def get_derived(
|
|
88
|
+
self,
|
|
89
|
+
keys: frozenset[tuple[str, int, int]],
|
|
90
|
+
builder: Callable[[list[RawEvent]], tuple[list[Turn], list[Session], Counter[AssumptionTag]]],
|
|
91
|
+
) -> tuple[list[Turn], list[Session], Counter[AssumptionTag]]:
|
|
92
|
+
"""Return memoized (turns, sessions, fired_counts) for the given key set; rebuild on miss.
|
|
93
|
+
|
|
94
|
+
The memo is keyed on `keys` (the set of active ParseCache entries). When no
|
|
95
|
+
file changed mtime/size since the last build, the cached derivation is
|
|
96
|
+
returned without re-iterating events — this is the dominant per-tick cost.
|
|
97
|
+
"""
|
|
98
|
+
with self._lock:
|
|
99
|
+
if self._derived is not None and self._derived_keys == keys:
|
|
100
|
+
return self._derived
|
|
101
|
+
# Build outside the parse-cache invariant by snapshotting events first.
|
|
102
|
+
events: list[RawEvent] = []
|
|
103
|
+
for k in keys:
|
|
104
|
+
bucket = self._store.get(k)
|
|
105
|
+
if bucket is not None:
|
|
106
|
+
events.extend(bucket)
|
|
107
|
+
# Builder runs without the lock — it's pure CPU over a private list.
|
|
108
|
+
derived = builder(events)
|
|
109
|
+
with self._lock:
|
|
110
|
+
self._derived = derived
|
|
111
|
+
self._derived_keys = keys
|
|
112
|
+
return derived
|
|
74
113
|
|
|
75
114
|
@property
|
|
76
115
|
def size(self) -> int:
|
|
@@ -80,12 +119,18 @@ class ParseCache:
|
|
|
80
119
|
|
|
81
120
|
def _build_turns_and_sessions(
|
|
82
121
|
all_events: list[RawEvent],
|
|
83
|
-
) -> tuple[list[Turn], list[Session]]:
|
|
84
|
-
"""Build deduplicated turns and sessions from pre-parsed raw events.
|
|
122
|
+
) -> tuple[list[Turn], list[Session], Counter[AssumptionTag]]:
|
|
123
|
+
"""Build deduplicated turns and sessions from pre-parsed raw events.
|
|
124
|
+
|
|
125
|
+
Returns the (turns, sessions, fired-assumption-counts). Fired counts are returned
|
|
126
|
+
rather than written to a global recorder so the result is referentially
|
|
127
|
+
transparent and safe to memoize on ParseCache.
|
|
128
|
+
"""
|
|
85
129
|
seen: dict[str, tuple[RawEvent, str]] = {}
|
|
86
130
|
passthroughs: list[tuple[RawEvent, None]] = []
|
|
87
131
|
cwd_by_session: dict[str, str] = {}
|
|
88
132
|
session_source: dict[str, str] = {}
|
|
133
|
+
fired: Counter[AssumptionTag] = Counter()
|
|
89
134
|
|
|
90
135
|
for ev in all_events:
|
|
91
136
|
if ev.cwd and ev.session_id not in cwd_by_session:
|
|
@@ -115,7 +160,8 @@ def _build_turns_and_sessions(
|
|
|
115
160
|
|
|
116
161
|
tc = cost_for_turn(ev.model, usage)
|
|
117
162
|
tags.extend(t for t in tc.assumptions if t not in tags)
|
|
118
|
-
|
|
163
|
+
for tag in tags:
|
|
164
|
+
fired[tag] += 1
|
|
119
165
|
|
|
120
166
|
key_str = k or ev.uuid or str(id(ev))
|
|
121
167
|
turns.append(Turn(
|
|
@@ -153,7 +199,7 @@ def _build_turns_and_sessions(
|
|
|
153
199
|
turns=t_list,
|
|
154
200
|
))
|
|
155
201
|
sessions.sort(key=lambda s: s.turns[0].timestamp if s.turns else s.session_id)
|
|
156
|
-
return turns, sessions
|
|
202
|
+
return turns, sessions, fired
|
|
157
203
|
|
|
158
204
|
|
|
159
205
|
@dataclass
|
|
@@ -709,7 +755,6 @@ def build_snapshot_full(
|
|
|
709
755
|
hourly_today, daily, models, recent_activity,
|
|
710
756
|
assumptions_summary
|
|
711
757
|
"""
|
|
712
|
-
assumption_recorder.reset()
|
|
713
758
|
now = datetime.now(tz=timezone.utc)
|
|
714
759
|
today_date = now.date()
|
|
715
760
|
since_90d = today_date - timedelta(days=89)
|
|
@@ -717,18 +762,18 @@ def build_snapshot_full(
|
|
|
717
762
|
dirs = get_config_dirs(all_projects=all_projects)
|
|
718
763
|
paths = find_jsonl_files(dirs)
|
|
719
764
|
|
|
720
|
-
all_raw_events: list[RawEvent] = []
|
|
721
765
|
active_keys: set[tuple[str, int, int]] = set()
|
|
722
766
|
for path in paths:
|
|
723
767
|
try:
|
|
724
|
-
key,
|
|
768
|
+
key, _events = parse_cache.get_or_parse(path)
|
|
725
769
|
active_keys.add(key)
|
|
726
|
-
all_raw_events.extend(events)
|
|
727
770
|
except OSError:
|
|
728
771
|
pass
|
|
729
772
|
|
|
730
773
|
parse_cache.purge(active_keys)
|
|
731
|
-
all_turns, all_sessions =
|
|
774
|
+
all_turns, all_sessions, _fired = parse_cache.get_derived(
|
|
775
|
+
frozenset(active_keys), _build_turns_and_sessions
|
|
776
|
+
)
|
|
732
777
|
|
|
733
778
|
turns_90d = [t for t in all_turns if t.timestamp.date() >= since_90d]
|
|
734
779
|
daily_90d = _build_daily_series(turns_90d, since_90d)
|
|
@@ -756,7 +801,6 @@ def build_snapshot_full(
|
|
|
756
801
|
models = _build_models(period_turns, period)
|
|
757
802
|
recent_activity = _build_recent_activity(all_turns, cwd_by_sid, now)
|
|
758
803
|
|
|
759
|
-
_fired = assumption_recorder.fired()
|
|
760
804
|
payload = {
|
|
761
805
|
"generated_at": now.isoformat(),
|
|
762
806
|
"config": {"reference_usd": reference_usd, "tick_seconds": tick_seconds},
|
|
@@ -781,6 +825,25 @@ def build_snapshot_full(
|
|
|
781
825
|
return SnapshotResult(payload=payload, turns=all_turns, sessions=all_sessions)
|
|
782
826
|
|
|
783
827
|
|
|
828
|
+
def compute_active_keys(all_projects: bool) -> frozenset[tuple[str, int, int]]:
|
|
829
|
+
"""Stat all active JSONL files and return their (path, size, mtime_ns) keys.
|
|
830
|
+
|
|
831
|
+
Cheap alternative to a full build_snapshot_full when the only question is
|
|
832
|
+
"did anything change since the last build?". The SSE broadcaster uses this as
|
|
833
|
+
an idle gate so unchanged ticks skip the full snapshot assembly entirely.
|
|
834
|
+
"""
|
|
835
|
+
dirs = get_config_dirs(all_projects=all_projects)
|
|
836
|
+
paths = find_jsonl_files(dirs)
|
|
837
|
+
keys: set[tuple[str, int, int]] = set()
|
|
838
|
+
for p in paths:
|
|
839
|
+
try:
|
|
840
|
+
s = p.stat()
|
|
841
|
+
keys.add((str(p), s.st_size, s.st_mtime_ns))
|
|
842
|
+
except OSError:
|
|
843
|
+
pass
|
|
844
|
+
return frozenset(keys)
|
|
845
|
+
|
|
846
|
+
|
|
784
847
|
# ---------------------------------------------------------------------------
|
|
785
848
|
# Endpoint panel builders
|
|
786
849
|
# ---------------------------------------------------------------------------
|
|
@@ -660,6 +660,7 @@ function _apiToDailyChartData(d) {
|
|
|
660
660
|
const chart = _normApiSeries(d, p => p.date, dt => new Date(dt + 'T00:00:00').getTime() / 1000);
|
|
661
661
|
chart._activeProjects = d.active_projects ?? [];
|
|
662
662
|
chart._activeModels = d.active_models ?? [];
|
|
663
|
+
chart._note = d.note ?? null;
|
|
663
664
|
return chart;
|
|
664
665
|
}
|
|
665
666
|
|
|
@@ -714,6 +715,11 @@ const _fetchDaily = _makeFetcher({
|
|
|
714
715
|
function _paintDaily(data) {
|
|
715
716
|
const scale = _scaleFor(_dMetric, _dScaleRaw);
|
|
716
717
|
_syncScalePills('daily-scale-pills', _dMetric === 'hit_pct' ? 'linear-forced' : scale);
|
|
718
|
+
const note = $('daily-note');
|
|
719
|
+
if (note) {
|
|
720
|
+
if (data._note) { note.textContent = data._note; note.classList.remove('hidden'); }
|
|
721
|
+
else { note.textContent = ''; note.classList.add('hidden'); }
|
|
722
|
+
}
|
|
717
723
|
_paintTimeline('daily-chart', data, 'No history yet — check back after a few days.', {
|
|
718
724
|
xFmt: _xFmtDate,
|
|
719
725
|
stepped: data.labels.map(lbl => lbl !== '7d avg'),
|
|
@@ -249,6 +249,7 @@
|
|
|
249
249
|
<span class="filter-group">model <button class="cur" id="daily-model-filter">all ▾</button></span>
|
|
250
250
|
<span class="tl-y-lbl">y auto-fits data · hover for exact values</span>
|
|
251
251
|
</div>
|
|
252
|
+
<div id="daily-note" class="tl-note hidden"></div>
|
|
252
253
|
<div id="daily-chart" class="tl-chart">
|
|
253
254
|
<div class="tl-insufficient">No history yet — check back after a few days.</div>
|
|
254
255
|
</div>
|
|
@@ -487,6 +487,15 @@ header.topbar {
|
|
|
487
487
|
border-radius: 2px;
|
|
488
488
|
background: var(--bg-soft);
|
|
489
489
|
}
|
|
490
|
+
.tl-note {
|
|
491
|
+
margin: 0 0 8px;
|
|
492
|
+
padding: 6px 10px;
|
|
493
|
+
font-size: 12px;
|
|
494
|
+
color: var(--mute);
|
|
495
|
+
background: var(--bg-soft);
|
|
496
|
+
border-left: 2px solid var(--rule-2);
|
|
497
|
+
border-radius: 2px;
|
|
498
|
+
}
|
|
490
499
|
.u-tooltip {
|
|
491
500
|
position: absolute;
|
|
492
501
|
pointer-events: none;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""SSE broadcaster: one shared producer fans out diffs to N subscribers per period.
|
|
2
|
+
|
|
3
|
+
Each connected dashboard tab is a subscriber. Without sharing, N tabs cause N
|
|
4
|
+
independent snapshot rebuilds per tick (the rebuild dominates serve CPU). The
|
|
5
|
+
broadcaster groups subscribers by the only request-scoped axis — `period` — and
|
|
6
|
+
runs one background task per group that builds the snapshot once and fans the
|
|
7
|
+
payload out to every subscriber's queue. Each subscriber tracks its own
|
|
8
|
+
`prev_payload` so the wire format (full first message, shallow-diff thereafter)
|
|
9
|
+
is preserved per-tab regardless of when they joined.
|
|
10
|
+
|
|
11
|
+
Idle back-off: after `IDLE_THRESHOLD` seconds with no payload changes, the
|
|
12
|
+
producer's tick stretches to `max(tick * 3, 15)` until the next change.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import contextlib
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import AsyncGenerator, Callable
|
|
23
|
+
|
|
24
|
+
from tokenol.serve.state import (
|
|
25
|
+
ParseCache,
|
|
26
|
+
SnapshotResult,
|
|
27
|
+
build_snapshot_full,
|
|
28
|
+
compute_active_keys,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
IDLE_THRESHOLD = 30.0
|
|
34
|
+
IDLE_TICK_FLOOR = 15
|
|
35
|
+
SUBSCRIBER_QUEUE_MAXSIZE = 2
|
|
36
|
+
# Time-windowed panels (recent_activity = last 60 min, day boundaries) drift even
|
|
37
|
+
# when no JSONL files change, so we force a rebuild at this cadence regardless of
|
|
38
|
+
# the file-mtime gate. Keeps idle CPU near zero between heartbeats.
|
|
39
|
+
DEFAULT_HEARTBEAT_S = 60.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _shallow_diff(prev: dict, curr: dict) -> dict:
|
|
43
|
+
"""Return only top-level keys whose values changed."""
|
|
44
|
+
return {k: v for k, v in curr.items() if prev.get(k) != v}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _effective_tick(tick: int, idle_seconds: float) -> int:
|
|
48
|
+
if idle_seconds >= IDLE_THRESHOLD:
|
|
49
|
+
return max(tick * 3, IDLE_TICK_FLOOR)
|
|
50
|
+
return tick
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _Subscriber:
|
|
54
|
+
__slots__ = ("queue",)
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
# Bounded queue: producer drops the oldest entry on overflow rather than
|
|
58
|
+
# blocking, so a slow client can't stall fan-out to other subscribers.
|
|
59
|
+
self.queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=SUBSCRIBER_QUEUE_MAXSIZE)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Group:
|
|
63
|
+
"""One producer task + its subscriber set, keyed by `period`."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
period: str,
|
|
68
|
+
build_payload: Callable[[str], dict],
|
|
69
|
+
compute_keys: Callable[[], frozenset[tuple[str, int, int]]],
|
|
70
|
+
get_tick_seconds: Callable[[], int],
|
|
71
|
+
heartbeat_s: float = DEFAULT_HEARTBEAT_S,
|
|
72
|
+
) -> None:
|
|
73
|
+
self.period = period
|
|
74
|
+
self._build_payload = build_payload
|
|
75
|
+
self._compute_keys = compute_keys
|
|
76
|
+
self._get_tick_seconds = get_tick_seconds
|
|
77
|
+
self._heartbeat_s = heartbeat_s
|
|
78
|
+
self.subscribers: set[_Subscriber] = set()
|
|
79
|
+
self.task: asyncio.Task | None = None
|
|
80
|
+
self.last_payload: dict | None = None
|
|
81
|
+
|
|
82
|
+
async def run(self) -> None:
|
|
83
|
+
"""Producer loop with a two-stage gate:
|
|
84
|
+
|
|
85
|
+
1. Stat all JSONL files (cheap). If the (path, size, mtime_ns) set is
|
|
86
|
+
unchanged AND the last build is younger than `heartbeat_s`, skip the
|
|
87
|
+
build entirely — most idle ticks land here, so per-tick CPU is just
|
|
88
|
+
the cost of stat'ing a few hundred files.
|
|
89
|
+
2. Otherwise rebuild and fan out.
|
|
90
|
+
"""
|
|
91
|
+
prev_payload: dict | None = None
|
|
92
|
+
prev_keys: frozenset[tuple[str, int, int]] | None = None
|
|
93
|
+
last_change_ts = time.monotonic()
|
|
94
|
+
last_built_at: float = 0.0
|
|
95
|
+
loop = asyncio.get_running_loop()
|
|
96
|
+
try:
|
|
97
|
+
while True:
|
|
98
|
+
tick = int(self._get_tick_seconds())
|
|
99
|
+
idle_seconds = time.monotonic() - last_change_ts
|
|
100
|
+
sleep_for = _effective_tick(tick, idle_seconds)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
keys = await loop.run_in_executor(None, self._compute_keys)
|
|
104
|
+
except Exception:
|
|
105
|
+
log.exception("active-keys probe failed — forcing build")
|
|
106
|
+
keys = None
|
|
107
|
+
|
|
108
|
+
now = time.monotonic()
|
|
109
|
+
stale = (now - last_built_at) >= self._heartbeat_s
|
|
110
|
+
changed = keys is None or keys != prev_keys
|
|
111
|
+
if not (changed or stale or prev_payload is None):
|
|
112
|
+
await asyncio.sleep(sleep_for)
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
curr = await loop.run_in_executor(
|
|
117
|
+
None, self._build_payload, self.period
|
|
118
|
+
)
|
|
119
|
+
except Exception:
|
|
120
|
+
log.exception("snapshot build failed — skipping tick")
|
|
121
|
+
await asyncio.sleep(sleep_for)
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
if prev_payload is None or _shallow_diff(prev_payload, curr):
|
|
125
|
+
last_change_ts = time.monotonic()
|
|
126
|
+
self.last_payload = curr
|
|
127
|
+
prev_payload = curr
|
|
128
|
+
prev_keys = keys
|
|
129
|
+
last_built_at = now
|
|
130
|
+
|
|
131
|
+
for sub in list(self.subscribers):
|
|
132
|
+
self._push(sub, curr)
|
|
133
|
+
|
|
134
|
+
await asyncio.sleep(sleep_for)
|
|
135
|
+
except asyncio.CancelledError:
|
|
136
|
+
raise
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _push(sub: _Subscriber, payload: dict) -> None:
|
|
140
|
+
"""Non-blocking fan-out; on overflow, drop the oldest entry and replace.
|
|
141
|
+
|
|
142
|
+
We never want a stuck client to back-pressure the producer.
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
sub.queue.put_nowait(payload)
|
|
146
|
+
return
|
|
147
|
+
except asyncio.QueueFull:
|
|
148
|
+
pass
|
|
149
|
+
with contextlib.suppress(asyncio.QueueEmpty):
|
|
150
|
+
sub.queue.get_nowait()
|
|
151
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
152
|
+
sub.queue.put_nowait(payload)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class SnapshotBroadcaster:
|
|
156
|
+
"""Shared producer for /api/stream: one task per `period`, fan-out to all tabs.
|
|
157
|
+
|
|
158
|
+
The broadcaster is created once per app and held on `app.state.broadcaster`.
|
|
159
|
+
Subscribers use `subscribe(period)` as an async generator yielding SSE-
|
|
160
|
+
formatted strings.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
parse_cache: ParseCache,
|
|
166
|
+
all_projects: bool,
|
|
167
|
+
get_reference_usd: Callable[[], float],
|
|
168
|
+
get_tick_seconds: Callable[[], int],
|
|
169
|
+
get_thresholds: Callable[[], dict],
|
|
170
|
+
heartbeat_s: float = DEFAULT_HEARTBEAT_S,
|
|
171
|
+
) -> None:
|
|
172
|
+
self._parse_cache = parse_cache
|
|
173
|
+
self._all_projects = all_projects
|
|
174
|
+
self._get_reference_usd = get_reference_usd
|
|
175
|
+
self._get_tick_seconds = get_tick_seconds
|
|
176
|
+
self._get_thresholds = get_thresholds
|
|
177
|
+
self._heartbeat_s = heartbeat_s
|
|
178
|
+
self._groups: dict[str, _Group] = {}
|
|
179
|
+
self._lock = asyncio.Lock()
|
|
180
|
+
|
|
181
|
+
def _compute_active_keys(self) -> frozenset[tuple[str, int, int]]:
|
|
182
|
+
return compute_active_keys(self._all_projects)
|
|
183
|
+
|
|
184
|
+
def _build_payload(self, period: str) -> dict:
|
|
185
|
+
result: SnapshotResult = build_snapshot_full(
|
|
186
|
+
self._parse_cache,
|
|
187
|
+
all_projects=self._all_projects,
|
|
188
|
+
reference_usd=self._get_reference_usd(),
|
|
189
|
+
tick_seconds=int(self._get_tick_seconds()),
|
|
190
|
+
period=period,
|
|
191
|
+
thresholds=self._get_thresholds(),
|
|
192
|
+
)
|
|
193
|
+
return result.payload
|
|
194
|
+
|
|
195
|
+
async def subscribe(self, period: str) -> AsyncGenerator[str, None]:
|
|
196
|
+
sub = _Subscriber()
|
|
197
|
+
async with self._lock:
|
|
198
|
+
grp = self._groups.get(period)
|
|
199
|
+
if grp is None:
|
|
200
|
+
grp = _Group(
|
|
201
|
+
period,
|
|
202
|
+
self._build_payload,
|
|
203
|
+
self._compute_active_keys,
|
|
204
|
+
self._get_tick_seconds,
|
|
205
|
+
heartbeat_s=self._heartbeat_s,
|
|
206
|
+
)
|
|
207
|
+
grp.task = asyncio.create_task(
|
|
208
|
+
grp.run(), name=f"snapshot-broadcaster:{period}"
|
|
209
|
+
)
|
|
210
|
+
self._groups[period] = grp
|
|
211
|
+
grp.subscribers.add(sub)
|
|
212
|
+
# Bootstrap a late-joining subscriber with the most recent payload so
|
|
213
|
+
# they don't wait a full tick for their first message.
|
|
214
|
+
if grp.last_payload is not None:
|
|
215
|
+
_Group._push(sub, grp.last_payload)
|
|
216
|
+
|
|
217
|
+
prev_payload: dict | None = None
|
|
218
|
+
try:
|
|
219
|
+
while True:
|
|
220
|
+
payload = await sub.queue.get()
|
|
221
|
+
data = payload if prev_payload is None else _shallow_diff(prev_payload, payload)
|
|
222
|
+
prev_payload = payload
|
|
223
|
+
if data:
|
|
224
|
+
yield f"data: {json.dumps(data)}\n\n"
|
|
225
|
+
finally:
|
|
226
|
+
async with self._lock:
|
|
227
|
+
grp.subscribers.discard(sub)
|
|
228
|
+
if not grp.subscribers:
|
|
229
|
+
if grp.task is not None:
|
|
230
|
+
grp.task.cancel()
|
|
231
|
+
self._groups.pop(period, None)
|
|
232
|
+
|
|
233
|
+
async def shutdown(self) -> None:
|
|
234
|
+
async with self._lock:
|
|
235
|
+
tasks = [g.task for g in self._groups.values() if g.task is not None]
|
|
236
|
+
for t in tasks:
|
|
237
|
+
t.cancel()
|
|
238
|
+
self._groups.clear()
|
|
239
|
+
for t in tasks:
|
|
240
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
241
|
+
await t
|
|
242
|
+
|
|
243
|
+
def group_count(self) -> int:
|
|
244
|
+
"""Number of active producer groups (testing aid)."""
|
|
245
|
+
return len(self._groups)
|