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.
Files changed (92) hide show
  1. {tokenol-0.2.0 → tokenol-0.3.0}/CHANGELOG.md +29 -0
  2. {tokenol-0.2.0 → tokenol-0.3.0}/PKG-INFO +1 -1
  3. {tokenol-0.2.0 → tokenol-0.3.0}/pyproject.toml +1 -1
  4. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/__init__.py +1 -1
  5. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/ingest/parser.py +0 -1
  6. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/model/events.py +0 -3
  7. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/app.py +40 -19
  8. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/state.py +74 -11
  9. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/app.js +6 -0
  10. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/index.html +1 -0
  11. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/styles.css +9 -0
  12. tokenol-0.3.0/src/tokenol/serve/streaming.py +245 -0
  13. {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_serve_app.py +8 -4
  14. {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_serve_state.py +66 -1
  15. tokenol-0.3.0/tests/test_serve_streaming.py +345 -0
  16. tokenol-0.2.0/src/tokenol/serve/streaming.py +0 -63
  17. tokenol-0.2.0/tests/test_serve_streaming.py +0 -188
  18. {tokenol-0.2.0 → tokenol-0.3.0}/.claude/settings.local.json +0 -0
  19. {tokenol-0.2.0 → tokenol-0.3.0}/.claudesignore +0 -0
  20. {tokenol-0.2.0 → tokenol-0.3.0}/.github/workflows/ci.yml +0 -0
  21. {tokenol-0.2.0 → tokenol-0.3.0}/.gitignore +0 -0
  22. {tokenol-0.2.0 → tokenol-0.3.0}/README.md +0 -0
  23. {tokenol-0.2.0 → tokenol-0.3.0}/docs/ASSUMPTIONS.md +0 -0
  24. {tokenol-0.2.0 → tokenol-0.3.0}/docs/METRICS.md +0 -0
  25. {tokenol-0.2.0 → tokenol-0.3.0}/docs/screenshots/breakdown_bottom.jpg +0 -0
  26. {tokenol-0.2.0 → tokenol-0.3.0}/docs/screenshots/breakdown_top.jpg +0 -0
  27. {tokenol-0.2.0 → tokenol-0.3.0}/docs/screenshots/main.jpg +0 -0
  28. {tokenol-0.2.0 → tokenol-0.3.0}/docs/screenshots/project.jpg +0 -0
  29. {tokenol-0.2.0 → tokenol-0.3.0}/docs/screenshots/session_bottom.jpg +0 -0
  30. {tokenol-0.2.0 → tokenol-0.3.0}/docs/screenshots/session_top.jpg +0 -0
  31. {tokenol-0.2.0 → tokenol-0.3.0}/docs/superpowers/plans/2026-04-24-breakdown-pr1.md +0 -0
  32. {tokenol-0.2.0 → tokenol-0.3.0}/docs/superpowers/plans/2026-04-24-breakdown-pr2.md +0 -0
  33. {tokenol-0.2.0 → tokenol-0.3.0}/docs/superpowers/plans/2026-04-24-breakdown-pr3.md +0 -0
  34. {tokenol-0.2.0 → tokenol-0.3.0}/docs/superpowers/specs/2026-04-24-breakdown-tab-design.md +0 -0
  35. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/assumptions.py +0 -0
  36. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/cli.py +0 -0
  37. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/enums.py +0 -0
  38. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/ingest/__init__.py +0 -0
  39. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/ingest/builder.py +0 -0
  40. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/ingest/discovery.py +0 -0
  41. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/ingest/schema.py +0 -0
  42. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/__init__.py +0 -0
  43. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/context.py +0 -0
  44. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/cost.py +0 -0
  45. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/history.py +0 -0
  46. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/patterns.py +0 -0
  47. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/rollups.py +0 -0
  48. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/thresholds.py +0 -0
  49. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/verdicts.py +0 -0
  50. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/metrics/windows.py +0 -0
  51. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/model/__init__.py +0 -0
  52. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/model/pricing.py +0 -0
  53. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/model/registry.py +0 -0
  54. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/report/__init__.py +0 -0
  55. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/report/text.py +0 -0
  56. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/__init__.py +0 -0
  57. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/prefs.py +0 -0
  58. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/session_detail.py +0 -0
  59. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/breakdown.html +0 -0
  60. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/breakdown.js +0 -0
  61. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/chart.js +0 -0
  62. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/components.js +0 -0
  63. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/day.html +0 -0
  64. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/day.js +0 -0
  65. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/model.html +0 -0
  66. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/model.js +0 -0
  67. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/project.html +0 -0
  68. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/project.js +0 -0
  69. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/session.html +0 -0
  70. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/session.js +0 -0
  71. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/tool.html +0 -0
  72. {tokenol-0.2.0 → tokenol-0.3.0}/src/tokenol/serve/static/tool.js +0 -0
  73. {tokenol-0.2.0 → tokenol-0.3.0}/tests/conftest.py +0 -0
  74. {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/basic.jsonl +0 -0
  75. {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/dedup.jsonl +0 -0
  76. {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/gemini.jsonl +0 -0
  77. {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/interrupted.jsonl +0 -0
  78. {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/missing_ids.jsonl +0 -0
  79. {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/multi.jsonl +0 -0
  80. {tokenol-0.2.0 → tokenol-0.3.0}/tests/fixtures/sidechain.jsonl +0 -0
  81. {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_cli.py +0 -0
  82. {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_context_metrics.py +0 -0
  83. {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_dedup.py +0 -0
  84. {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_history_and_thresholds.py +0 -0
  85. {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_metrics.py +0 -0
  86. {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_parser.py +0 -0
  87. {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_patterns.py +0 -0
  88. {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_serve_prefs.py +0 -0
  89. {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_session_detail.py +0 -0
  90. {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_verdicts.py +0 -0
  91. {tokenol-0.2.0 → tokenol-0.3.0}/tests/test_windows.py +0 -0
  92. {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.2.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.2.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" }
@@ -1,3 +1,3 @@
1
1
  """tokenol — Claude Code usage & efficiency audit tool."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -118,7 +118,6 @@ def parse_file(path: Path) -> Iterator[RawEvent]:
118
118
  tool_error_count=tool_error_count,
119
119
  tool_names=tool_names,
120
120
  cwd=cwd,
121
- raw=ev,
122
121
  )
123
122
 
124
123
 
@@ -55,9 +55,6 @@ class RawEvent:
55
55
  # Working directory (from system events)
56
56
  cwd: str | None = None
57
57
 
58
- # Raw for extensibility (caller may inspect)
59
- raw: dict = field(default_factory=dict) # type: ignore[assignment]
60
-
61
58
 
62
59
  @dataclass
63
60
  class Turn:
@@ -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
- app = FastAPI(title="tokenol")
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 = ParseCache()
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
- from tokenol.serve.streaming import snapshot_stream
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 snapshot_stream(
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
- return JSONResponse(
286
- status_code=400,
287
- content={"error": "insufficient_history", "have_days": have_days},
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
- return JSONResponse(build_daily_panel(result.turns, result.sessions, range, metric, project, model))
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
- assumption_recorder.record(tags)
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, events = parse_cache.get_or_parse(path)
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 = _build_turns_and_sessions(all_raw_events)
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)