cctally 1.34.1 → 1.36.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 +10 -0
- package/bin/_cctally_cache.py +32 -9
- package/bin/_cctally_db.py +111 -14
- package/bin/_lib_conversation.py +155 -9
- package/bin/_lib_conversation_query.py +82 -3
- package/dashboard/static/assets/{index-CXakskUV.js → index-DxQXelz7.js} +19 -17
- package/dashboard/static/assets/{index-Q7C0vruA.css → index-a_BOAOJ_.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
|
@@ -358,6 +358,33 @@ def _turn_cost_map(conn, turn_keys):
|
|
|
358
358
|
return costs
|
|
359
359
|
|
|
360
360
|
|
|
361
|
+
def _turn_usage_map(conn, turn_keys):
|
|
362
|
+
"""{(msg_id, req_id): {"input","output","cache_creation","cache_read"}} for
|
|
363
|
+
the given non-null turn keys, read from the SAME deduped session_entries row
|
|
364
|
+
cost is computed from (#177). This is a SEPARATE sibling of _turn_cost_map —
|
|
365
|
+
that one returns a float and is also consumed by the search path
|
|
366
|
+
(_attach_costs), so its shape must NOT change. Tokens here come from the same
|
|
367
|
+
source row as the cost, but they are NOT arithmetically equal to it: cost may
|
|
368
|
+
be the vendor-provided cost_usd_raw override (token math bypassed), so the
|
|
369
|
+
contract is "same source row," never cost == f(tokens). Keys absent from
|
|
370
|
+
session_entries are simply not present (the turn omits ``tokens``)."""
|
|
371
|
+
usage = {}
|
|
372
|
+
keys = [(m, r) for (m, r) in turn_keys if m is not None and r is not None]
|
|
373
|
+
if not keys:
|
|
374
|
+
return usage
|
|
375
|
+
for i in range(0, len(keys), 400):
|
|
376
|
+
chunk = keys[i:i + 400]
|
|
377
|
+
cond = " OR ".join("(msg_id=? AND req_id=?)" for _ in chunk)
|
|
378
|
+
params = [v for pair in chunk for v in pair]
|
|
379
|
+
sql = ("SELECT msg_id, req_id, input_tokens, output_tokens, "
|
|
380
|
+
"cache_create_tokens, cache_read_tokens "
|
|
381
|
+
"FROM session_entries WHERE " + cond)
|
|
382
|
+
for m, r, inp, out, cc, cr in conn.execute(sql, params):
|
|
383
|
+
usage[(m, r)] = {"input": inp or 0, "output": out or 0,
|
|
384
|
+
"cache_creation": cc or 0, "cache_read": cr or 0}
|
|
385
|
+
return usage
|
|
386
|
+
|
|
387
|
+
|
|
361
388
|
def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
362
389
|
"""Reader payload for one session (spec §3.2). Returns None for an unknown
|
|
363
390
|
session. Dedups logical messages by (session_id, uuid) (canonical = earliest
|
|
@@ -375,10 +402,14 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
375
402
|
# Pull the session ordered; dedup logical messages by (session_id, uuid),
|
|
376
403
|
# canonical row = earliest (timestamp_utc, id). Replays carry the original
|
|
377
404
|
# uuid, so the first occurrence in ascending order is canonical.
|
|
405
|
+
# #177: stop_reason / attribution_* are TAIL-APPENDED (indices 15/16/17)
|
|
406
|
+
# AFTER source_tool_use_id so the existing positional reads (incl.
|
|
407
|
+
# _latest(logical, 10/11) for cwd/git_branch, r[6] for model, [2] for ts)
|
|
408
|
+
# are all unchanged. Every unpacker below extends its tail in lockstep.
|
|
378
409
|
raw = conn.execute(
|
|
379
410
|
"SELECT id, uuid, timestamp_utc, entry_type, text, blocks_json, model, "
|
|
380
411
|
" msg_id, req_id, is_sidechain, cwd, git_branch, source_path, parent_uuid, "
|
|
381
|
-
" source_tool_use_id "
|
|
412
|
+
" source_tool_use_id, stop_reason, attribution_skill, attribution_plugin "
|
|
382
413
|
"FROM conversation_messages WHERE session_id=? "
|
|
383
414
|
"ORDER BY timestamp_utc, id", (session_id,)).fetchall()
|
|
384
415
|
|
|
@@ -422,7 +453,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
422
453
|
|
|
423
454
|
for row in logical:
|
|
424
455
|
(rid, u, ts, etype, text, blocks, model, msg_id, req_id,
|
|
425
|
-
is_sc, cwd, branch, source_path, parent_uuid, source_tool_use_id
|
|
456
|
+
is_sc, cwd, branch, source_path, parent_uuid, source_tool_use_id,
|
|
457
|
+
stop_reason, attr_skill, attr_plugin) = row
|
|
426
458
|
if etype == "assistant" and msg_id is not None:
|
|
427
459
|
key = (msg_id, req_id)
|
|
428
460
|
idx = turn_index.get(key)
|
|
@@ -451,6 +483,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
451
483
|
# spawn tool_use id <-> tool_result tool_use_id; agent_id == subagent_key.
|
|
452
484
|
spawn_kind = {} # tool_use id -> subagent_type
|
|
453
485
|
agent_link = {} # tool_use id -> (agent_id, raw_meta)
|
|
486
|
+
ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
|
|
454
487
|
for it in items:
|
|
455
488
|
for b in it["blocks"]:
|
|
456
489
|
k = b.get("kind")
|
|
@@ -463,6 +496,10 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
463
496
|
meta = b.pop("subagent_meta", None)
|
|
464
497
|
if aid and b.get("tool_use_id") is not None:
|
|
465
498
|
agent_link[b["tool_use_id"]] = (aid, meta or {})
|
|
499
|
+
ans = b.pop("ask_answers", None) # #177 S2
|
|
500
|
+
anno = b.pop("ask_annotations", None)
|
|
501
|
+
if ans is not None and b.get("tool_use_id") is not None:
|
|
502
|
+
ask_link[b["tool_use_id"]] = (ans, anno)
|
|
466
503
|
subagent_meta = {}
|
|
467
504
|
for _tuid, _kind in spawn_kind.items():
|
|
468
505
|
_link = agent_link.get(_tuid)
|
|
@@ -495,8 +532,12 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
495
532
|
if owners and not non_result and len(owner_ids) == 1:
|
|
496
533
|
owner = owners[0]
|
|
497
534
|
for use_block, res_block in resolved:
|
|
535
|
+
# #177: full_length (pre-clip char count) rides through the fold
|
|
536
|
+
# for the "showing X of Y" affordance; None on pre-enrichment
|
|
537
|
+
# rows that lack it (the .get default — never KeyErrors).
|
|
498
538
|
use_block["result"] = {"text": res_block.get("text", ""),
|
|
499
539
|
"truncated": bool(res_block.get("truncated")),
|
|
540
|
+
"full_length": res_block.get("full_length"),
|
|
500
541
|
"is_error": bool(res_block.get("is_error"))}
|
|
501
542
|
owner["member_uuids"].append(tr["anchor"]["uuid"])
|
|
502
543
|
drop.add(id(tr))
|
|
@@ -517,6 +558,11 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
517
558
|
b["kind"] = "tool_call"
|
|
518
559
|
b["tool_use_id"] = b.pop("id", None)
|
|
519
560
|
b.setdefault("result", None)
|
|
561
|
+
link = ask_link.get(b["tool_use_id"]) # #177 S2
|
|
562
|
+
if link is not None:
|
|
563
|
+
b["answers"] = link[0]
|
|
564
|
+
if link[1]:
|
|
565
|
+
b["annotations"] = link[1]
|
|
520
566
|
|
|
521
567
|
# ---- Phase 4: classify injected meta items (skill / command / context) ----
|
|
522
568
|
# `meta` rows (the parser's isMeta classification) AND — only while the 005
|
|
@@ -570,6 +616,9 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
570
616
|
items = [it for it in items if id(it) not in _skill_drop]
|
|
571
617
|
|
|
572
618
|
costs = _turn_cost_map(conn, list(turn_index))
|
|
619
|
+
# #177: per-turn token usage from the SAME deduped session_entries row cost
|
|
620
|
+
# uses (a separate map; _turn_cost_map is unchanged for the search path).
|
|
621
|
+
usage = _turn_usage_map(conn, list(turn_index))
|
|
573
622
|
# Stamp per-item cost first, then derive the header from the SUM of the
|
|
574
623
|
# ROUNDED per-item assistant costs (M2) — so the §6.5 invariant
|
|
575
624
|
# sum(items.cost_usd) == header cost_usd holds EXACTLY to 1e-9 by
|
|
@@ -582,6 +631,11 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
582
631
|
turn_cost = round(costs.get((it["_msg_id"], it["_req_id"]), 0.0), 6)
|
|
583
632
|
it["cost_usd"] = turn_cost
|
|
584
633
|
header_cost += turn_cost
|
|
634
|
+
# #177: stamp tokens from the same source row; absent when the turn
|
|
635
|
+
# key has no session_entries row (omitted, not zero-filled).
|
|
636
|
+
tok = usage.get((it["_msg_id"], it["_req_id"]))
|
|
637
|
+
if tok is not None:
|
|
638
|
+
it["tokens"] = tok
|
|
585
639
|
del it["_msg_id"]
|
|
586
640
|
del it["_req_id"]
|
|
587
641
|
it.pop("_has_prose", None)
|
|
@@ -682,6 +736,11 @@ def _build_turn(members):
|
|
|
682
736
|
"_source_tool_use_id": first[14],
|
|
683
737
|
"_has_prose": False,
|
|
684
738
|
}
|
|
739
|
+
# #177: stop_reason / attribution_* (tail-appended cols 15/16/17) are seeded
|
|
740
|
+
# by the _fold_fragment(item, first) call below — the same seed-then-fold path
|
|
741
|
+
# model/text/is_sidechain already use. _fold_fragment applies the last-non-null
|
|
742
|
+
# guard, so a single-fragment turn is covered there and omitted keys (never
|
|
743
|
+
# None) preserve the absent-when-absent contract.
|
|
685
744
|
_fold_fragment(item, first)
|
|
686
745
|
for m in members[1:]:
|
|
687
746
|
_extend_turn(item, m)
|
|
@@ -717,6 +776,16 @@ def _fold_fragment(item, row):
|
|
|
717
776
|
item["text"] = frag_text
|
|
718
777
|
else:
|
|
719
778
|
item["text"] = item["text"] + "\n" + frag_text
|
|
779
|
+
# #177: stop_reason = last-non-null fragment value (the terminal fragment
|
|
780
|
+
# carries the real reason); attribution_* = last-non-null (turn-level
|
|
781
|
+
# constant). A later null fragment must NOT blank an earlier value — hence
|
|
782
|
+
# the `is not None` guard rather than an unconditional assign.
|
|
783
|
+
if row[15] is not None:
|
|
784
|
+
item["stop_reason"] = row[15]
|
|
785
|
+
if row[16] is not None:
|
|
786
|
+
item["attribution_skill"] = row[16]
|
|
787
|
+
if row[17] is not None:
|
|
788
|
+
item["attribution_plugin"] = row[17]
|
|
720
789
|
|
|
721
790
|
|
|
722
791
|
def _build_simple(row):
|
|
@@ -726,7 +795,8 @@ def _build_simple(row):
|
|
|
726
795
|
internal _msg_id/_req_id keys, so the cost loop's KeyError path can never fire
|
|
727
796
|
(I2). The model is preserved for assistant rows."""
|
|
728
797
|
(rid, u, ts, etype, text, blocks, model, msg_id, req_id, is_sc, cwd, branch,
|
|
729
|
-
source_path, parent_uuid, source_tool_use_id
|
|
798
|
+
source_path, parent_uuid, source_tool_use_id,
|
|
799
|
+
stop_reason, attr_skill, attr_plugin) = row
|
|
730
800
|
try:
|
|
731
801
|
parsed = _json.loads(blocks or "[]")
|
|
732
802
|
except (ValueError, TypeError):
|
|
@@ -749,6 +819,15 @@ def _build_simple(row):
|
|
|
749
819
|
if etype == "assistant":
|
|
750
820
|
item["model"] = model
|
|
751
821
|
item["cost_usd"] = 0.0
|
|
822
|
+
# #177: stop_reason / attribution are assistant-only — a null-msg_id
|
|
823
|
+
# assistant turn still carries them. Omitted when null (absent-when-
|
|
824
|
+
# absent). Human / tool_result simple items never get these keys.
|
|
825
|
+
if stop_reason is not None:
|
|
826
|
+
item["stop_reason"] = stop_reason
|
|
827
|
+
if attr_skill is not None:
|
|
828
|
+
item["attribution_skill"] = attr_skill
|
|
829
|
+
if attr_plugin is not None:
|
|
830
|
+
item["attribution_plugin"] = attr_plugin
|
|
752
831
|
return item
|
|
753
832
|
|
|
754
833
|
|