@arbiterforge/ca-pi 0.15.5 → 0.15.7

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.
@@ -1,39 +1,48 @@
1
1
  #!/usr/bin/env python3
2
2
  # codeArbiter — cost/token ledger subsystem for the statusline (extracted T-12).
3
3
  #
4
- # Owns the user-level token/cost accounting the statusline renders: an Anthropic
5
- # API price table, transcript-tailing accumulation (deduped per requestId), the
6
- # per-session JSON ledger (~/.codearbiter/ledger.json) with TTL pruning + an
7
- # atomic write, and the per-call burn samples that feed the sparkline. The
8
- # statusline imports this for its cost segment; it carries NO rendering concern
9
- # (no ANSI, no box drawing) so the accounting is unit-testable in isolation.
4
+ # Owns the user-level Claude accounting the statusline renders (spec
5
+ # claude-statusline-accounting-integrity): a bounded, starvation-free scanner over
6
+ # the parent transcript AND every delegated `<session>/subagents/*.jsonl`
7
+ # transcript. It is the ONE JSONL parser on the render path. Around it sit
8
+ # per-session v2 state under ~/.codearbiter/ledger.json.sessions/, incrementally
9
+ # maintained Session/Today totals with structured coverage, the separately kept
10
+ # host-reported estimate, and parent-only burn samples for the sparkline. What a
11
+ # usage record MEANS (identity, reconciliation, pricing) is owned by _usagelib;
12
+ # this module persists, schedules and aggregates. No rendering concern (no ANSI,
13
+ # no box drawing), so the accounting is unit-testable in isolation.
10
14
  #
11
15
  # Design principles (mirroring _metricslib.py / _taskboardlib.py):
12
16
  # - Stdlib only; no third-party imports ever — runs on stock Python.
13
17
  # - Zero side effects at import time: no git calls, no file I/O.
14
- # - Pure functions are fully testable with synthetic input. ledger_update()
15
- # and pi_ledger_update() are the only filesystem entry points; everything
16
- # else is pure or a private bounded persistence helper.
17
- # - Never raise on malformed user input — every reader degrades to safe blanks.
18
+ # - ledger_update(), persist_sess_start() and pi_ledger_update() are the only
19
+ # filesystem entry points; everything else is pure or a private bounded
20
+ # persistence helper.
21
+ # - Bounds defer work, never discard it: an offset never advances past bytes
22
+ # that were not consumed, and backlog shows as `catching_up`.
23
+ # - Steady-state cost never grows with history. A no-change render reads only
24
+ # compact summaries; request evidence is loaded only for new records.
25
+ # - Never raise on malformed user input — every reader degrades to safe blanks,
26
+ # and uncertainty surfaces as coverage reasons, never as a fabricated figure.
18
27
  #
19
28
  # Public API:
20
- # price_for(model) -> tuple (input,out,c5,c1,cr) USD per 1M tokens
21
- # api_cost(tok) -> float estimated API-equivalent USD for {model: tokens}
22
- # ledger_path() -> str resolved ledger file path (env-overridable)
23
- # _tx_accumulate(rec, tx_path) -> bool tail a transcript into rec; True if offset advanced
24
- # _agg_reqs(reqs, only=None) -> dict aggregate per-request map -> {model: tokens}
25
- # _totals(models) -> dict {in,out,cost} display totals for a model map
26
- # ledger_update(data, sid) -> tuple (rec, session_totals, today_totals)
29
+ # ledger_path() -> str resolved ledger anchor path (env-overridable)
30
+ # ledger_update(data, sid) -> tuple (summary, session, today); session/today carry
31
+ # in/out tokens, exact picodollars `pd`, display
32
+ # `cost`, coverage `state`, `reasons`, `stale`
33
+ # persist_sess_start(sid, value) -> bool cache a resolved session-start epoch
27
34
  # pi_ledger_path() -> str separate user-global Pi ledger path
28
35
  # pi_ledger_update(session_key, scan_start, scan_end, facts, path=None) -> dict
29
36
  # bounded Pi session/day snapshot
30
- # burn_samples(rec) -> list[float] recent per-call token-burn values for the sparkline
37
+ # burn_samples(rec) -> list[float] recent per-request (parent-only) burn values
31
38
 
39
+ import base64
32
40
  import hashlib
33
41
  import json
34
42
  import math
35
43
  import os
36
44
  import re
45
+ import shutil
37
46
  import stat
38
47
  import sys
39
48
  import time
@@ -56,11 +65,17 @@ from _hooklib import LOCK_WAIT # noqa: E402
56
65
  # stays importable for this module's call sites and the test suite. No cycle
57
66
  # risk — _fmtlib imports only _colorlib, never _ledgerlib.
58
67
  from _fmtlib import parse_iso # noqa: E402,F401
68
+ # Usage semantics (normalization, identity, reconciliation, pricing) have ONE
69
+ # owner; this module only persists, schedules and aggregates what it returns.
70
+ import _usagelib # noqa: E402
71
+ try: # presentation label for child transcripts, derived by the one parser here
72
+ from _subagentslib import sub_label as _sub_label # noqa: E402
73
+ except Exception: # pragma: no cover — a label is never worth breaking accounting
74
+ _sub_label = None
59
75
 
60
76
  # Tunables (module constants; mirrored from the original inline statusline block).
61
77
  SESSION_TTL = 36 * 3600 # prune sessions older than ~1.5 days
62
78
  BURN_RING = 40 # recent per-call token-burn samples kept for the sparkline
63
- TX_MAX_NEW_LINES = 20000 # hot-path bound: transcript lines parsed per render
64
79
  # `last_ts` exists only to keep a live record inside SESSION_TTL. Stamping it on
65
80
  # EVERY render made every render a writing render (#392) — the statusline runs in
66
81
  # a fresh process per refresh, so that was two atomic replacements per refresh
@@ -96,19 +111,6 @@ PI_TOTAL_KEYS = frozenset({
96
111
  PI_FACT_KEYS = frozenset({"position", "timestamp"}) | PI_TOTAL_KEYS
97
112
  PI_STATUSES = frozenset({"ok", "invalid", "corrupt", "lock_failed", "write_failed"})
98
113
 
99
- # API list prices, USD per 1M tokens (captured 2026-06-10 from Anthropic's
100
- # pricing pages). Used ONLY to estimate the pay-as-you-go API-equivalent cost of
101
- # this session's REAL tokens — the bar labels it "api≈"; it is not a bill.
102
- # Per model family: (input, output, cache_write_5m, cache_write_1h, cache_read).
103
- # Cache multipliers are the standard ones: write 1.25x/2x input, read 0.1x.
104
- API_PRICES = {
105
- "fable": (10.0, 50.0, 12.50, 20.0, 1.00),
106
- "opus": (5.0, 25.0, 6.25, 10.0, 0.50),
107
- "sonnet": (3.0, 15.0, 3.75, 6.0, 0.30),
108
- "haiku": (1.0, 5.0, 1.25, 2.0, 0.10),
109
- }
110
-
111
-
112
114
  # --------------------------------------------------------------------------- coercion
113
115
  def num(x, default=0.0):
114
116
  """Coerce any host value to float; tolerate strings, None, and containers."""
@@ -136,29 +138,7 @@ def get(d, *path, default=None):
136
138
  return cur
137
139
 
138
140
 
139
- # --------------------------------------------------------------------------- pricing
140
- def price_for(model):
141
- ml = str(model).lower()
142
- for fam, p in API_PRICES.items():
143
- if fam in ml:
144
- return p
145
- return API_PRICES["sonnet"] # reasonable mid default for an unrecognized model
146
-
147
-
148
- def api_cost(tok):
149
- """Estimated pay-as-you-go API cost (USD) for accumulated per-model tokens."""
150
- total = 0.0
151
- for model, t in (tok or {}).items():
152
- if not isinstance(t, dict):
153
- continue
154
- pin, pout, p5, p1, pr = price_for(model)
155
- total += (num(t.get("in")) * pin + num(t.get("out")) * pout
156
- + num(t.get("c5")) * p5 + num(t.get("c1")) * p1
157
- + num(t.get("cr")) * pr) / 1e6
158
- return total
159
-
160
-
161
- # --------------------------------------------------------------------------- ledger
141
+ # --------------------------------------------------------------------------- ledger files
162
142
  def ledger_path():
163
143
  return os.environ.get("CODEARBITER_LEDGER") or \
164
144
  os.path.join(os.path.expanduser("~"), ".codearbiter", "ledger.json")
@@ -195,6 +175,13 @@ def _atomic_json(path, value):
195
175
  pass
196
176
 
197
177
 
178
+ def _remove(path):
179
+ try:
180
+ os.remove(path)
181
+ except OSError:
182
+ pass
183
+
184
+
198
185
  def _session_dir(path):
199
186
  return f"{path}.sessions"
200
187
 
@@ -204,6 +191,8 @@ def _session_key(sid):
204
191
 
205
192
 
206
193
  def _session_file(path, sid):
194
+ """The LEGACY (v1) per-session shard. v2 never writes it; it is read once to
195
+ seed a migrating session and otherwise only stat-ed for pruning."""
207
196
  return os.path.join(_session_dir(path), f"{_session_key(sid)}.json")
208
197
 
209
198
 
@@ -211,332 +200,1013 @@ def _start_file(path, sid):
211
200
  return os.path.join(_session_dir(path), f"{_session_key(sid)}.start.json")
212
201
 
213
202
 
214
- def _merge_sessions(path):
215
- """Merge the legacy snapshot with authoritative independently-written shards.
203
+ def _acct_file(path, sid):
204
+ return os.path.join(_session_dir(path), f"{_session_key(sid)}.acct.json")
216
205
 
217
- Returns (sessions, legacy) where `legacy` is the compatibility snapshot's own
218
- mapping exactly as it was read from disk (None when the file is absent or
219
- malformed). Callers compare the merged result against `legacy` to tell whether
220
- the snapshot on disk is already in sync, so the whole-snapshot rewrite happens
221
- only when it would actually change bytes (#392). Session records are copied out
222
- of `legacy` so later in-place edits (the .start.json overlay below, and the
223
- caller's own record) cannot mutate the comparison baseline.
224
- """
225
- led = _read_json(path, {})
226
- legacy = led.get("sessions") if isinstance(led, dict) else None
227
- if not isinstance(legacy, dict):
228
- legacy = None
229
- sessions = {} if legacy is None else {
230
- sid: dict(rec) for sid, rec in legacy.items() if isinstance(rec, dict)}
231
- directory = _session_dir(path)
232
- try:
233
- names = os.listdir(directory)
234
- except OSError:
235
- names = []
236
- for name in names:
237
- if not name.endswith(".json") or name.endswith(".start.json"):
238
- continue
239
- enumerated = os.path.join(directory, name)
240
- item = _read_json(enumerated)
241
- if not isinstance(item, dict) or not isinstance(item.get("rec"), dict):
242
- try:
243
- os.remove(enumerated)
244
- except OSError:
245
- pass
246
- continue
247
- sid = str(item.get("sid"))
248
- if os.path.basename(_session_file(path, sid)) != name:
249
- try:
250
- os.remove(enumerated)
251
- except OSError:
252
- pass
253
- continue
254
- rec = item["rec"]
255
- if time.time() - num(rec.get("last_ts")) > SESSION_TTL:
256
- for stale in (enumerated, _start_file(path, sid)):
257
- try:
258
- os.remove(stale)
259
- except OSError:
260
- pass
261
- sessions.pop(sid, None)
262
- continue
263
- sessions[sid] = rec
264
- for name in names:
265
- if not name.endswith(".start.json"):
266
- continue
267
- enumerated = os.path.join(directory, name)
268
- item = _read_json(enumerated)
269
- if not isinstance(item, dict):
270
- try:
271
- os.remove(enumerated)
272
- except OSError:
273
- pass
274
- continue
275
- sid = str(item.get("sid"))
276
- start = num(item.get("sess_start"), None)
277
- valid_name = os.path.basename(_start_file(path, sid)) == name
278
- if valid_name and sid in sessions and start is not None:
279
- sessions[sid]["sess_start"] = float(start)
280
- else:
281
- try:
282
- os.remove(enumerated)
283
- except OSError:
284
- pass
285
- now = time.time()
286
- sessions = {sid: rec for sid, rec in sessions.items()
287
- if isinstance(rec, dict)
288
- and now - num(rec.get("last_ts")) <= SESSION_TTL}
289
- return sessions, legacy
206
+
207
+ def _hot_file(path, sid):
208
+ return os.path.join(_session_dir(path), f"{_session_key(sid)}.hot.json")
209
+
210
+
211
+ def _ev_dir(path, sid):
212
+ return os.path.join(_session_dir(path), f"{_session_key(sid)}.ev")
290
213
 
291
214
 
292
- def _load_sessions(path):
293
- """The merged live-session map only (see _merge_sessions for the baseline)."""
294
- return _merge_sessions(path)[0]
215
+ # --------------------------------------------------------------------------- v2 accounting (spec
216
+ # claude-statusline-accounting-integrity). One compact SUMMARY per session is read
217
+ # on every render; request EVIDENCE lives in a bounded hot file plus hash-prefix
218
+ # partitions that split before they exceed PART_MAX, and is loaded only for the
219
+ # identities a render's new records touch. Derived totals are maintained by
220
+ # subtract-old / add-new, so steady-state work never grows with history.
221
+ ACCT_SCHEMA = "codearbiter.claude-accounting/v2"
222
+ ACCT_BYTE_BUDGET = 8 * 1024 * 1024 # transcript bytes read per render, all sources
223
+ ACCT_RECORD_BUDGET = 50000 # complete JSONL lines consumed per render
224
+ ACCT_SOURCE_BUDGET = 16 # sources opened per render
225
+ MAX_RECORD_BYTES = 4 * 1024 * 1024 # per-line ceiling (~14x the largest observed line)
226
+ DISCOVERY_BOUND = 4096 # child transcripts discovered per session
227
+ HOT_MAX = 256 # hot evidence entries before eviction to cold
228
+ PART_MAX = 256 # entries per cold partition before it splits
229
+ UI_LOCK_WAIT = 0.02 # render-path try-lock budget (seconds)
230
+ FP_WINDOW = 512 # head/tail fingerprint window (bytes)
231
+ UNPROVEN_KEEP = 8 # byte positions of unproven records kept per source
232
+ _LEGACY_SHARD = re.compile(r"[0-9a-f]{64}\.json\Z")
233
+ _HEX = "0123456789abcdef"
295
234
 
296
235
 
297
- def _write_snapshot(path, sessions):
298
- return _atomic_json(path, {"sessions": sessions})
236
+ class _Inconsistent(Exception):
237
+ """Evidence on disk does not match the summary that should own it."""
299
238
 
300
239
 
301
- def _msg_date(ts):
302
- """Local calendar date (YYYY-MM-DD) of a transcript message's timestamp, so
303
- tokens are attributed to the day they were actually burned (a session that
304
- crosses midnight splits correctly across days)."""
305
- e = parse_iso(ts) if isinstance(ts, str) else None
306
- if e is None:
307
- return datetime.now().strftime("%Y-%m-%d")
240
+ def _local_day(ts):
241
+ """Local calendar day (YYYY-MM-DD) of a trustworthy transcript timestamp."""
242
+ e = parse_iso(ts)
308
243
  try:
309
244
  return datetime.fromtimestamp(e).strftime("%Y-%m-%d")
310
- except (OSError, OverflowError, ValueError):
311
- return datetime.now().strftime("%Y-%m-%d")
245
+ except (TypeError, OSError, OverflowError, ValueError):
246
+ return None
247
+
248
+
249
+ def _today():
250
+ return datetime.now().strftime("%Y-%m-%d")
251
+
252
+
253
+ def _order_sources(ids):
254
+ """Deterministic child scheduling order (a test seam for order independence)."""
255
+ return sorted(ids)
256
+
257
+
258
+ def _ident_hash(key):
259
+ return hashlib.sha256(key.encode("utf-8", "replace")).hexdigest()
312
260
 
313
261
 
314
- def _tx_accumulate(rec, tx_path):
315
- """Tail the session transcript JSONL from the stored byte offset, UPSERTING each
316
- assistant message's usage into a per-requestId dedup map (the transcript logs a
317
- single API call several times via streaming/replay; counting each request once
318
- is what keeps tokens AND cost honest) and pushing a per-call burn sample. Append-
319
- only -> O(new lines)/render. Returns True if the offset advanced."""
320
- if not tx_path or not os.path.isfile(tx_path):
262
+ # Per-partition membership filter (Bloom), carried in the hot file so a render
263
+ # that ingests brand-new identities can prove "not in this cold partition"
264
+ # without reading it. A filter only ever answers "definitely absent" or "maybe
265
+ # present"; a missing, malformed or undersized filter is "maybe present" and
266
+ # falls back to the partition read, because a false "absent" would silently
267
+ # double-count a fork replay (never fold unreadable into absent).
268
+ FILTER_BITS_PER_KEY = 10
269
+ FILTER_PROBES = 7
270
+
271
+
272
+ def _filter_probes(key, m):
273
+ d = hashlib.sha256(b"filter:" + key.encode("utf-8", "replace")).digest()
274
+ return [int.from_bytes(d[4 * i:4 * i + 4], "big") % m for i in range(FILTER_PROBES)]
275
+
276
+
277
+ def _filter_build(keys):
278
+ keys = list(keys)
279
+ m = max(64, FILTER_BITS_PER_KEY * len(keys))
280
+ m += -m % 8
281
+ bits = bytearray(m // 8)
282
+ for key in keys:
283
+ for b in _filter_probes(key, m):
284
+ bits[b >> 3] |= 1 << (b & 7)
285
+ return [m, base64.b64encode(bytes(bits)).decode("ascii")]
286
+
287
+
288
+ def _filter_absent(flt, key):
289
+ """True only when `flt` is well-formed and proves `key` absent."""
290
+ try:
291
+ m, enc = flt
292
+ if isinstance(m, bool) or not isinstance(m, int) or m <= 0 or m % 8 or not isinstance(enc, str):
293
+ return False
294
+ bits = base64.b64decode(enc.encode("ascii"), validate=True)
295
+ if len(bits) * 8 != m:
296
+ return False
297
+ except (TypeError, ValueError, UnicodeError):
321
298
  return False
299
+ return any(not bits[b >> 3] & (1 << (b & 7)) for b in _filter_probes(key, m))
300
+
301
+
302
+ def _fresh_summary(sid, now):
303
+ return {"v": 2, "sid": sid, "first_ts": now, "last_ts": now, "reg": _usagelib.REGISTRY_VERSION,
304
+ "seq": 0, "hot_seq": 0, "parts": {}, "mid_only": 0, "src": {}, "cursor": "",
305
+ "tot": {"in": 0, "out": 0, "pd": 0}, "rc": {}, "days": {}, "unk_time": 0,
306
+ "host": {"latest": None, "max": None, "anom": False, "missing": False, "seen": False},
307
+ "burn": [], "flags": {}, "cov": {}}
308
+
309
+
310
+ def _summary_valid(rec, sid):
322
311
  try:
323
- size = os.path.getsize(tx_path)
324
- except OSError:
312
+ return (rec.get("v") == 2 and rec.get("sid") == sid
313
+ and isinstance(rec.get("src"), dict) and isinstance(rec.get("tot"), dict)
314
+ and isinstance(rec.get("days"), dict) and isinstance(rec.get("parts"), dict)
315
+ and isinstance(rec.get("host"), dict) and isinstance(rec.get("burn"), list)
316
+ and isinstance(rec.get("rc"), dict) and isinstance(rec.get("seq"), int))
317
+ except AttributeError:
325
318
  return False
326
- if not isinstance(rec.get("reqs"), dict):
327
- rec["reqs"] = {} # fresh record, or migrating from an earlier schema
328
- rec["tx_off"] = 0
329
- rec.pop("days", None)
330
- rec.pop("tok", None)
331
- if not isinstance(rec.get("burn"), list):
332
- rec["burn"] = []
333
- off = int(num(rec.get("tx_off")))
334
- # New transcript for this session, or truncation/rotation -> reparse from start.
335
- if rec.get("tx_path") != tx_path or off > size:
336
- off, rec["reqs"], rec["burn"], rec["tx_path"] = 0, {}, [], tx_path
337
- if off >= size:
319
+
320
+
321
+ class _Store:
322
+ """Evidence for one session: a bounded LRU hot map plus cold hash-prefix
323
+ partitions. Every file carries the summary sequence number it was written
324
+ under; a mismatch on load means a torn commit and raises _Inconsistent."""
325
+
326
+ def __init__(self, path, sid, rec):
327
+ self.path, self.sid, self.rec = path, sid, rec
328
+ self.hot = None
329
+ self.hot_dirty = False
330
+ self.parts = {}
331
+ self.parts_dirty = set()
332
+ self.grown = set() # partitions whose key set grew: refilter
333
+ self.obsolete = []
334
+
335
+ def _part_file(self, prefix):
336
+ return os.path.join(_ev_dir(self.path, self.sid), f"p{prefix}.json")
337
+
338
+ def _load_hot(self):
339
+ if self.hot is None:
340
+ want = self.rec.get("hot_seq", 0)
341
+ if want:
342
+ v = _read_json(_hot_file(self.path, self.sid))
343
+ if not (isinstance(v, dict) and v.get("seq") == want
344
+ and isinstance(v.get("ev"), dict) and isinstance(v.get("order"), list)):
345
+ raise _Inconsistent
346
+ flt = v.get("f")
347
+ self.hot = {"ev": v["ev"], "order": [k for k in v["order"] if k in v["ev"]],
348
+ "f": dict(flt) if isinstance(flt, dict) else {}}
349
+ else:
350
+ self.hot = {"ev": {}, "order": [], "f": {}}
351
+ return self.hot
352
+
353
+ def _leaf(self, h):
354
+ parts = self.rec["parts"]
355
+ for k in range(len(h) + 1):
356
+ if h[:k] in parts:
357
+ return h[:k]
358
+ raise _Inconsistent
359
+
360
+ def _load_part(self, prefix):
361
+ if prefix not in self.parts:
362
+ want = self.rec["parts"].get(prefix, 0)
363
+ if not want:
364
+ self.parts[prefix] = {}
365
+ else:
366
+ v = _read_json(self._part_file(prefix))
367
+ if not (isinstance(v, dict) and v.get("seq") == want and isinstance(v.get("ev"), dict)):
368
+ raise _Inconsistent
369
+ self.parts[prefix] = v["ev"]
370
+ return self.parts[prefix]
371
+
372
+ def get(self, key):
373
+ """(entry, location) where location is "hot" or a partition prefix."""
374
+ hot = self._load_hot()
375
+ if key in hot["ev"]:
376
+ return hot["ev"][key], "hot"
377
+ if self.rec["parts"]:
378
+ prefix = self._leaf(_ident_hash(key))
379
+ if prefix not in self.parts and (
380
+ not self.rec["parts"].get(prefix)
381
+ or _filter_absent(hot["f"].get(prefix), key)):
382
+ return None, None
383
+ part = self._load_part(prefix)
384
+ if key in part:
385
+ return part[key], prefix
386
+ return None, None
387
+
388
+ def put(self, key, entry, where):
389
+ if where is None or where == "hot":
390
+ hot = self._load_hot()
391
+ if key in hot["ev"]:
392
+ hot["order"].remove(key)
393
+ hot["ev"][key] = entry
394
+ hot["order"].append(key)
395
+ self.hot_dirty = True
396
+ else:
397
+ part = self._load_part(where)
398
+ if key not in part:
399
+ self.grown.add(where) # keep the filter a superset
400
+ part[key] = entry
401
+ self.parts_dirty.add(where)
402
+
403
+ def delete(self, key, where):
404
+ if where == "hot":
405
+ hot = self._load_hot()
406
+ hot["ev"].pop(key, None)
407
+ if key in hot["order"]:
408
+ hot["order"].remove(key)
409
+ self.hot_dirty = True
410
+ elif where is not None:
411
+ self._load_part(where).pop(key, None)
412
+ self.parts_dirty.add(where)
413
+
414
+ def evict(self):
415
+ hot = self.hot
416
+ if hot is None or len(hot["ev"]) <= HOT_MAX:
417
+ return
418
+ if not self.rec["parts"]:
419
+ self.rec["parts"][""] = 0
420
+ n = len(hot["ev"]) - HOT_MAX // 2
421
+ victims, hot["order"] = hot["order"][:n], hot["order"][n:]
422
+ for key in victims:
423
+ entry = hot["ev"].pop(key)
424
+ prefix = self._leaf(_ident_hash(key))
425
+ self._load_part(prefix)[key] = entry
426
+ self.parts_dirty.add(prefix)
427
+ self.grown.add(prefix)
428
+ self.hot_dirty = True
429
+ for prefix in list(self.parts_dirty):
430
+ self._split(prefix)
431
+
432
+ def _split(self, prefix):
433
+ part = self._load_part(prefix)
434
+ if len(part) <= PART_MAX or len(prefix) >= 64:
435
+ return
436
+ if self.rec["parts"].get(prefix):
437
+ self.obsolete.append(self._part_file(prefix))
438
+ del self.rec["parts"][prefix]
439
+ del self.parts[prefix]
440
+ self.parts_dirty.discard(prefix)
441
+ children = {prefix + c: {} for c in _HEX}
442
+ for key, entry in part.items():
443
+ children[prefix + _ident_hash(key)[len(prefix)]][key] = entry
444
+ for child, entries in children.items():
445
+ self.rec["parts"][child] = 0
446
+ self.parts[child] = entries
447
+ self.parts_dirty.add(child)
448
+ self.grown.add(child)
449
+ for child in children:
450
+ self._split(child)
451
+
452
+ def all_locations(self):
453
+ """Every (key, entry, location) — a full walk, only for rebuilds."""
454
+ hot = self._load_hot()
455
+ for key in list(hot["ev"]):
456
+ yield key, hot["ev"][key], "hot"
457
+ for prefix in list(self.rec["parts"]):
458
+ part = self._load_part(prefix)
459
+ for key in list(part):
460
+ yield key, part[key], prefix
461
+
462
+ def flush(self, seq):
463
+ """Write dirty evidence under `seq`; False on any failure (the caller
464
+ must then NOT commit the summary)."""
465
+ for prefix in sorted(self.parts_dirty):
466
+ if prefix not in self.rec["parts"]:
467
+ continue
468
+ entries = self.parts.get(prefix, {})
469
+ if entries:
470
+ if not _atomic_json(self._part_file(prefix), {"seq": seq, "ev": entries}):
471
+ return False
472
+ self.rec["parts"][prefix] = seq
473
+ else:
474
+ if self.rec["parts"].get(prefix):
475
+ self.obsolete.append(self._part_file(prefix))
476
+ self.rec["parts"][prefix] = 0
477
+ if self.grown:
478
+ hot = self._load_hot()
479
+ for prefix in self.grown:
480
+ if self.parts.get(prefix):
481
+ hot["f"][prefix] = _filter_build(self.parts[prefix])
482
+ self.hot_dirty = True
483
+ if self.hot_dirty:
484
+ hot = self._load_hot()
485
+ live = {p: f for p, f in hot["f"].items() if self.rec["parts"].get(p)}
486
+ if not _atomic_json(_hot_file(self.path, self.sid),
487
+ {"seq": seq, "ev": hot["ev"], "order": hot["order"], "f": live}):
488
+ return False
489
+ self.rec["hot_seq"] = seq
490
+ return True
491
+
492
+
493
+ def _bump(counts, reason, sign):
494
+ n = counts.get(reason, 0) + sign
495
+ if n:
496
+ counts[reason] = n
497
+ else:
498
+ counts.pop(reason, None)
499
+
500
+
501
+ def _contrib(entry):
502
+ """Priced contribution of one request's accepted evidence."""
503
+ facts = entry["s"]
504
+ acc = None
505
+ for src in sorted(facts):
506
+ acc = _usagelib.merge(acc, facts[src])
507
+ p = _usagelib.price(acc)
508
+ return {"o": "p" if "p" in facts else min(facts), "day": _usagelib.event_day(acc),
509
+ "days": _usagelib.days_seen(acc), "in": p["in"], "out": p["out"], "pd": p["pd"],
510
+ "r": sorted(p["reasons"])}
511
+
512
+
513
+ def _apply(rec, c, sign):
514
+ """Add (sign=+1) or remove (sign=-1) one contribution from every derived index."""
515
+ tot = rec["tot"]
516
+ tot["in"] += sign * c["in"]
517
+ tot["out"] += sign * c["out"]
518
+ tot["pd"] += sign * c["pd"]
519
+ for r in c["r"]:
520
+ _bump(rec["rc"], r, sign)
521
+ if c["day"]:
522
+ day = rec["days"].setdefault(c["day"], {"in": 0, "out": 0, "pd": 0, "rc": {}})
523
+ day["in"] += sign * c["in"]
524
+ day["out"] += sign * c["out"]
525
+ day["pd"] += sign * c["pd"]
526
+ for r in c["r"]:
527
+ _bump(day["rc"], r, sign)
528
+ if len(c["days"]) > 1:
529
+ for d in c["days"]:
530
+ other = rec["days"].setdefault(d, {"in": 0, "out": 0, "pd": 0, "rc": {}})
531
+ _bump(other["rc"], "conflicting_event_day", sign)
532
+ for d in set(c["days"]) | {c["day"]}:
533
+ b = rec["days"].get(d)
534
+ if b is not None and not (b["in"] or b["out"] or b["pd"] or b["rc"]):
535
+ del rec["days"][d]
536
+ else:
537
+ rec["unk_time"] += sign
538
+ src = rec["src"].get(c["o"])
539
+ if src is not None:
540
+ src["tok"][0] += sign * c["in"]
541
+ src["tok"][1] += sign * c["out"]
542
+ src["pd"] += sign * c["pd"]
543
+
544
+
545
+ def _burn_touch(rec, key, old, new):
546
+ """Parent-only burn (spec AC-24): one sample per parent-owned request, in
547
+ order of first acceptance, updated as later snapshots grow it."""
548
+ if new is None or new["o"] != "p":
549
+ return
550
+ tag = _ident_hash(key)[:12]
551
+ value = new["in"] + new["out"]
552
+ for item in rec["burn"]:
553
+ if item[0] == tag:
554
+ item[1] = value
555
+ return
556
+ if old is None or old["o"] != "p":
557
+ rec["burn"].append([tag, value])
558
+ if len(rec["burn"]) > BURN_RING:
559
+ rec["burn"] = rec["burn"][-BURN_RING:]
560
+
561
+
562
+ def _find_by_mid(store, mid_key):
563
+ """(rid key, entry, location) of the request whose stored message id is
564
+ `mid_key`. A full evidence walk: it runs only for a message-ID-only record
565
+ whose alias is not yet known, which the observed corpus never produced for
566
+ billable usage (spec observation 5)."""
567
+ for key, entry, where in list(store.all_locations()):
568
+ if entry.get("m") == mid_key and "s" in entry:
569
+ return key, entry, where
570
+ return None, None, None
571
+
572
+
573
+ def _ingest(store, rec, src, key, alias, fact):
574
+ """Merge one source's fact into its request's evidence and move the derived
575
+ indices by exactly (new contribution - old contribution).
576
+
577
+ Identity is `rid:` when a request id exists. The record's message id is kept
578
+ inside that entry (`m`), so a message-ID-only representation of the same
579
+ request resolves to it in either arrival order without an eager alias entry
580
+ per request (spec D-5)."""
581
+ entry, where = store.get(key)
582
+ if entry is not None and "a" in entry: # message-ID-only record of an aliased request
583
+ key = entry["a"]
584
+ entry, where = store.get(key)
585
+ elif entry is None and key.startswith("mid:"):
586
+ found, fentry, fwhere = _find_by_mid(store, key)
587
+ if found is not None:
588
+ store.put(key, {"a": found}, "hot") # remember: no second walk
589
+ key, entry, where = found, fentry, fwhere
590
+ old = _contrib(entry) if entry is not None and entry.get("s") else None
591
+ if entry is None:
592
+ entry, where = {"s": {}}, None
593
+ folded = False
594
+ if alias and rec.get("mid_only", 0) > 0:
595
+ aentry, awhere = store.get(alias)
596
+ if aentry is not None and aentry.get("s"):
597
+ _apply(rec, _contrib(aentry), -1) # earlier mid-only evidence joins
598
+ for s2, f2 in aentry["s"].items():
599
+ entry["s"][s2] = _usagelib.merge(entry["s"].get(s2), f2)
600
+ rec["mid_only"] -= 1
601
+ store.put(alias, {"a": key}, awhere)
602
+ folded = True
603
+ if alias and entry.get("m") != alias:
604
+ entry["m"] = alias
605
+ folded = folded or old is not None
606
+ prev = entry["s"].get(src)
607
+ merged = _usagelib.merge(prev, fact)
608
+ if not folded and old is not None and merged == prev:
338
609
  return False
339
- try:
340
- with open(tx_path, "rb") as f:
341
- f.seek(off)
342
- chunk = f.read()
343
- except OSError:
610
+ entry["s"][src] = merged
611
+ if old is None and not folded and key.startswith("mid:"):
612
+ rec["mid_only"] = rec.get("mid_only", 0) + 1
613
+ new = _contrib(entry)
614
+ if old is not None:
615
+ _apply(rec, old, -1)
616
+ _apply(rec, new, +1)
617
+ _burn_touch(rec, key, old, new)
618
+ store.put(key, entry, where)
619
+ return True
620
+
621
+
622
+ def _drop_source(store, rec, src_id):
623
+ """Remove every trace of one source generation (replacement/truncation).
624
+ A full evidence walk — permitted only for this rebuild case (spec D-21)."""
625
+ for key, entry, where in list(store.all_locations()):
626
+ if "s" not in entry or src_id not in entry["s"]:
627
+ continue
628
+ _apply(rec, _contrib(entry), -1)
629
+ del entry["s"][src_id]
630
+ if entry["s"]:
631
+ new = _contrib(entry)
632
+ _apply(rec, new, +1)
633
+ store.put(key, entry, where)
634
+ else:
635
+ store.delete(key, where)
636
+ if key.startswith("mid:"):
637
+ rec["mid_only"] = max(0, rec.get("mid_only", 0) - 1)
638
+ if src_id == "p":
639
+ rec["burn"] = []
640
+
641
+
642
+ def _reprice(store, rec):
643
+ """Recompute every derived index from evidence (registry version change)."""
644
+ rec["tot"] = {"in": 0, "out": 0, "pd": 0}
645
+ rec["rc"], rec["days"], rec["unk_time"] = {}, {}, 0
646
+ for s in rec["src"].values():
647
+ s["tok"], s["pd"] = [0, 0], 0
648
+ for _key, entry, _where in store.all_locations():
649
+ if "s" in entry:
650
+ _apply(rec, _contrib(entry), +1)
651
+ rec["reg"] = _usagelib.REGISTRY_VERSION
652
+
653
+
654
+ # --------------------------------------------------------------------------- scanning
655
+ def _stat_key(st):
656
+ return [st.st_size, st.st_mtime_ns, getattr(st, "st_ino", 0) or 0]
657
+
658
+
659
+ def _digest(data):
660
+ return hashlib.sha256(data).hexdigest()
661
+
662
+
663
+ def _new_source(kind, locator):
664
+ return {"k": kind, "path": locator, "off": 0, "stat": None, "size": 0, "fp": None,
665
+ "seen": None, "skip": None, "life": "live", "label": None, "label_done": kind == "p",
666
+ "models": [], "tok": [0, 0], "pd": 0, "mt": 0, "unp": 0, "unpp": [], "mal": 0,
667
+ "ovr": 0}
668
+
669
+
670
+ def _reset_source(s):
671
+ keep = {k: s[k] for k in ("k", "path")}
672
+ s.clear()
673
+ s.update(_new_source(keep["k"], keep["path"]))
674
+
675
+
676
+ def _verify(f, s, st_key):
677
+ """True when the stored consumed region is still this file's content."""
678
+ off = s["off"]
679
+ if off == 0 or s["fp"] is None:
680
+ return True
681
+ size = st_key[0]
682
+ if size < off:
344
683
  return False
345
- new_off = size
346
- # A writer may flush mid-line; keep a trailing partial line for next render.
347
- if chunk and not chunk.endswith(b"\n"):
348
- cut = chunk.rfind(b"\n")
349
- if cut < 0:
350
- return False # no complete line yet
351
- new_off = off + cut + 1
352
- chunk = chunk[:cut]
353
- parsed = 0
354
- for raw in chunk.split(b"\n"):
355
- if not raw.strip():
684
+ old = s.get("seen") or [0, 0, 0]
685
+ head_len, head, tail = s["fp"]
686
+ grew_in_place = old[2] == st_key[2] and size > old[0]
687
+ if not grew_in_place:
688
+ f.seek(0)
689
+ if _digest(f.read(head_len)) != head:
690
+ return False
691
+ start = max(0, off - FP_WINDOW)
692
+ f.seek(start)
693
+ return _digest(f.read(off - start)) == tail
694
+
695
+
696
+ class _Scan:
697
+ """Result of scanning one source within a render's remaining budget."""
698
+
699
+ def __init__(self):
700
+ self.facts, self.bytes, self.records = [], 0, 0
701
+ self.unp, self.unpp, self.mal, self.ovr = 0, [], 0, 0
702
+ self.label, self.models, self.t0 = None, [], None
703
+ self.stop = "budget"
704
+
705
+
706
+ def _scan(s, f, byte_budget, record_budget, sid_kind):
707
+ """Consume complete records from s["off"] of the open file `f` within the
708
+ budgets. Never advances past bytes it did not consume; an oversized line is
709
+ passed deterministically across renders via the persisted `skip` line start."""
710
+ out = _Scan()
711
+ pos = s["off"]
712
+ consumed_tail = b""
713
+ f.seek(pos)
714
+ while out.bytes < byte_budget and out.records < record_budget:
715
+ if s["skip"] is not None:
716
+ line_start = s["skip"]
717
+ line_tail = b""
718
+ done = False
719
+ while out.bytes < byte_budget:
720
+ chunk = f.read(min(65536, byte_budget - out.bytes))
721
+ if not chunk:
722
+ break
723
+ out.bytes += len(chunk)
724
+ nl = chunk.find(b"\n")
725
+ if nl < 0:
726
+ pos += len(chunk)
727
+ line_tail = (line_tail + chunk)[-_usagelib.TAIL_WINDOW:]
728
+ continue
729
+ pos += nl + 1
730
+ line_tail = (line_tail + chunk[:nl + 1])[-_usagelib.TAIL_WINDOW:]
731
+ done = True
732
+ break
733
+ if not done:
734
+ s["off"] = pos
735
+ out.stop = "skip"
736
+ break
737
+ want = min(_usagelib.TAIL_WINDOW, pos - line_start)
738
+ if len(line_tail) < want:
739
+ f.seek(pos - want)
740
+ line_tail = f.read(want)
741
+ out.bytes += want
742
+ if _usagelib.usage_bearing(line_tail):
743
+ out.ovr += 1
744
+ s["skip"] = None
745
+ s["off"] = pos
746
+ out.records += 1
747
+ consumed_tail = line_tail[-FP_WINDOW:]
748
+ f.seek(pos)
356
749
  continue
357
- parsed += 1
358
- if parsed > TX_MAX_NEW_LINES:
750
+ remaining = byte_budget - out.bytes
751
+ limit = min(MAX_RECORD_BYTES, remaining) + 1
752
+ line = f.readline(limit)
753
+ out.bytes += len(line)
754
+ if not line:
755
+ out.stop = "eof"
359
756
  break
360
- try:
361
- o = json.loads(raw.decode("utf-8", "replace"))
362
- except ValueError:
757
+ if line.endswith(b"\n"):
758
+ pos += len(line)
759
+ out.records += 1
760
+ consumed_tail = (consumed_tail + line)[-FP_WINDOW:]
761
+ _consume(line, pos - len(line), out, s, sid_kind)
762
+ s["off"] = pos
363
763
  continue
364
- if not isinstance(o, dict) or o.get("type") != "assistant":
365
- continue
366
- m = o.get("message")
367
- u = m.get("usage") if isinstance(m, dict) else None
368
- if not isinstance(u, dict):
764
+ if len(line) < limit:
765
+ out.stop = "partial" # writer-flushed partial line: wait
766
+ break
767
+ if limit - 1 >= MAX_RECORD_BYTES:
768
+ s["skip"] = pos # oversized: pass it without decoding
769
+ pos += len(line)
770
+ s["off"] = pos
369
771
  continue
370
- model = m.get("model") or "?"
371
- i = num(u.get("input_tokens"))
372
- cr = num(u.get("cache_read_input_tokens"))
373
- cc = u.get("cache_creation")
374
- if isinstance(cc, dict):
375
- c5 = num(cc.get("ephemeral_5m_input_tokens"))
376
- c1 = num(cc.get("ephemeral_1h_input_tokens"))
772
+ break # fits the ceiling, not this render's budget
773
+ # Fingerprint the consumed region for replacement detection (spec D-15).
774
+ if s["off"]:
775
+ head_len = min(FP_WINDOW, s["off"])
776
+ if s["fp"] is None or s["fp"][0] < head_len:
777
+ f.seek(0)
778
+ head = _digest(f.read(head_len))
377
779
  else:
378
- c5 = c1 = 0.0
379
- cw = num(u.get("cache_creation_input_tokens"))
380
- if cw and not (c5 or c1): # 5m/1h split absent -> treat all as 5m
381
- c5 = cw
382
- out = num(u.get("output_tokens"))
383
- ts = o.get("timestamp")
384
- e = parse_iso(ts) if isinstance(ts, str) else None
385
- if e is not None: # earliest message ts = true session start
386
- t0 = rec.get("t0")
387
- if not isinstance(t0, (int, float)) or e < t0:
388
- rec["t0"] = e
389
- # Dedupe by requestId: the transcript logs each API call multiple times
390
- # (streaming/replay), so UPSERT each request's final usage exactly once
391
- # instead of summing every line, which 2-3x over-counts BOTH tokens and cost.
392
- key = o.get("requestId") or m.get("id") or f"_p{int(off) + parsed}"
393
- is_new = key not in rec["reqs"]
394
- rec["reqs"][key] = {"d": _msg_date(ts), "m": model,
395
- "in": i, "cr": cr, "c5": c5, "c1": c1, "out": out}
396
- if is_new:
397
- rec["burn"].append(i + c5 + c1 + out) # fresh input + output (cache reads excluded)
398
- if len(rec["burn"]) > BURN_RING:
399
- rec["burn"] = rec["burn"][-BURN_RING:]
400
- rec["tx_off"] = new_off
401
- return True
780
+ head_len, head = s["fp"][0], s["fp"][1]
781
+ want = min(FP_WINDOW, s["off"])
782
+ if len(consumed_tail) < want:
783
+ f.seek(s["off"] - want)
784
+ consumed_tail = f.read(want)
785
+ s["fp"] = [head_len, head, _digest(consumed_tail[-want:])]
786
+ return out
402
787
 
403
788
 
404
- def _agg_reqs(reqs, only=None):
405
- """Aggregate the per-request dedup map into {model: tokens}; if `only` is a
406
- date, include just that local-calendar-day's requests (for the Today totals)."""
407
- out = {}
408
- for r in (reqs or {}).values():
409
- if not isinstance(r, dict):
410
- continue
411
- if only is not None and r.get("d") != only:
789
+ def _consume(line, start, out, s, kind):
790
+ if not s["label_done"] and (b'"role":"user"' in line or b'"role": "user"' in line):
791
+ try:
792
+ o = json.loads(line)
793
+ except ValueError:
794
+ o = None
795
+ msg = o.get("message") if isinstance(o, dict) else None
796
+ if isinstance(msg, dict) and msg.get("role") == "user":
797
+ s["label_done"] = True
798
+ if _sub_label is not None:
799
+ label = safe(_sub_label, msg.get("content"))
800
+ s["label"] = label or None
801
+ if not _usagelib.usage_bearing(line):
802
+ return
803
+ try:
804
+ o = json.loads(line)
805
+ except ValueError:
806
+ out.mal += 1
807
+ return
808
+ n = _usagelib.normalize(o, _local_day)
809
+ if n["kind"] == "ignore":
810
+ return
811
+ if n["kind"] == "malformed":
812
+ out.mal += 1
813
+ return
814
+ if n["id"] is None:
815
+ out.unp += 1
816
+ out.unpp.append(start)
817
+ return
818
+ out.facts.append((n["id"], n["alias"], n["fact"]))
819
+ if kind == "p":
820
+ t = n["fact"]["t"]
821
+ if t is not None and (out.t0 is None or t < out.t0):
822
+ out.t0 = t
823
+ else:
824
+ model = o["message"].get("model")
825
+ if isinstance(model, str) and model.strip() and model not in out.models:
826
+ out.models.append(model.strip())
827
+
828
+
829
+ # --------------------------------------------------------------------------- one render
830
+ def _children_dir(tx):
831
+ return os.path.join(os.path.dirname(tx), os.path.splitext(os.path.basename(tx))[0],
832
+ "subagents")
833
+
834
+
835
+ def _discover(rec, tx):
836
+ """{source id: (path, stat key, mtime)} for the parent and up to
837
+ DISCOVERY_BOUND children, plus whether listing failed / hit the bound."""
838
+ found, failed, bounded = {}, False, False
839
+ try:
840
+ st = os.stat(tx)
841
+ found["p"] = (tx, _stat_key(st), st.st_mtime)
842
+ except OSError:
843
+ pass
844
+ sub = _children_dir(tx)
845
+ count = 0
846
+ try:
847
+ with os.scandir(sub) as it:
848
+ for entry in it:
849
+ if not entry.name.endswith(".jsonl"):
850
+ continue
851
+ if count >= DISCOVERY_BOUND:
852
+ bounded = True
853
+ break
854
+ count += 1
855
+ try:
856
+ st = entry.stat()
857
+ except OSError:
858
+ continue
859
+ found["c:" + entry.name] = (entry.path, _stat_key(st), st.st_mtime)
860
+ except FileNotFoundError:
861
+ pass
862
+ except OSError:
863
+ failed = True
864
+ return found, failed, bounded
865
+
866
+
867
+ def _needs_work(s, stat_key):
868
+ """A source needs scanning when it changed since it was last caught up. A
869
+ source with deferred work (budget backlog, or an oversized line in progress)
870
+ never records a caught-up stat, so it always compares unequal."""
871
+ return s["stat"] != stat_key
872
+
873
+
874
+ def _account(store, rec, tx):
875
+ """Scan changed sources within budgets and fold them into the summary."""
876
+ flags = {"list_failed": False, "bound": False, "catching": False}
877
+ if rec["reg"] != _usagelib.REGISTRY_VERSION:
878
+ _reprice(store, rec)
879
+ found, flags["list_failed"], flags["bound"] = _discover(rec, tx)
880
+ for sid_, (path, st_key, mtime) in found.items():
881
+ if sid_ not in rec["src"]:
882
+ rec["src"][sid_] = _new_source("p" if sid_ == "p" else "c",
883
+ path if sid_ == "p" else sid_[2:])
884
+ s = rec["src"][sid_]
885
+ s["life"] = "live"
886
+ s["mt"] = mtime
887
+ if not flags["list_failed"]:
888
+ for sid_, s in rec["src"].items():
889
+ if sid_ not in found:
890
+ s["life"] = "gone"
891
+ elif "p" not in found and "p" in rec["src"]:
892
+ rec["src"]["p"]["life"] = "gone"
893
+ work = [k for k, (_p, st_key, _m) in found.items() if _needs_work(rec["src"][k], st_key)]
894
+ children = [k for k in work if k != "p"]
895
+ ordered = _order_sources(children)
896
+ if rec.get("cursor") and ordered:
897
+ after = [k for k in ordered if k > rec["cursor"]]
898
+ ordered = after + [k for k in ordered if k <= rec["cursor"]] if after else ordered
899
+ plan = (["p"] if "p" in work else []) + ordered
900
+ bytes_left, records_left = ACCT_BYTE_BUDGET, ACCT_RECORD_BUDGET
901
+ opened = 0
902
+ for sid_ in plan:
903
+ if opened >= ACCT_SOURCE_BUDGET or bytes_left <= 0 or records_left <= 0:
904
+ break
905
+ path, st_key, _m = found[sid_]
906
+ s = rec["src"][sid_]
907
+ share_b, share_r = bytes_left, records_left
908
+ if sid_ == "p" and children:
909
+ share_b, share_r = max(1, bytes_left // 2), max(1, records_left // 2)
910
+ opened += 1
911
+ try:
912
+ with open(path, "rb") as f: # one handle: verify, then scan
913
+ if not _verify(f, s, st_key):
914
+ _drop_source(store, rec, sid_)
915
+ _reset_source(s)
916
+ result = _scan(s, f, share_b, share_r, s["k"])
917
+ except OSError:
412
918
  continue
413
- o = out.setdefault(r.get("m") or "?",
414
- {"in": 0.0, "cr": 0.0, "c5": 0.0, "c1": 0.0, "out": 0.0})
415
- for k in o:
416
- o[k] += num(r.get(k))
919
+ bytes_left -= result.bytes
920
+ records_left -= result.records
921
+ for key, alias, fact in result.facts:
922
+ _ingest(store, rec, sid_, key, alias, fact)
923
+ store.evict()
924
+ s["unp"] += result.unp
925
+ s["unpp"] = (s["unpp"] + result.unpp)[-UNPROVEN_KEEP:]
926
+ s["mal"] += result.mal
927
+ s["ovr"] += result.ovr
928
+ for m in result.models:
929
+ if m not in s["models"] and len(s["models"]) < 3:
930
+ s["models"].append(m)
931
+ if result.t0 is not None and (rec.get("t0") is None or result.t0 < rec["t0"]):
932
+ rec["t0"] = result.t0
933
+ s["size"] = st_key[0]
934
+ s["seen"] = st_key
935
+ caught_up = s["skip"] is None and (result.stop in ("eof", "partial")
936
+ or s["off"] >= st_key[0])
937
+ s["stat"] = st_key if caught_up else None
938
+ if sid_ != "p":
939
+ rec["cursor"] = sid_
940
+ for sid_, (_p, st_key, _m) in found.items():
941
+ if _needs_work(rec["src"][sid_], st_key):
942
+ flags["catching"] = True
943
+ return flags
944
+
945
+
946
+ def _session_reasons(rec, flags):
947
+ reasons = set(rec["rc"])
948
+ srcs = rec["src"].values()
949
+ if any(s["unp"] for s in srcs):
950
+ reasons.add("unproven_identity")
951
+ if any(s["mal"] for s in srcs):
952
+ reasons.add("malformed_record")
953
+ if any(s["ovr"] for s in srcs):
954
+ reasons.add("oversized_record")
955
+ if flags.get("bound"):
956
+ reasons.add("discovery_bound")
957
+ if any(s["life"] == "gone" and (s["stat"] is None or s["skip"] is not None)
958
+ for s in srcs):
959
+ reasons.add("source_vanished_with_backlog")
960
+ return reasons
961
+
962
+
963
+ def _session_state(rec, flags, reasons, has_parent):
964
+ if not has_parent and not rec["src"]:
965
+ return "unavailable"
966
+ if flags.get("catching") or flags.get("list_failed"):
967
+ return "catching_up"
968
+ return "partial" if reasons else "complete"
969
+
970
+
971
+ def _update_host(rec, data):
972
+ host = rec["host"]
973
+ v = get(data, "cost", "total_cost_usd") if isinstance(data, dict) else None
974
+ valid = (isinstance(v, (int, float)) and not isinstance(v, bool)
975
+ and math.isfinite(v) and v >= 0)
976
+ if valid:
977
+ v = float(v)
978
+ host["latest"] = v
979
+ host["max"] = v if host["max"] is None else max(host["max"], v)
980
+ host["anom"] = v < host["max"]
981
+ host["missing"] = False
982
+ host["seen"] = True
983
+ elif host["seen"]:
984
+ host["missing"] = True
985
+
986
+
987
+ def _usd(pd):
988
+ return pd / 1e12
989
+
990
+
991
+ def _scope(totals, state, reasons, stale):
992
+ return {"in": float(totals["in"]), "out": float(totals["out"]), "pd": int(totals["pd"]),
993
+ "cost": _usd(totals["pd"]), "state": state, "reasons": sorted(reasons),
994
+ "stale": stale}
995
+
996
+
997
+ def _unavailable(stale=False):
998
+ out = _scope({"in": 0, "out": 0, "pd": 0}, "unavailable", (), stale)
999
+ out.update(host=None, host_delta=None)
417
1000
  return out
418
1001
 
419
1002
 
420
- def _totals(models):
421
- """Display totals + API-equivalent cost for a model map. The displayed "in" is
422
- FRESH input (uncached input + cache writes) — cache READS are excluded from the
423
- token count (they re-serve already-sent context every turn and would inflate it
424
- 30-100x), but they ARE still priced into the cost via api_cost()."""
425
- tin = tout = 0.0
426
- for t in (models or {}).values():
427
- if isinstance(t, dict):
428
- tin += num(t.get("in")) + num(t.get("c5")) + num(t.get("c1"))
429
- tout += num(t.get("out"))
430
- return {"in": tin, "out": tout, "cost": api_cost(models)}
1003
+ def _session_output(rec, stale):
1004
+ cov = rec.get("cov") or {}
1005
+ out = _scope(rec["tot"], cov.get("state", "unavailable"), cov.get("reasons", []), stale)
1006
+ out["host"] = rec["host"].get("max")
1007
+ out["host_delta"] = None
1008
+ if out["state"] == "complete" and out["host"] is not None:
1009
+ out["host_delta"] = out["host"] - out["cost"]
1010
+ return out
1011
+
1012
+
1013
+ def _today_output(path, sid, rec, stale, prune):
1014
+ """Today across every live session's COMPACT summary (never its evidence)."""
1015
+ today = _today()
1016
+ now = time.time()
1017
+ totals = {"in": 0, "out": 0, "pd": 0}
1018
+ reasons, catching, any_data = set(), False, False
1019
+ own_key = _session_key(sid)
1020
+ summaries = []
1021
+ if rec is not None:
1022
+ summaries.append(rec)
1023
+ directory = _session_dir(path)
1024
+ try:
1025
+ names = os.listdir(directory)
1026
+ except OSError:
1027
+ names = []
1028
+ acct_keys = {n[:-10] for n in names if n.endswith(".acct.json")}
1029
+ for name in names:
1030
+ full = os.path.join(directory, name)
1031
+ if name.endswith(".acct.json"):
1032
+ if name[:-10] == own_key:
1033
+ continue
1034
+ payload = _read_json(full)
1035
+ other = payload.get("rec") if isinstance(payload, dict) else None
1036
+ if not (isinstance(other, dict) and _summary_valid(other, payload.get("sid"))):
1037
+ if prune:
1038
+ _remove(full)
1039
+ continue
1040
+ if now - num(other.get("last_ts")) > SESSION_TTL:
1041
+ if prune:
1042
+ _prune_session(path, name[:-10])
1043
+ continue
1044
+ summaries.append(other)
1045
+ elif _LEGACY_SHARD.match(name) and name[:-5] != own_key:
1046
+ try:
1047
+ mtime = os.stat(full).st_mtime
1048
+ except OSError:
1049
+ continue
1050
+ if now - mtime > SESSION_TTL:
1051
+ if prune:
1052
+ _remove(full)
1053
+ if name[:-5] not in acct_keys:
1054
+ _remove(os.path.join(directory, name[:-5] + ".start.json"))
1055
+ elif now - mtime <= 86400 and name[:-5] not in acct_keys:
1056
+ reasons.add("unmigrated_session")
1057
+ for s in summaries:
1058
+ bucket = s["days"].get(today)
1059
+ cov = s.get("cov") or {}
1060
+ active = bucket is not None or s.get("last_day") == today
1061
+ if bucket:
1062
+ any_data = True
1063
+ totals["in"] += bucket["in"]
1064
+ totals["out"] += bucket["out"]
1065
+ totals["pd"] += bucket["pd"]
1066
+ reasons.update(bucket["rc"])
1067
+ if active:
1068
+ if s.get("unk_time"):
1069
+ reasons.add("unknown_event_time")
1070
+ reasons.update(cov.get("src_reasons", []))
1071
+ if cov.get("state") == "catching_up":
1072
+ catching = True
1073
+ if catching:
1074
+ state = "catching_up"
1075
+ elif reasons:
1076
+ state = "partial"
1077
+ elif rec is None and not any_data:
1078
+ state = "unavailable"
1079
+ else:
1080
+ state = "complete"
1081
+ return _scope(totals, state, reasons, stale)
1082
+
1083
+
1084
+ def _prune_session(path, key):
1085
+ directory = _session_dir(path)
1086
+ for suffix in (".acct.json", ".hot.json", ".start.json", ".json"):
1087
+ _remove(os.path.join(directory, key + suffix))
1088
+ shutil.rmtree(os.path.join(directory, key + ".ev"), ignore_errors=True)
1089
+
1090
+
1091
+ def _load_summary(path, sid):
1092
+ payload = _read_json(_acct_file(path, sid))
1093
+ if (isinstance(payload, dict) and payload.get("schema") == ACCT_SCHEMA
1094
+ and payload.get("sid") == sid and isinstance(payload.get("rec"), dict)
1095
+ and _summary_valid(payload["rec"], sid)):
1096
+ return payload["rec"]
1097
+ return None
1098
+
1099
+
1100
+ def _seed_summary(path, sid, now, keep=None):
1101
+ """A fresh summary; carries session metadata over from a discarded v2 summary
1102
+ (`keep`) or, once, from the legacy v1 shard — never its cost (spec D-16)."""
1103
+ rec = _fresh_summary(sid, now)
1104
+ if keep:
1105
+ for k in ("first_ts", "sess_start", "t0"):
1106
+ if keep.get(k) is not None:
1107
+ rec[k] = keep[k]
1108
+ if isinstance(keep.get("host"), dict):
1109
+ rec["host"] = keep["host"]
1110
+ return rec
1111
+ legacy = _read_json(_session_file(path, sid))
1112
+ old = legacy.get("rec") if isinstance(legacy, dict) else None
1113
+ if isinstance(old, dict):
1114
+ first = num(old.get("first_ts"), None)
1115
+ if first is not None and math.isfinite(first) and 0 < first <= now:
1116
+ rec["first_ts"] = first
1117
+ hc = old.get("host_cost")
1118
+ if isinstance(hc, (int, float)) and not isinstance(hc, bool) and math.isfinite(hc) and hc > 0:
1119
+ rec["host"].update(max=float(hc), seen=True)
1120
+ return rec
1121
+
1122
+
1123
+ def _discard_evidence(path, sid):
1124
+ _remove(_hot_file(path, sid))
1125
+ shutil.rmtree(_ev_dir(path, sid), ignore_errors=True)
431
1126
 
432
1127
 
433
1128
  def ledger_update(data, sid):
434
- blank = {"in": 0.0, "out": 0.0, "cost": 0.0}
1129
+ """One render's accounting: returns (summary, session, today). Session/today
1130
+ carry in/out tokens, exact picodollars (`pd`), display `cost`, a coverage
1131
+ `state` (complete | catching_up | partial | unavailable), `reasons`, and
1132
+ `stale` (True when this render could not take the lock and is showing the
1133
+ last committed snapshot instead — never fabricated zeros)."""
435
1134
  if not sid:
436
- return {}, dict(blank), dict(blank)
1135
+ return {}, _unavailable(), _unavailable()
437
1136
  path = ledger_path()
438
- lock = _acquire_lock(path)
1137
+ lock = _acquire_lock(path, UI_LOCK_WAIT)
439
1138
  if lock is None:
440
- return {}, dict(blank), dict(blank)
1139
+ return _stale_view(path, sid)
441
1140
  try:
442
1141
  return _ledger_update_unlocked(data, sid, path)
443
1142
  finally:
444
1143
  _release_lock(lock)
445
1144
 
446
1145
 
1146
+ def _stale_view(path, sid):
1147
+ rec = _load_summary(path, sid)
1148
+ if rec is None:
1149
+ return {}, _unavailable(stale=True), _unavailable(stale=True)
1150
+ return rec, _session_output(rec, True), _today_output(path, sid, rec, True, prune=False)
1151
+
1152
+
447
1153
  def _ledger_update_unlocked(data, sid, path):
448
- """Read-modify-write the per-session ledger. Accumulate the session's TRUE token
449
- COUNTS by tailing its transcript (deduped per requestId), take the COST from the
450
- host's cost.total_cost_usd, and return (session record, this-session totals,
451
- today's totals across sessions). Best-effort; safe blanks on any failure.
452
-
453
- The statusline re-renders in a fresh process on every refresh, so this is the
454
- product's hottest filesystem path: each write below is skipped unless it would
455
- actually change bytes on disk (#392). The on-disk FORMAT is unchanged — the
456
- per-session shard and the whole-ledger compatibility snapshot are both still
457
- written exactly as before, just no longer unconditionally."""
458
- blank = {"in": 0.0, "out": 0.0, "cost": 0.0}
459
1154
  now = time.time()
460
- today = datetime.now().strftime("%Y-%m-%d")
461
-
462
- sessions, legacy = _merge_sessions(path)
463
-
464
- rec = sessions.get(sid)
465
- dirty = not isinstance(rec, dict)
466
- if dirty:
467
- rec = {}
468
- stored_ts = num(rec.get("last_ts"))
469
- if "first_ts" not in rec:
470
- rec["first_ts"] = now
471
- dirty = True
472
- if rec.get("last_day") != today:
473
- rec["last_day"] = today
474
- dirty = True
475
- host_cost = num(get(data, "cost", "total_cost_usd"))
476
- if rec.get("host_cost") != host_cost:
477
- rec["host_cost"] = host_cost
478
- dirty = True
479
-
480
1155
  tx = data.get("transcript_path") if isinstance(data, dict) else None
481
- if safe(_tx_accumulate, rec, tx):
482
- dirty = True
483
- sess = _totals(_agg_reqs(rec.get("reqs"))) # tokens: this session, all requests (deduped)
484
- # Cost = Claude Code's authoritative cost.total_cost_usd — it already prices every
485
- # call (including subagents in separate transcripts) exactly as your bill does, so
486
- # it is far more accurate than recomputing tokens*price. api_cost is fallback only.
487
- if rec["host_cost"] > 0:
488
- sess["cost"] = rec["host_cost"]
489
- bucket = dict(_totals(_agg_reqs(rec.get("reqs"), only=today)), date=today)
490
- if rec.get("today") != bucket:
491
- rec["today"] = bucket
492
- dirty = True
493
- if "tot" in rec: # retire the batch-1 whole-session cache key
494
- del rec["tot"]
495
- dirty = True
496
- # Heartbeat: `last_ts` is pure liveness for the TTL sweep, so it rides along
497
- # with a write we are already making rather than forcing one of its own.
498
- if dirty or now - stored_ts >= LEDGER_HEARTBEAT:
1156
+ tx = tx if isinstance(tx, str) and tx else None
1157
+ rec = _load_summary(path, sid)
1158
+ on_disk = json.dumps(rec, sort_keys=True) if rec is not None else None
1159
+ if rec is None:
1160
+ stale_disk = os.path.exists(_acct_file(path, sid))
1161
+ _discard_evidence(path, sid)
1162
+ if stale_disk:
1163
+ _remove(_acct_file(path, sid))
1164
+ rec = _seed_summary(path, sid, now)
1165
+ if "sess_start" not in rec:
1166
+ start = _read_json(_start_file(path, sid))
1167
+ value = num(start.get("sess_start"), None) if isinstance(start, dict) else None
1168
+ if value is not None and math.isfinite(value):
1169
+ rec["sess_start"] = float(value)
1170
+ if tx and rec.get("ppath") not in (None, tx):
1171
+ _discard_evidence(path, sid)
1172
+ rec = _seed_summary(path, sid, now, keep=rec)
1173
+ _update_host(rec, data)
1174
+ flags = {"catching": False, "list_failed": False, "bound": False}
1175
+ store = _Store(path, sid, rec)
1176
+ if tx:
1177
+ rec["ppath"] = tx
1178
+ try:
1179
+ flags = _account(store, rec, tx)
1180
+ except _Inconsistent:
1181
+ _discard_evidence(path, sid)
1182
+ rec = _seed_summary(path, sid, now, keep=rec)
1183
+ rec["ppath"] = tx
1184
+ store = _Store(path, sid, rec)
1185
+ flags = _account(store, rec, tx)
1186
+ reasons = _session_reasons(rec, flags)
1187
+ state = _session_state(rec, flags, reasons, tx is not None)
1188
+ src_reasons = sorted(r for r in reasons if r not in rec["rc"])
1189
+ rec["cov"] = {"state": state, "reasons": sorted(reasons), "src_reasons": src_reasons}
1190
+ if rec["days"].get(_today()) is not None or rec.get("last_day") is None:
1191
+ rec["last_day"] = _today()
1192
+ changed = on_disk is None or json.dumps(rec, sort_keys=True) != on_disk
1193
+ if changed or now - num(rec.get("last_ts")) >= LEDGER_HEARTBEAT:
499
1194
  rec["last_ts"] = now
500
- dirty = True
501
- sessions[sid] = rec
502
-
503
- for k in list(sessions.keys()):
504
- v = sessions[k]
505
- if not isinstance(v, dict) or now - num(v.get("last_ts")) > SESSION_TTL:
506
- del sessions[k]
507
- # Each session owns one independently replaced shard. A writer for another
508
- # session therefore cannot replace this record with an older snapshot. The
509
- # merged map above already reflects every shard read under this same lock, so
510
- # there is nothing a re-read could learn — no concurrent writer can have run.
511
- if dirty:
512
- _atomic_json(_session_file(path, sid), {"sid": sid, "rec": rec})
513
- if sessions != legacy:
514
- # Compatibility/readability cache; shards are truth. Rewritten only when
515
- # it has drifted from the merged truth — which still covers TTL pruning,
516
- # a new session, and a legacy-only record that a shard has superseded.
517
- _write_snapshot(path, sessions)
518
-
519
- # Today = each session's TODAY bucket (tokens whose transcript timestamp falls
520
- # on the current local day), summed across sessions — not whole-session totals.
521
- day = dict(blank)
522
- for v in sessions.values():
523
- if not isinstance(v, dict):
524
- continue
525
- t = v.get("today") if isinstance(v.get("today"), dict) else None
526
- if t and t.get("date") == today: # tokens: true per-calendar-day buckets
527
- day["in"] += num(t.get("in"))
528
- day["out"] += num(t.get("out"))
529
- if v.get("last_day") == today: # cost: host per-session total, day-attributed
530
- day["cost"] += num(v.get("host_cost"))
531
- return rec, sess, day
1195
+ seq = rec["seq"] + 1
1196
+ if store.flush(seq):
1197
+ rec["seq"] = seq
1198
+ if _atomic_json(_acct_file(path, sid), {"schema": ACCT_SCHEMA, "sid": sid,
1199
+ "seq": seq, "rec": rec}):
1200
+ for obsolete in store.obsolete:
1201
+ _remove(obsolete)
1202
+ return rec, _session_output(rec, False), _today_output(path, sid, rec, False, prune=True)
532
1203
 
533
1204
 
534
1205
  def persist_sess_start(sid, value):
535
- """Write a resolved wall-clock session-start epoch into the ledger record for
536
- `sid` so later renders read it from the ledger instead of re-scanning the host's
537
- session-metadata directory (the statusline fast path). Best-effort and idempotent:
538
- a no-op if already stored, and a silent skip on any I/O error — it must never
539
- break a render. Returns True iff the ledger was written."""
1206
+ """Write a resolved wall-clock session-start epoch beside the session's ledger
1207
+ summary so later renders read it instead of re-scanning the host's session
1208
+ metadata directory. Best-effort and idempotent; never breaks a render.
1209
+ Returns True iff the start file was written."""
540
1210
  if not sid or not value:
541
1211
  return False
542
1212
  path = ledger_path()
@@ -550,23 +1220,16 @@ def persist_sess_start(sid, value):
550
1220
 
551
1221
 
552
1222
  def _persist_sess_start_unlocked(sid, value, path):
553
- sessions, legacy = _merge_sessions(path)
554
- rec = sessions.get(sid)
555
- if not isinstance(rec, dict):
1223
+ rec = _load_summary(path, sid)
1224
+ if rec is None:
556
1225
  return False
557
1226
  if num(rec.get("sess_start"), None) == float(value):
558
- return False # already cached — nothing to do
559
- # Cache metadata has its own file, so it cannot overwrite a concurrently
560
- # refreshed token/cost shard for the same session.
561
- if not _atomic_json(_start_file(path, sid),
562
- {"sid": sid, "sess_start": float(value)}):
563
1227
  return False
564
- # Applying the value we just persisted is exactly what a re-merge would
565
- # produce — the start-file overlay in _merge_sessions — without the reread.
566
- rec["sess_start"] = float(value)
567
- if sessions != legacy:
568
- _write_snapshot(path, sessions)
569
- return True
1228
+ current = _read_json(_start_file(path, sid))
1229
+ if isinstance(current, dict) and num(current.get("sess_start"), None) == float(value):
1230
+ return False
1231
+ # Its own file, so it can never overwrite a concurrently refreshed summary.
1232
+ return _atomic_json(_start_file(path, sid), {"sid": sid, "sess_start": float(value)})
570
1233
 
571
1234
 
572
1235
  # --------------------------------------------------------------------------- Pi usage ledger
@@ -1027,9 +1690,13 @@ def _pi_ledger_update_unlocked(path, session_key, scan_start, scan_end, facts):
1027
1690
 
1028
1691
 
1029
1692
  def burn_samples(rec):
1030
- """Recent per-message token-burn values (most-recent window) for the sparkline —
1031
- real per-API-call totals accumulated from the transcript, not a time-extrapolated
1032
- estimate. Returns [] when there is too little data to draw a line. The statusline
1033
- turns this list into a colored sparkline; this lib stays render-free."""
1034
- b = [num(x) for x in (rec.get("burn") or []) if isinstance(x, (int, float))]
1035
- return b[-24:] if len(b) >= 2 else []
1693
+ """Recent per-request token-burn values (most-recent window) for the sparkline:
1694
+ one sample per accepted PARENT request (spec AC-24), not a time-extrapolated
1695
+ estimate. Returns [] when there is too little data to draw a line. The
1696
+ statusline turns this list into a colored sparkline; this lib stays render-free."""
1697
+ out = []
1698
+ for item in (rec.get("burn") or []) if isinstance(rec, dict) else []:
1699
+ v = item[1] if isinstance(item, list) and len(item) == 2 else item
1700
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
1701
+ out.append(float(v))
1702
+ return out[-24:] if len(out) >= 2 else []