cctally 1.43.2 → 1.44.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.
- package/CHANGELOG.md +15 -0
- package/bin/_cctally_cache.py +257 -8
- package/bin/_cctally_dashboard.py +29 -2
- package/bin/_cctally_db.py +59 -0
- package/bin/_lib_conversation.py +49 -0
- package/bin/_lib_conversation_query.py +122 -8
- package/bin/cctally +1 -0
- package/dashboard/static/assets/{index-qxISkiGC.js → index-C_xsUDIl.js} +9 -9
- package/dashboard/static/assets/{index-mPwkGfE9.css → index-Drpkfv6k.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
|
@@ -196,6 +196,19 @@ def _session_titles_map(conn, session_ids):
|
|
|
196
196
|
if not session_ids:
|
|
197
197
|
return {}
|
|
198
198
|
titles = {}
|
|
199
|
+
# #193: AI title wins when present. Query the dedicated table first; the
|
|
200
|
+
# existing first-prompt scan below fills only sessions WITHOUT one (its
|
|
201
|
+
# ``if sid in titles: continue`` guard skips ai-title sessions for free).
|
|
202
|
+
try:
|
|
203
|
+
ph0 = ",".join("?" for _ in session_ids)
|
|
204
|
+
for sid, at in conn.execute(
|
|
205
|
+
f"SELECT session_id, ai_title FROM conversation_ai_titles "
|
|
206
|
+
f"WHERE session_id IN ({ph0})", tuple(session_ids)
|
|
207
|
+
).fetchall():
|
|
208
|
+
if at:
|
|
209
|
+
titles[sid] = at
|
|
210
|
+
except sqlite3.OperationalError:
|
|
211
|
+
pass # table absent (pre-migration / :memory:) -> fall through to first-prompt
|
|
199
212
|
# While 005's reingest is pending, a stale `human` row may actually be an
|
|
200
213
|
# injected skill body (a SessionStart skill can even lead the transcript) —
|
|
201
214
|
# skip those as title candidates so the rail never shows "Base directory for
|
|
@@ -339,18 +352,73 @@ def _session_latest_meta_map(conn, session_ids):
|
|
|
339
352
|
return meta
|
|
340
353
|
|
|
341
354
|
|
|
355
|
+
# Rail sort keys, in the rollup table's STRUCTURAL columns (Task A). ``recent``
|
|
356
|
+
# rides idx_conv_sessions_recent(last_activity_utc DESC, session_id DESC) and
|
|
357
|
+
# early-terminates at LIMIT with no temp B-tree; ``oldest`` scan-sorts the few
|
|
358
|
+
# thousand rollup rows (microseconds). The live fallback re-expresses the SAME
|
|
359
|
+
# orderings over conversation_messages aggregates via _SORTS_LIVE below.
|
|
360
|
+
# When adding a sort key, add it to BOTH _SORTS and _SORTS_LIVE and exercise it
|
|
361
|
+
# in test_conversation_rail_rollup_reconcile.py (its _dump asserts the test's
|
|
362
|
+
# sort list covers exactly _SORTS.keys(), so a one-sided add fails loud).
|
|
342
363
|
_SORTS = {
|
|
364
|
+
"recent": "last_activity_utc DESC, session_id DESC",
|
|
365
|
+
"oldest": "started_utc ASC, session_id ASC",
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
# The matching ORDER BY for the live GROUP BY fallback: the rollup's
|
|
369
|
+
# last_activity_utc IS MAX(timestamp_utc) and started_utc IS MIN(timestamp_utc),
|
|
370
|
+
# so the two branches paginate in byte-identical order (the load-bearing
|
|
371
|
+
# invariant). Keep this table in lockstep with _SORTS.
|
|
372
|
+
_SORTS_LIVE = {
|
|
343
373
|
"recent": "MAX(timestamp_utc) DESC, session_id DESC",
|
|
344
374
|
"oldest": "MIN(timestamp_utc) ASC, session_id ASC",
|
|
345
375
|
}
|
|
346
376
|
|
|
347
377
|
|
|
348
|
-
def
|
|
349
|
-
"""
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
378
|
+
def _rollup_authoritative(conn) -> bool:
|
|
379
|
+
"""True when the conversation_sessions rollup is authoritative — i.e. the
|
|
380
|
+
durable ``conversation_sessions_backfill_pending`` flag is NOT set, so the
|
|
381
|
+
rollup has been fully recomputed and the fast read is safe.
|
|
382
|
+
|
|
383
|
+
Returns True (authoritative) when the flag is absent, including the case
|
|
384
|
+
where cache_meta does not exist at all (a path-less / schema-not-applied or
|
|
385
|
+
in-memory connection). This mirrors Task A's
|
|
386
|
+
_conversation_sessions_backfill_pending OperationalError degrade-to-False —
|
|
387
|
+
here that means "no pending flag" -> authoritative -> read the rollup. A
|
|
388
|
+
populated cache.db always has cache_meta, so this only affects bare conns."""
|
|
389
|
+
try:
|
|
390
|
+
pending = conn.execute(
|
|
391
|
+
"SELECT 1 FROM cache_meta "
|
|
392
|
+
"WHERE key='conversation_sessions_backfill_pending'"
|
|
393
|
+
).fetchone() is not None
|
|
394
|
+
except sqlite3.OperationalError:
|
|
395
|
+
return True
|
|
396
|
+
return not pending
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _list_session_rows_rollup(conn, order, limit, offset):
|
|
400
|
+
"""FAST path: read the pre-aggregated rail rows straight from the
|
|
401
|
+
conversation_sessions rollup (spec §3). No GROUP BY, no temp B-tree for the
|
|
402
|
+
``recent`` sort. Returns (session_id, msg_count, started, last_activity)
|
|
403
|
+
tuples — the same shape the live aggregate yields, so the downstream
|
|
404
|
+
assembly is identical. No WHERE is needed: the rollup is non-null by
|
|
405
|
+
construction (PK NOT NULL; the recompute's GROUP BY already drops NULLs)."""
|
|
406
|
+
return conn.execute(
|
|
407
|
+
"SELECT session_id, msg_count, "
|
|
408
|
+
" started_utc AS started, last_activity_utc AS last_activity "
|
|
409
|
+
"FROM conversation_sessions "
|
|
410
|
+
"ORDER BY " + order + " LIMIT ? OFFSET ?",
|
|
411
|
+
(limit + 1, offset),
|
|
412
|
+
).fetchall()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _list_session_rows_live(conn, order, limit, offset):
|
|
416
|
+
"""RETAINED fallback (Codex gate BLOCKER 2): the original live GROUP BY over
|
|
417
|
+
conversation_messages, used while the rollup is not authoritative (the flag
|
|
418
|
+
is set — e.g. an existing install before its first sync, or permanently
|
|
419
|
+
under ``--no-sync``). Byte-identical output to the rollup branch by
|
|
420
|
+
construction; ``order`` here is the _SORTS_LIVE aggregate expression."""
|
|
421
|
+
return conn.execute(
|
|
354
422
|
"SELECT session_id, COUNT(*) AS msg_count, "
|
|
355
423
|
" MIN(timestamp_utc) AS started, MAX(timestamp_utc) AS last_activity "
|
|
356
424
|
"FROM conversation_messages "
|
|
@@ -359,6 +427,33 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
|
|
|
359
427
|
"ORDER BY " + order + " LIMIT ? OFFSET ?",
|
|
360
428
|
(limit + 1, offset),
|
|
361
429
|
).fetchall()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
|
|
433
|
+
"""All-history per-session browse rows (spec §3.1). NOT 365-day bounded.
|
|
434
|
+
|
|
435
|
+
Reads the conversation_sessions rollup (Task A) when it is authoritative —
|
|
436
|
+
i.e. the durable ``conversation_sessions_backfill_pending`` flag is clear —
|
|
437
|
+
which lets the ``recent`` sort early-terminate on idx_conv_sessions_recent
|
|
438
|
+
with no full-table aggregate. While the flag is set (an existing install
|
|
439
|
+
before its first sync consumes the backfill, or permanently under
|
|
440
|
+
``--no-sync``), it falls back to the RETAINED live GROUP BY over
|
|
441
|
+
conversation_messages — correct, possibly slower, never an empty rail.
|
|
442
|
+
|
|
443
|
+
The two branches are byte-identical for every (sort, limit, offset): the
|
|
444
|
+
rollup recomputes the same COUNT/MIN/MAX over the same rows, and the
|
|
445
|
+
fallback IS the old aggregate (pinned by the reconcile test over both
|
|
446
|
+
branches). Staleness bound: at most one sync tick (~5s) if a session gains
|
|
447
|
+
messages on the exact tick the rail reads — cosmetic only, and the rail
|
|
448
|
+
revalidates every visible tick anyway."""
|
|
449
|
+
limit = max(1, min(int(limit), 200))
|
|
450
|
+
offset = max(0, int(offset))
|
|
451
|
+
if _rollup_authoritative(conn):
|
|
452
|
+
order = _SORTS.get(sort, _SORTS["recent"])
|
|
453
|
+
rows = _list_session_rows_rollup(conn, order, limit, offset)
|
|
454
|
+
else:
|
|
455
|
+
order = _SORTS_LIVE.get(sort, _SORTS_LIVE["recent"])
|
|
456
|
+
rows = _list_session_rows_live(conn, order, limit, offset)
|
|
362
457
|
has_more = len(rows) > limit
|
|
363
458
|
rows = rows[:limit]
|
|
364
459
|
session_ids = [r[0] for r in rows]
|
|
@@ -536,6 +631,7 @@ def _assemble_session(conn, session_id):
|
|
|
536
631
|
# the top-level subagent_meta map (no undocumented block keys leak). Join is
|
|
537
632
|
# spawn tool_use id <-> tool_result tool_use_id; agent_id == subagent_key.
|
|
538
633
|
spawn_kind = {} # tool_use id -> subagent_type
|
|
634
|
+
spawn_desc = {} # tool_use id -> spawning Task description (#193)
|
|
539
635
|
agent_link = {} # tool_use id -> (agent_id, raw_meta)
|
|
540
636
|
ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
|
|
541
637
|
bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
|
|
@@ -549,6 +645,13 @@ def _assemble_session(conn, session_id):
|
|
|
549
645
|
st = b.pop("subagent_type", None)
|
|
550
646
|
if st and b.get("id") is not None:
|
|
551
647
|
spawn_kind[b["id"]] = st
|
|
648
|
+
# #193: harvest the spawning Task description from the
|
|
649
|
+
# already-stored bounded input. Guarded on subagent_type, so
|
|
650
|
+
# a Bash `description` (no subagent_type) is NEVER picked up.
|
|
651
|
+
_inp = b.get("input")
|
|
652
|
+
_d = _inp.get("description") if isinstance(_inp, dict) else None
|
|
653
|
+
if isinstance(_d, str) and _d.strip():
|
|
654
|
+
spawn_desc[b["id"]] = _d
|
|
552
655
|
elif k == "tool_result":
|
|
553
656
|
aid = b.pop("agent_id", None)
|
|
554
657
|
meta = b.pop("subagent_meta", None)
|
|
@@ -579,6 +682,8 @@ def _assemble_session(conn, session_id):
|
|
|
579
682
|
continue # spawn with no (yet) result -> title-only
|
|
580
683
|
_aid, _raw = _link
|
|
581
684
|
_entry = {"kind": _kind}
|
|
685
|
+
if spawn_desc.get(_tuid): # #193: spawning Task description
|
|
686
|
+
_entry["description"] = spawn_desc[_tuid]
|
|
582
687
|
for _f in ("total_tokens", "total_duration_ms", "total_tool_use_count", "status"):
|
|
583
688
|
if _raw.get(_f) is not None:
|
|
584
689
|
_entry[_f] = _raw[_f]
|
|
@@ -783,6 +888,13 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
783
888
|
subagent_meta = asm["subagent_meta"]
|
|
784
889
|
header_cost = asm["header_cost"]
|
|
785
890
|
|
|
891
|
+
# #193: compute the reader header title ONCE so BOTH return sites (the early
|
|
892
|
+
# empty/stale-cursor return and the normal return) carry it. Same fallback
|
|
893
|
+
# chain the rail/search rows use: ai-title -> first human prompt -> project
|
|
894
|
+
# label -> session_id (matching the list's `titles.get(sid) or pl or sid`).
|
|
895
|
+
_pl = _project_label(_latest(logical, 10))
|
|
896
|
+
_title = _session_titles_map(conn, [session_id]).get(session_id) or _pl or session_id
|
|
897
|
+
|
|
786
898
|
# Cursor pagination over the item list (anchored to each item's canonical id).
|
|
787
899
|
# A non-None `after` that matches no item's anchor (stale/deleted cursor)
|
|
788
900
|
# yields an EMPTY page — never silently re-serves the head (M1).
|
|
@@ -796,7 +908,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
796
908
|
if start is None:
|
|
797
909
|
return {
|
|
798
910
|
"session_id": session_id,
|
|
799
|
-
"
|
|
911
|
+
"title": _title,
|
|
912
|
+
"project_label": _pl,
|
|
800
913
|
"git_branch": _latest(logical, 11),
|
|
801
914
|
"started_utc": logical[0][2],
|
|
802
915
|
"last_activity_utc": logical[-1][2],
|
|
@@ -830,7 +943,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
830
943
|
models = sorted({r[6] for r in logical if r[6]})
|
|
831
944
|
return {
|
|
832
945
|
"session_id": session_id,
|
|
833
|
-
"
|
|
946
|
+
"title": _title,
|
|
947
|
+
"project_label": _pl,
|
|
834
948
|
"git_branch": _latest(logical, 11),
|
|
835
949
|
"started_utc": first[2],
|
|
836
950
|
"last_activity_utc": last[2],
|
package/bin/cctally
CHANGED
|
@@ -792,6 +792,7 @@ _progress_stderr = _cctally_cache._progress_stderr
|
|
|
792
792
|
_ensure_session_files_row = _cctally_cache._ensure_session_files_row
|
|
793
793
|
sync_cache = _cctally_cache.sync_cache
|
|
794
794
|
backfill_conversation_messages = _cctally_cache.backfill_conversation_messages
|
|
795
|
+
backfill_ai_titles = _cctally_cache.backfill_ai_titles
|
|
795
796
|
iter_entries = _cctally_cache.iter_entries
|
|
796
797
|
_collect_entries_direct = _cctally_cache._collect_entries_direct
|
|
797
798
|
_JoinedClaudeEntry = _cctally_cache._JoinedClaudeEntry
|