cctally 1.38.1 → 1.40.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.
@@ -11,10 +11,12 @@ deduped session_entries row (idx_entries_dedup), via the shared pricing helper
11
11
  — never per physical fragment and never from cost_usd_raw (often NULL).
12
12
  """
13
13
  from __future__ import annotations
14
+ import base64 as _base64
14
15
  import json as _json
15
16
  import os
16
17
  import re
17
18
  import sqlite3
19
+ from datetime import datetime as _datetime
18
20
 
19
21
  # Public surface (Plan 2): shipped in the npm tarball + brew formula + public
20
22
  # mirror — imported by the dashboard's conversation endpoints at runtime.
@@ -24,6 +26,9 @@ from _lib_pricing import _calculate_entry_cost
24
26
  # content block the same way the parser does at ingest — reuse the parser's
25
27
  # _stringify so the full (un-capped) result text matches the cached/capped one.
26
28
  from _lib_conversation import _stringify
29
+ # #177 S4: the media-route reader walks a content array with the SAME ordinal
30
+ # generator the ingest placeholders used, so "media item N" addresses one item.
31
+ from _lib_conversation import iter_media_items
27
32
 
28
33
 
29
34
  # Mirror of dashboard/web/src/conversations/systemMarkers.ts::MARKER_RE — anchored
@@ -389,14 +394,15 @@ def _turn_usage_map(conn, turn_keys):
389
394
  return usage
390
395
 
391
396
 
392
- def get_conversation(conn, session_id, *, after=None, limit=500):
393
- """Reader payload for one session (spec §3.2). Returns None for an unknown
394
- session. Dedups logical messages by (session_id, uuid) (canonical = earliest
395
- timestamp), groups assistant fragments into turn items by (msg_id, req_id),
396
- joins cost once, anchors a turn on its prose-bearing fragment, and exposes
397
- every member fragment uuid for jump resolution. Cursor over (timestamp_utc,
398
- id); ~500 items/page."""
399
- limit = max(1, min(int(limit), 1000))
397
+ def _assemble_session(conn, session_id):
398
+ """Shared assembly for get_conversation / get_conversation_outline (#177 S5).
399
+
400
+ Runs the full dedup turn-grouping fold sweep → meta-classify →
401
+ cost/usage-stamp pipeline over the WHOLE session and returns the
402
+ pre-pagination state, so the outline's turns match the reader's items 1:1
403
+ BY CONSTRUCTION (Codex F8 — one grouping pass, never two implementations).
404
+ Returns None for an unknown session.
405
+ """
400
406
  exists = conn.execute(
401
407
  "SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
402
408
  (session_id,)).fetchone()
@@ -489,6 +495,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
489
495
  agent_link = {} # tool_use id -> (agent_id, raw_meta)
490
496
  ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
491
497
  bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
498
+ web_search_link = {} # tool_use id -> web_search payload (#177 S4)
499
+ web_fetch_link = {} # tool_use id -> web_fetch payload (#177 S4)
492
500
  task_link = {} # tool_use id -> {"task_id", "task_list"} (Task* checklist)
493
501
  for it in items:
494
502
  for b in it["blocks"]:
@@ -510,6 +518,12 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
510
518
  bintr = b.pop("bash_interrupted", None)
511
519
  if b.get("tool_use_id") is not None and (bstderr is not None or bintr):
512
520
  bash_link[b["tool_use_id"]] = (bstderr, bool(bintr))
521
+ ws = b.pop("web_search", None) # #177 S4
522
+ if ws is not None and b.get("tool_use_id") is not None:
523
+ web_search_link[b["tool_use_id"]] = ws
524
+ wf = b.pop("web_fetch", None) # #177 S4
525
+ if wf is not None and b.get("tool_use_id") is not None:
526
+ web_fetch_link[b["tool_use_id"]] = wf
513
527
  tid_ = b.pop("task_id", None) # Task* checklist
514
528
  tlist_ = b.pop("task_list", None)
515
529
  if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
@@ -553,6 +567,9 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
553
567
  "truncated": bool(res_block.get("truncated")),
554
568
  "full_length": res_block.get("full_length"),
555
569
  "is_error": bool(res_block.get("is_error"))}
570
+ res_media = res_block.get("media") # #177 S4: public render-ready key
571
+ if res_media:
572
+ use_block["result"]["media"] = res_media
556
573
  owner["member_uuids"].append(tr["anchor"]["uuid"])
557
574
  drop.add(id(tr))
558
575
  # else: leave tr standalone (orphan / multi-owner / mixed) — a folded
@@ -583,6 +600,14 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
583
600
  b["stderr"] = blink[0]
584
601
  if blink[1]:
585
602
  b["interrupted"] = True
603
+ if b.get("name") == "WebSearch": # #177 S4: name-keyed
604
+ wslink = web_search_link.get(b["tool_use_id"])
605
+ if wslink is not None:
606
+ b["web_search"] = wslink
607
+ if b.get("name") == "WebFetch": # #177 S4: name-keyed
608
+ wflink = web_fetch_link.get(b["tool_use_id"])
609
+ if wflink is not None:
610
+ b["web_fetch"] = wflink
586
611
 
587
612
  # ---- Phase 3b: fold the Task* op stream into per-run checklist snapshots ----
588
613
  _fold_task_runs(items, task_link)
@@ -670,6 +695,26 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
670
695
  for it in items:
671
696
  it.pop("_source_tool_use_id", None)
672
697
 
698
+ return {"items": items, "logical": logical,
699
+ "subagent_meta": subagent_meta, "header_cost": header_cost}
700
+
701
+
702
+ def get_conversation(conn, session_id, *, after=None, limit=500):
703
+ """Reader payload for one session (spec §3.2). Returns None for an unknown
704
+ session. Dedups logical messages by (session_id, uuid) (canonical = earliest
705
+ timestamp), groups assistant fragments into turn items by (msg_id, req_id),
706
+ joins cost once, anchors a turn on its prose-bearing fragment, and exposes
707
+ every member fragment uuid for jump resolution. Cursor over (timestamp_utc,
708
+ id); ~500 items/page."""
709
+ limit = max(1, min(int(limit), 1000))
710
+ asm = _assemble_session(conn, session_id)
711
+ if asm is None:
712
+ return None
713
+ items = asm["items"]
714
+ logical = asm["logical"]
715
+ subagent_meta = asm["subagent_meta"]
716
+ header_cost = asm["header_cost"]
717
+
673
718
  # Cursor pagination over the item list (anchored to each item's canonical id).
674
719
  # A non-None `after` that matches no item's anchor (stale/deleted cursor)
675
720
  # yields an EMPTY page — never silently re-serves the head (M1).
@@ -720,6 +765,104 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
720
765
  }
721
766
 
722
767
 
768
+ _OUTLINE_LABEL_CAP = 120
769
+
770
+
771
+ def _outline_label(text):
772
+ """First non-blank line, capped at _OUTLINE_LABEL_CAP chars ('' when none)."""
773
+ for ln in (text or "").splitlines():
774
+ s = ln.strip()
775
+ if s:
776
+ return s[:_OUTLINE_LABEL_CAP]
777
+ return ""
778
+
779
+
780
+ def _parse_outline_ts(ts):
781
+ if not ts:
782
+ return None
783
+ try:
784
+ return _datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
785
+ except ValueError:
786
+ return None
787
+
788
+
789
+ def get_conversation_outline(conn, session_id):
790
+ """Full-session per-turn skeleton + aggregates (#177 S5, spec §1).
791
+
792
+ No pagination — every grouped turn, but only skeleton fields (no inputs,
793
+ no result bodies, no full prose). `ts` is NULLABLE (Codex F6); stats and
794
+ every consumer tolerate it. Stats derive from the SAME assembled items the
795
+ reader pages (Codex F8). Returns None for an unknown session.
796
+ """
797
+ asm = _assemble_session(conn, session_id)
798
+ if asm is None:
799
+ return None
800
+ items, logical = asm["items"], asm["logical"]
801
+ turns = []
802
+ turn_counts = {"total": 0, "human": 0, "assistant": 0, "tool_result": 0, "meta": 0}
803
+ tool_counts, models = {}, {}
804
+ error_count = 0
805
+ tokens = {"input": 0, "output": 0, "cache_creation": 0, "cache_read": 0}
806
+ for it in items:
807
+ kind = it["kind"]
808
+ turn_counts["total"] += 1
809
+ if kind in turn_counts:
810
+ turn_counts[kind] += 1
811
+ t = {"uuid": it["anchor"]["uuid"], "kind": kind, "ts": it["ts"],
812
+ "label": _outline_label(it.get("text", "")),
813
+ "member_uuids": list(it["member_uuids"]),
814
+ "subagent_key": it["subagent_key"], "parent_uuid": it["parent_uuid"],
815
+ "is_sidechain": it["is_sidechain"]}
816
+ tools, thinking = [], []
817
+ for b in it["blocks"]:
818
+ bk = b.get("kind")
819
+ if bk in ("tool_call", "tool_use"):
820
+ res = b.get("result")
821
+ err = bool(res and res.get("is_error"))
822
+ tools.append({"name": b.get("name"), "is_error": err})
823
+ elif bk == "tool_result": # orphan error channel (spec delta b)
824
+ tools.append({"name": None, "is_error": bool(b.get("is_error"))})
825
+ elif bk == "thinking":
826
+ ln = _outline_label(b.get("text", ""))
827
+ if ln:
828
+ thinking.append(ln)
829
+ for tref in tools:
830
+ if tref["is_error"]:
831
+ error_count += 1
832
+ if tref["name"]:
833
+ tool_counts[tref["name"]] = tool_counts.get(tref["name"], 0) + 1
834
+ if tools:
835
+ t["tools"] = tools
836
+ if thinking:
837
+ t["thinking"] = thinking
838
+ if kind == "assistant":
839
+ if it.get("model"):
840
+ t["model"] = it["model"]
841
+ models[it["model"]] = models.get(it["model"], 0) + 1
842
+ tok = it.get("tokens")
843
+ if tok is not None:
844
+ t["tokens"] = tok
845
+ for k in tokens:
846
+ tokens[k] += tok.get(k, 0)
847
+ if kind == "meta":
848
+ t["meta_kind"] = it.get("meta_kind")
849
+ t["skill_name"] = it.get("skill_name")
850
+ if not t["label"]:
851
+ t["label"] = _outline_label(it.get("skill_name") or "")
852
+ turns.append(t)
853
+ ts_vals = [r[2] for r in logical if r[2]]
854
+ d0 = _parse_outline_ts(ts_vals[0] if ts_vals else None)
855
+ d1 = _parse_outline_ts(ts_vals[-1] if ts_vals else None)
856
+ duration = int((d1 - d0).total_seconds()) if d0 and d1 else None
857
+ return {"session_id": session_id,
858
+ "subagent_meta": asm["subagent_meta"],
859
+ "stats": {"turns": turn_counts, "tool_counts": tool_counts,
860
+ "error_count": error_count, "models": models,
861
+ "duration_seconds": duration, "tokens": tokens,
862
+ "cost_usd": asm["header_cost"]},
863
+ "turns": turns}
864
+
865
+
723
866
  _TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
724
867
 
725
868
 
@@ -1317,3 +1460,117 @@ def read_full_payload(source_path, byte_offset, tool_use_id, which):
1317
1460
  resp["stderr"] = tur["stderr"][:_FULL_PAYLOAD_CEILING] # per-stream bound (see above)
1318
1461
  return resp
1319
1462
  return None
1463
+
1464
+
1465
+ # ---------------------------------------------------------------------------
1466
+ # #177 S4: on-demand media kernel. The route re-reads the source JSONL line by
1467
+ # byte_offset (the #178 mechanism), decodes the base64 of the addressed media
1468
+ # ordinal, and serves the raw bytes — nothing is ever written to cache.db.
1469
+ # ---------------------------------------------------------------------------
1470
+ _MEDIA_LINE_CEILING = 64 * 1024 * 1024 # raw line read cap (pathological guard)
1471
+ _MEDIA_PAYLOAD_CEILING = 20 * 1024 * 1024 # decoded cap; enforced on ENCODED length
1472
+ # Response Content-Type is the matched constant — never an echoed transcript string.
1473
+ _MEDIA_TYPE_ALLOWLIST = {
1474
+ "image/png": "image/png", "image/jpeg": "image/jpeg",
1475
+ "image/gif": "image/gif", "image/webp": "image/webp",
1476
+ "application/pdf": "application/pdf",
1477
+ }
1478
+
1479
+
1480
+ def locate_media(conn, session_id, *, tool_use_id=None, uuid=None, index=0):
1481
+ """``(source_path, byte_offset)`` for the row whose stored placeholder
1482
+ carries media ordinal ``index`` — tool_use_id mode reads tool_result
1483
+ ``media[]``; uuid mode reads user-content image/document blocks. Mirrors
1484
+ locate_tool_payload: instr() prefilter (never LIKE — ids contain ``_``),
1485
+ candidates parsed + exact-matched, deterministic ORDER BY matching
1486
+ get_conversation. ``None`` -> 404. Pre-reingest rows have no placeholder
1487
+ and correctly 404 (the client renders the badge for them anyway)."""
1488
+ if tool_use_id is not None:
1489
+ rows = conn.execute(
1490
+ "SELECT source_path, byte_offset, blocks_json FROM conversation_messages "
1491
+ "WHERE session_id=? AND instr(blocks_json, ?) > 0 "
1492
+ "ORDER BY timestamp_utc, id", (session_id, tool_use_id)).fetchall()
1493
+ else:
1494
+ rows = conn.execute(
1495
+ "SELECT source_path, byte_offset, blocks_json FROM conversation_messages "
1496
+ "WHERE session_id=? AND uuid=? ORDER BY timestamp_utc, id",
1497
+ (session_id, uuid)).fetchall()
1498
+ for source_path, byte_offset, blocks_json in rows:
1499
+ try:
1500
+ blocks = _json.loads(blocks_json)
1501
+ except (ValueError, TypeError):
1502
+ continue
1503
+ if not isinstance(blocks, list):
1504
+ continue
1505
+ for b in blocks:
1506
+ if not isinstance(b, dict):
1507
+ continue
1508
+ if tool_use_id is not None:
1509
+ if (b.get("kind") == "tool_result"
1510
+ and b.get("tool_use_id") == tool_use_id):
1511
+ for m in b.get("media") or []:
1512
+ if isinstance(m, dict) and m.get("index") == index:
1513
+ return source_path, byte_offset
1514
+ elif (b.get("kind") in ("image", "document")
1515
+ and b.get("index") == index):
1516
+ return source_path, byte_offset
1517
+ return None
1518
+
1519
+
1520
+ def read_media_bytes(source_path, byte_offset, *, tool_use_id=None, uuid=None,
1521
+ index=0):
1522
+ """Re-read the source line and return ``("ok", media_type, raw_bytes)`` for
1523
+ media ordinal ``index``, else ``("unsupported"|"too_large"|"gone", None,
1524
+ None)`` (-> 404 / 413 / 410). The decoded cap is enforced as an
1525
+ ENCODED-length precheck — never decode-then-measure (Codex F4); decode is
1526
+ strict (validate=True; binascii.Error subclasses ValueError). The media
1527
+ walk IS iter_media_items — the same generator the ingest placeholders used,
1528
+ so ordinals cannot drift (spec §4.1 chokepoint)."""
1529
+ try:
1530
+ with open(source_path, "rb") as fh:
1531
+ fh.seek(byte_offset)
1532
+ line = fh.readline(_MEDIA_LINE_CEILING + 1)
1533
+ if len(line) > _MEDIA_LINE_CEILING:
1534
+ return ("too_large", None, None)
1535
+ obj = _json.loads(line)
1536
+ except (OSError, ValueError):
1537
+ return ("gone", None, None)
1538
+ if not isinstance(obj, dict):
1539
+ return ("gone", None, None)
1540
+ content = (obj.get("message") or {}).get("content")
1541
+ if not isinstance(content, list):
1542
+ return ("gone", None, None)
1543
+ if tool_use_id is not None:
1544
+ target = None
1545
+ for b in content:
1546
+ if (isinstance(b, dict) and b.get("type") == "tool_result"
1547
+ and b.get("tool_use_id") == tool_use_id):
1548
+ target = b.get("content")
1549
+ break
1550
+ if target is None:
1551
+ return ("gone", None, None)
1552
+ else:
1553
+ target = content
1554
+ item = None
1555
+ for idx, m in iter_media_items(target):
1556
+ if idx == index:
1557
+ item = m
1558
+ break
1559
+ if item is None:
1560
+ return ("gone", None, None)
1561
+ source = item.get("source")
1562
+ if not isinstance(source, dict):
1563
+ return ("gone", None, None)
1564
+ media_type = _MEDIA_TYPE_ALLOWLIST.get(source.get("media_type"))
1565
+ if media_type is None:
1566
+ return ("unsupported", None, None)
1567
+ data = source.get("data")
1568
+ if not isinstance(data, str):
1569
+ return ("gone", None, None)
1570
+ if len(data) > _MEDIA_PAYLOAD_CEILING * 4 // 3:
1571
+ return ("too_large", None, None)
1572
+ try:
1573
+ raw = _base64.b64decode(data, validate=True)
1574
+ except ValueError:
1575
+ return ("gone", None, None)
1576
+ return ("ok", media_type, raw)