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.
@@ -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) = row
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) = row
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