cctally 1.28.0 → 1.29.0

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.
@@ -29,6 +29,25 @@ def _project_label(cwd) -> str:
29
29
  return os.path.basename(cwd.rstrip("/")) or cwd
30
30
 
31
31
 
32
+ def _subagent_key(source_path):
33
+ """Privacy-safe subagent-thread identity for the reader. Each subagent (Task)
34
+ invocation writes its own ``agent-<hash>.jsonl``; the main session is
35
+ ``<session_id>.jsonl``. Returns the agent hash (``agent-`` prefix + ``.jsonl``
36
+ suffix stripped; an ``acompact-`` middle is kept), or ``None`` for the main
37
+ file / a non-agent path. We expose ONLY this derived key — never the raw
38
+ absolute ``source_path`` (which leaks home dir / username / encoded project,
39
+ and the conversation routes are LAN-exposable via dashboard.expose_transcripts)."""
40
+ if not source_path:
41
+ return None
42
+ base = os.path.basename(source_path)
43
+ if not base.startswith("agent-"):
44
+ return None
45
+ stem = base[len("agent-"):]
46
+ if stem.endswith(".jsonl"):
47
+ stem = stem[: -len(".jsonl")]
48
+ return stem or None
49
+
50
+
32
51
  def _entry_cost(model, inp, out, cc, cr, cost_usd_raw) -> float:
33
52
  """Cost for one session_entries row via the shared pricing helper. Tokens →
34
53
  the helper's usage dict. cost_usd_raw is passed as the optional override the
@@ -198,7 +217,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
198
217
  # uuid, so the first occurrence in ascending order is canonical.
199
218
  raw = conn.execute(
200
219
  "SELECT id, uuid, timestamp_utc, entry_type, text, blocks_json, model, "
201
- " msg_id, req_id, is_sidechain, cwd, git_branch "
220
+ " msg_id, req_id, is_sidechain, cwd, git_branch, source_path, parent_uuid "
202
221
  "FROM conversation_messages WHERE session_id=? "
203
222
  "ORDER BY timestamp_utc, id", (session_id,)).fetchall()
204
223
 
@@ -224,7 +243,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
224
243
  turn_index = {} # (msg_id, req_id) -> index into items
225
244
  for row in logical:
226
245
  (rid, u, ts, etype, text, blocks, model, msg_id, req_id,
227
- is_sc, cwd, branch) = row
246
+ is_sc, cwd, branch, source_path, parent_uuid) = row
228
247
  if etype == "assistant" and msg_id is not None:
229
248
  key = (msg_id, req_id)
230
249
  idx = turn_index.get(key)
@@ -327,6 +346,12 @@ def _build_turn(members):
327
346
  "blocks": [],
328
347
  "model": first[6],
329
348
  "is_sidechain": bool(first[9]),
349
+ # subagent_key / parent_uuid are SEED-sourced (the first fragment, the
350
+ # turn's entry point) and NOT re-promoted in _fold_fragment — the prose
351
+ # anchor's parent_uuid is an intra-turn link, not the entry point (Codex
352
+ # P1). subagent_key is uniform across a turn's fragments (one file).
353
+ "subagent_key": _subagent_key(first[12]),
354
+ "parent_uuid": first[13],
330
355
  "_msg_id": first[7],
331
356
  "_req_id": first[8],
332
357
  "_has_prose": False,
@@ -374,7 +399,8 @@ def _build_simple(row):
374
399
  key → no session_entries join); it carries an explicit cost_usd of 0.0 and NO
375
400
  internal _msg_id/_req_id keys, so the cost loop's KeyError path can never fire
376
401
  (I2). The model is preserved for assistant rows."""
377
- (rid, u, ts, etype, text, blocks, model, msg_id, req_id, is_sc, cwd, branch) = row
402
+ (rid, u, ts, etype, text, blocks, model, msg_id, req_id, is_sc, cwd, branch,
403
+ source_path, parent_uuid) = row
378
404
  try:
379
405
  parsed = _json.loads(blocks or "[]")
380
406
  except (ValueError, TypeError):
@@ -387,6 +413,8 @@ def _build_simple(row):
387
413
  "text": text,
388
414
  "blocks": parsed,
389
415
  "is_sidechain": bool(is_sc),
416
+ "subagent_key": _subagent_key(source_path),
417
+ "parent_uuid": parent_uuid,
390
418
  }
391
419
  if etype == "assistant":
392
420
  item["model"] = model
@@ -440,19 +468,6 @@ def _row_to_hit(uuid_, sid, ts, cwd, snippet, msg_id, req_id):
440
468
  }
441
469
 
442
470
 
443
- def _dedup_hits(hits, limit, offset):
444
- seen = set()
445
- out = []
446
- for h in hits:
447
- key = (h["session_id"], h["uuid"])
448
- if key in seen:
449
- continue
450
- seen.add(key)
451
- out.append(h)
452
- total = len(out)
453
- return out[offset:offset + limit], total
454
-
455
-
456
471
  def _attach_costs(conn, page):
457
472
  """Compute turn cost for the FINAL page's hits in ONE _turn_cost_map call,
458
473
  then map it onto each hit and drop the private `_turn_key`. Off-page and
@@ -465,45 +480,126 @@ def _attach_costs(conn, page):
465
480
  return page
466
481
 
467
482
 
468
- def _search_fts(conn, q, limit, offset):
469
- sql = (
470
- "SELECT cm.session_id, cm.uuid, cm.timestamp_utc, cm.cwd, "
471
- " cm.msg_id, cm.req_id, "
472
- " snippet(conversation_fts, 0, '[', ']', ' ', 12) AS snip "
483
+ def _like_pattern(q):
484
+ """Build the LIKE pattern for `q`. Escape the ESCAPE char (\\) FIRST, then
485
+ the wildcards otherwise a query containing a backslash (incl. a trailing
486
+ one) mis-escapes the appended '%' and the LIKE silently matches nothing
487
+ (paired with ESCAPE '\\' in the queries below)."""
488
+ return ("%" + q.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
489
+ + "%")
490
+
491
+
492
+ def _fts_snippets(conn, fts_q, ids):
493
+ """{rowid: snippet} for the page rowids ONLY (#149). snippet() needs an
494
+ active MATCH, so it can't be deferred to an outer query over the page CTE;
495
+ a second bounded MATCH restricted to the page rowids generates snippets for
496
+ at most one page of hits instead of every corpus match."""
497
+ if not ids:
498
+ return {}
499
+ ph = ",".join("?" for _ in ids)
500
+ rows = conn.execute(
501
+ "SELECT cm.id, snippet(conversation_fts, 0, '[', ']', ' … ', 12) "
473
502
  "FROM conversation_fts "
474
503
  "JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
475
- "WHERE conversation_fts MATCH ? "
476
- # cm.id is the final tiebreaker so equal (rank, timestamp) hits order
477
- # deterministically — _dedup_hits keeps the FIRST occurrence, so without
478
- # it the surviving snippet/cost (and page boundary) would flip run-to-run.
479
- "ORDER BY bm25(conversation_fts), cm.timestamp_utc DESC, cm.id DESC"
480
- )
481
- raw = conn.execute(sql, (_fts_query(q),)).fetchall()
482
- hits = [_row_to_hit(u, sid, ts, cwd, snip, mid, rqd)
483
- for (sid, u, ts, cwd, mid, rqd, snip) in raw]
484
- page, total = _dedup_hits(hits, limit, offset)
485
- return {"query": q, "mode": "fts", "hits": _attach_costs(conn, page),
504
+ f"WHERE conversation_fts MATCH ? AND cm.id IN ({ph})",
505
+ (fts_q, *ids),
506
+ ).fetchall()
507
+ return {r[0]: r[1] for r in rows}
508
+
509
+
510
+ def _texts_for_ids(conn, ids):
511
+ """{rowid: text} for the page rowids ONLY (#149) the LIKE page query omits
512
+ `text` so we never pull every matched row's body into Python; this fetches
513
+ it for just the page so `_manual_snippet` runs at most `limit` times."""
514
+ if not ids:
515
+ return {}
516
+ ph = ",".join("?" for _ in ids)
517
+ rows = conn.execute(
518
+ f"SELECT id, text FROM conversation_messages WHERE id IN ({ph})",
519
+ tuple(ids),
520
+ ).fetchall()
521
+ return {r[0]: r[1] for r in rows}
522
+
523
+
524
+ def _search_fts(conn, q, limit, offset):
525
+ # All of dedup + paging + total live in SQL (#149) so Python never holds
526
+ # more than one page of hits/snippets, regardless of corpus match count.
527
+ fts_q = _fts_query(q)
528
+ # Exact post-dedup logical total — counted in C with no snippet generation
529
+ # and no Python row materialization.
530
+ total = conn.execute(
531
+ "SELECT COUNT(*) FROM ("
532
+ " SELECT DISTINCT cm.session_id, cm.uuid "
533
+ " FROM conversation_fts "
534
+ " JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
535
+ " WHERE conversation_fts MATCH ?)",
536
+ (fts_q,),
537
+ ).fetchone()[0]
538
+ # One row per logical (session_id, uuid): ROW_NUMBER()=1 keeps the SAME row
539
+ # the old Python dedup kept as its FIRST occurrence (order: bm25, ts DESC,
540
+ # id DESC — cm.id is the final deterministic tiebreaker), so the surviving
541
+ # snippet/cost and the page boundary stay byte-stable. bm25 still ranks
542
+ # across all matches (inherent to relevance ordering).
543
+ #
544
+ # bm25 is materialized as a plain `rank` column in the inner `matched` CTE
545
+ # before the window function runs: FTS5 auxiliary functions (bm25/snippet)
546
+ # may only be used directly against the MATCH query, NOT inside a window
547
+ # ORDER BY ("unable to use function bm25 in the requested context").
548
+ page = conn.execute(
549
+ "WITH matched AS ("
550
+ " SELECT cm.id AS rid, cm.session_id AS sid, cm.uuid AS uuid, "
551
+ " cm.timestamp_utc AS ts, cm.cwd AS cwd, "
552
+ " cm.msg_id AS mid, cm.req_id AS rqd, "
553
+ " bm25(conversation_fts) AS rank "
554
+ " FROM conversation_fts "
555
+ " JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
556
+ " WHERE conversation_fts MATCH ?), "
557
+ "ranked AS ("
558
+ " SELECT *, ROW_NUMBER() OVER ("
559
+ " PARTITION BY sid, uuid ORDER BY rank, ts DESC, rid DESC"
560
+ " ) AS rn "
561
+ " FROM matched) "
562
+ "SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
563
+ "ORDER BY rank, ts DESC, rid DESC LIMIT ? OFFSET ?",
564
+ (fts_q, limit, offset),
565
+ ).fetchall()
566
+ snips = _fts_snippets(conn, fts_q, [r[0] for r in page])
567
+ hits = [_row_to_hit(uuid, sid, ts, cwd, snips.get(rid, ""), mid, rqd)
568
+ for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
569
+ return {"query": q, "mode": "fts", "hits": _attach_costs(conn, hits),
486
570
  "total": total}
487
571
 
488
572
 
489
573
  def _search_like(conn, q, limit, offset):
490
- # Escape the ESCAPE char (\) FIRST, then the wildcards otherwise a query
491
- # containing a backslash (incl. a trailing one) mis-escapes the appended
492
- # '%' and the LIKE silently matches nothing (ESCAPE '\' below).
493
- like = ("%" + q.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
494
- + "%")
495
- sql = (
496
- "SELECT session_id, uuid, timestamp_utc, cwd, msg_id, req_id, text "
497
- "FROM conversation_messages "
498
- "WHERE text LIKE ? ESCAPE '\\' AND text != '' "
499
- "ORDER BY timestamp_utc DESC, id DESC"
500
- )
501
- hits = []
502
- for sid, u, ts, cwd, mid, rqd, text in conn.execute(sql, (like,)):
503
- hits.append(_row_to_hit(u, sid, ts, cwd,
504
- _manual_snippet(text, q), mid, rqd))
505
- page, total = _dedup_hits(hits, limit, offset)
506
- return {"query": q, "mode": "like", "hits": _attach_costs(conn, page),
574
+ # SQL-bounded mirror of _search_fts for the no-FTS5 fallback (#149); the
575
+ # COUNT + page each scan the table once (the degraded path already lacks an
576
+ # index for the substring match).
577
+ like = _like_pattern(q)
578
+ total = conn.execute(
579
+ "SELECT COUNT(*) FROM ("
580
+ " SELECT DISTINCT session_id, uuid FROM conversation_messages "
581
+ " WHERE text LIKE ? ESCAPE '\\' AND text != '')",
582
+ (like,),
583
+ ).fetchone()[0]
584
+ page = conn.execute(
585
+ "WITH ranked AS ("
586
+ " SELECT id AS rid, session_id AS sid, uuid AS uuid, "
587
+ " timestamp_utc AS ts, cwd AS cwd, msg_id AS mid, req_id AS rqd, "
588
+ " ROW_NUMBER() OVER ("
589
+ " PARTITION BY session_id, uuid "
590
+ " ORDER BY timestamp_utc DESC, id DESC"
591
+ " ) AS rn "
592
+ " FROM conversation_messages "
593
+ " WHERE text LIKE ? ESCAPE '\\' AND text != '') "
594
+ "SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
595
+ "ORDER BY ts DESC, rid DESC LIMIT ? OFFSET ?",
596
+ (like, limit, offset),
597
+ ).fetchall()
598
+ texts = _texts_for_ids(conn, [r[0] for r in page])
599
+ hits = [_row_to_hit(uuid, sid, ts, cwd,
600
+ _manual_snippet(texts.get(rid, ""), q), mid, rqd)
601
+ for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
602
+ return {"query": q, "mode": "like", "hits": _attach_costs(conn, hits),
507
603
  "total": total}
508
604
 
509
605
 
package/bin/_lib_jsonl.py CHANGED
@@ -201,6 +201,68 @@ def _parse_usage_entries(
201
201
  return no_key_entries
202
202
 
203
203
 
204
+ def parse_cost_entry(obj, path_str: str):
205
+ """Pure per-line cost parser: given a parsed JSONL object, return
206
+ ``(UsageEntry, msg_id, req_id)`` when it is a billable assistant entry, or
207
+ ``None`` otherwise (non-assistant, missing/invalid usage, model, or
208
+ timestamp, or a ``<synthetic>`` placeholder). No I/O, no byte offset — the
209
+ caller owns the readline()+tell() loop.
210
+
211
+ Extracted (#138) so the streaming ``_iter_jsonl_entries_with_offsets`` reader
212
+ and the fused single-pass sync walker (``_cctally_cache._iter_sync_entries``)
213
+ share ONE gating implementation — each JSONL line is ``json.loads``-parsed
214
+ once and classified once, never re-parsed for a separate second walk.
215
+ """
216
+ if obj.get("type") != "assistant":
217
+ return None
218
+
219
+ ts_raw = obj.get("timestamp")
220
+ if not isinstance(ts_raw, str) or not ts_raw.strip():
221
+ return None
222
+
223
+ msg = obj.get("message")
224
+ if not isinstance(msg, dict):
225
+ msg = obj
226
+
227
+ usage = msg.get("usage")
228
+ if not isinstance(usage, dict):
229
+ return None
230
+
231
+ model = msg.get("model") or obj.get("model")
232
+ if not isinstance(model, str) or not model.strip():
233
+ return None
234
+ model = model.strip()
235
+ if model == "<synthetic>":
236
+ # Matches ccusage's claude_loader.rs:454. Filtered here so the cache
237
+ # ingest path can't accidentally store these rows even if a downstream
238
+ # loop forgets to double-check (see `sync_cache` in _cctally_cache.py).
239
+ return None
240
+
241
+ try:
242
+ ts = dt.datetime.fromisoformat(ts_raw.strip().replace("Z", "+00:00"))
243
+ if ts.tzinfo is None:
244
+ ts = ts.replace(tzinfo=dt.timezone.utc)
245
+ except ValueError:
246
+ return None
247
+
248
+ msg_id = msg.get("id")
249
+ req_id = obj.get("requestId")
250
+ cost_usd_raw = obj.get("costUSD")
251
+ cost_usd = float(cost_usd_raw) if cost_usd_raw is not None else None
252
+
253
+ return (
254
+ UsageEntry(
255
+ timestamp=ts,
256
+ model=model,
257
+ usage=usage,
258
+ cost_usd=cost_usd,
259
+ source_path=path_str,
260
+ ),
261
+ msg_id,
262
+ req_id,
263
+ )
264
+
265
+
204
266
  def _iter_jsonl_entries_with_offsets(fh, path_str: str):
205
267
  """Yield (byte_offset, UsageEntry, msg_id, req_id) for each assistant
206
268
  entry starting from fh's current position.
@@ -209,7 +271,9 @@ def _iter_jsonl_entries_with_offsets(fh, path_str: str):
209
271
  accurate for resume-from-offset after partial ingests. Malformed JSON
210
272
  and non-assistant lines are skipped, but the offset still advances past
211
273
  them so they are never re-read. Range filtering is intentionally NOT
212
- done here — filters are applied at query time by iter_entries().
274
+ done here — filters are applied at query time by iter_entries(). The
275
+ per-line gating lives in ``parse_cost_entry`` (shared with the fused
276
+ single-pass sync walker, #138).
213
277
  """
214
278
  while True:
215
279
  offset = fh.tell()
@@ -230,56 +294,11 @@ def _iter_jsonl_entries_with_offsets(fh, path_str: str):
230
294
  obj = json.loads(stripped)
231
295
  except json.JSONDecodeError:
232
296
  continue
233
- if obj.get("type") != "assistant":
234
- continue
235
-
236
- ts_raw = obj.get("timestamp")
237
- if not isinstance(ts_raw, str) or not ts_raw.strip():
238
- continue
239
-
240
- msg = obj.get("message")
241
- if not isinstance(msg, dict):
242
- msg = obj
243
-
244
- usage = msg.get("usage")
245
- if not isinstance(usage, dict):
246
- continue
247
-
248
- model = msg.get("model") or obj.get("model")
249
- if not isinstance(model, str) or not model.strip():
297
+ parsed = parse_cost_entry(obj, path_str)
298
+ if parsed is None:
250
299
  continue
251
- model = model.strip()
252
- if model == "<synthetic>":
253
- # Matches ccusage's claude_loader.rs:454. Filtered at the
254
- # iterator level so the cache ingest path can't accidentally
255
- # store these rows even if a downstream loop forgets to
256
- # double-check (see `sync_cache` in _cctally_cache.py).
257
- continue
258
-
259
- try:
260
- ts = dt.datetime.fromisoformat(ts_raw.strip().replace("Z", "+00:00"))
261
- if ts.tzinfo is None:
262
- ts = ts.replace(tzinfo=dt.timezone.utc)
263
- except ValueError:
264
- continue
265
-
266
- msg_id = msg.get("id")
267
- req_id = obj.get("requestId")
268
- cost_usd_raw = obj.get("costUSD")
269
- cost_usd = float(cost_usd_raw) if cost_usd_raw is not None else None
270
-
271
- yield (
272
- offset,
273
- UsageEntry(
274
- timestamp=ts,
275
- model=model,
276
- usage=usage,
277
- cost_usd=cost_usd,
278
- source_path=path_str,
279
- ),
280
- msg_id,
281
- req_id,
282
- )
300
+ entry, msg_id, req_id = parsed
301
+ yield (offset, entry, msg_id, req_id)
283
302
 
284
303
 
285
304
  _CODEX_FILENAME_UUID_RE = re.compile(
package/bin/cctally CHANGED
@@ -2100,18 +2100,18 @@ get_max_milestone_for_week = _cctally_milestones.get_max_milestone_for_
2100
2100
  get_milestone_cost_for_week = _cctally_milestones.get_milestone_cost_for_week # record shim
2101
2101
  get_milestones_for_week = _cctally_milestones.get_milestones_for_week # forecast c.; tui shim; percent-breakdown c.
2102
2102
  insert_percent_milestone = _cctally_milestones.insert_percent_milestone # record shim; idempotency-test mod.
2103
- insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim
2103
+ insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim; test_budget_alerts / test_project_budget_dashboard ns[] (+ test_codex_budget_alerts / test_projected_alerts post-#143 vendor-param unification)
2104
2104
  insert_project_budget_milestone = _cctally_milestones.insert_project_budget_milestone # record shim; project-budget-config-test ns[]
2105
- insert_codex_budget_milestone = _cctally_milestones.insert_codex_budget_milestone # record shim; test_codex_budget_alerts ns[]
2106
- _codex_budget_crossings = _cctally_milestones._codex_budget_crossings # record shim (shared INSERT-and-arm core for the codex_budget axis)
2105
+ _budget_crossings = _cctally_milestones._budget_crossings # record shim (shared INSERT-and-arm core for the budget axis, both vendors, #143)
2107
2106
  _resolve_codex_budget_period_window = _cctally_milestones._resolve_codex_budget_period_window # record shim; milestones c. (codex period window)
2108
- _reconcile_codex_budget_milestones_on_set = _cctally_milestones._reconcile_codex_budget_milestones_on_set # test_codex_budget_alerts ns[]; forecast set/reconcile
2107
+ _resolve_budget_window = _cctally_milestones._resolve_budget_window # record shim; milestones c. (per-vendor cheap budget window dispatcher, #143)
2108
+ _budget_spend_for_vendor = _cctally_milestones._budget_spend_for_vendor # record shim; milestones c. (per-vendor budget spend dispatcher, #143)
2109
2109
  _reconcile_codex_budget_on_config_write = _cctally_milestones._reconcile_codex_budget_on_config_write # forecast/config c. (forward-only codex-budget reconcile)
2110
2110
  _resolve_claude_budget_window = _cctally_milestones._resolve_claude_budget_window # record shim; milestones c. (period-aware Claude budget window)
2111
2111
  _project_crossings = _cctally_milestones._project_crossings # record shim; milestones c. (#130 firing/reconcile shared crossing arithmetic)
2112
2112
  insert_projected_milestone = _cctally_milestones.insert_projected_milestone # record shim
2113
2113
  _projected_levels_already_latched = _cctally_milestones._projected_levels_already_latched # record shim
2114
- _reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts ns[]
2114
+ _reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts / test_codex_budget_alerts ns[] (vendor-param, #143)
2115
2115
  _reconcile_budget_on_config_write = _cctally_milestones._reconcile_budget_on_config_write # forecast/config/dashboard c.; test_forecast_ns_patch mod. patch
2116
2116
  _reconcile_project_budget_milestones_on_write = _cctally_milestones._reconcile_project_budget_milestones_on_write # forecast/config/dashboard c. (forward-only project-budget reconcile)
2117
2117