cctally 1.52.1 → 1.54.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.
@@ -214,12 +214,12 @@ class MessageRow:
214
214
  attribution_skill: "str | None" = None
215
215
  attribution_plugin: "str | None" = None
216
216
  # #177 S6: split non-prose search columns, derived by _derive_search_columns
217
- # on the FINAL post-augment blocks (see _normalize). ``search_aux`` is kept
218
- # physically (legacy column) but NEVER assigned a non-empty value the
219
- # consolidated multi-column FTS reads search_tool/search_thinking instead.
217
+ # on the FINAL post-augment blocks (see _normalize). The consolidated
218
+ # multi-column FTS reads search_tool/search_thinking. (#217 S1 / U7a: the
219
+ # legacy ``search_aux`` field was removed — the column is dropped from the
220
+ # live schema by migration 016 and the writer never carried a value anyway.)
220
221
  search_tool: str = ""
221
222
  search_thinking: str = ""
222
- search_aux: str = "" # documented-dead (#177 S6); always "" on new rows
223
223
 
224
224
 
225
225
  def iter_message_rows(fh, path_str):
@@ -462,16 +462,15 @@ def _normalize(obj, t, offset):
462
462
  is_sidechain=1 if obj.get("isSidechain") else 0,
463
463
  source_tool_use_id=obj.get("sourceToolUseID"),
464
464
  # #177: message-level enrichment. stop_reason is assistant-only;
465
- # attribution is top-level on the JSONL object. search_aux is kept even
466
- # for tool_result/meta rowsonly `text` is zeroed for prose FTS;
467
- # search_aux is the non-prose index (tool content stays searchable).
465
+ # attribution is top-level on the JSONL object. The non-prose index is
466
+ # the split search_tool/search_thinking columnstool/thinking content
467
+ # stays searchable even though `text` is zeroed for prose FTS on
468
+ # tool_result/meta rows.
468
469
  stop_reason=msg.get("stop_reason") if is_asst else None,
469
470
  attribution_skill=obj.get("attributionSkill"),
470
471
  attribution_plugin=obj.get("attributionPlugin"),
471
472
  search_tool=search_tool,
472
473
  search_thinking=search_thinking,
473
- # search_aux stays "" (documented-dead, #177 S6) — the consolidated
474
- # multi-column FTS reads search_tool/search_thinking instead.
475
474
  )
476
475
 
477
476
 
@@ -536,6 +535,18 @@ def _blocks_and_text(content):
536
535
  "full_length": len(raw),
537
536
  "is_error": bool(b.get("is_error")),
538
537
  "tool_use_id": b.get("tool_use_id")}
538
+ # #217 S1 / U6: a nested (grandchild) subagent result carries its
539
+ # `agentId:` (+ optional <usage>) in the result STRING, not a
540
+ # record-level toolUseResult. Parse them from the FULL raw HERE,
541
+ # BEFORE the cap clips the text — so a >16 KB result whose agentId:
542
+ # trailer lands past the cut still links (the read-time regex over
543
+ # the clipped text would miss it). Stamp the structured keys the
544
+ # kernel's id consumer already reads (b.pop("agent_id")); the
545
+ # read-time regex stays a fallback for un-reingested rows.
546
+ _nested = _nested_agent_stamp_from_text(raw)
547
+ if _nested is not None:
548
+ block["agent_id"] = _nested[0]
549
+ block["subagent_meta"] = _nested[1]
539
550
  # #177 S4: media placeholders for image/document items inside the
540
551
  # tool_result content array (where every MCP screenshot lives) —
541
552
  # ordinals from the shared iter_media_items chokepoint.
@@ -561,15 +572,57 @@ _SUBAGENT_META_KEYS = (
561
572
  )
562
573
 
563
574
 
575
+ # #217 S1 / U6 — nested (grandchild) subagent result parse. The new Claude Code
576
+ # format emits a grandchild's spawn result as STRING content (no structured
577
+ # record-level toolUseResult.agentId), with a trailing `agentId: <hash> (use
578
+ # SendMessage …)` line and an OPTIONAL `<usage>` totals wrapper. These constants
579
+ # live HERE (the parser) so the INGEST stamp runs them against the FULL raw
580
+ # content BEFORE the _TOOL_RESULT_CAP clip — a result whose agentId: trailer
581
+ # lands past the 16 KB cut would otherwise survive only as flat text and the
582
+ # read-time regex (over the clipped text) would miss it. _lib_conversation_query
583
+ # re-exports both for its read-time fallback over un-reingested rows.
584
+ _NESTED_AGENT_ID_RE = re.compile(r"agentId:\s*([0-9a-f]+)")
585
+ _NESTED_USAGE_RE = re.compile(
586
+ r"<usage>\s*subagent_tokens:\s*(\d+)\s*tool_uses:\s*(\d+)\s*duration_ms:\s*(\d+)\s*</usage>",
587
+ re.DOTALL,
588
+ )
589
+
590
+
591
+ def _nested_agent_stamp_from_text(raw):
592
+ """(agent_id, subagent_meta) parsed from a tool_result's FULL string content,
593
+ or None when no `agentId:` line is present (an ordinary result, NOT a spawn
594
+ grandchild). subagent_meta carries the completed `<usage>` totals when the
595
+ wrapper is present + well-formed, else {} — linking on agentId ALONE is
596
+ sufficient, so a missing/clipped/malformed usage degrades usage to {}, never
597
+ the whole link. Keys mirror the snake_case shape _attach_subagent_result /
598
+ the query kernel's structured-id consumer expect (Codex P1-B)."""
599
+ if not raw:
600
+ return None
601
+ m = _NESTED_AGENT_ID_RE.search(raw)
602
+ if not m:
603
+ return None
604
+ meta = {}
605
+ u = _NESTED_USAGE_RE.search(raw)
606
+ if u:
607
+ meta["total_tokens"] = int(u.group(1))
608
+ meta["total_tool_use_count"] = int(u.group(2))
609
+ meta["total_duration_ms"] = int(u.group(3))
610
+ meta["status"] = "completed"
611
+ return m.group(1), meta
612
+
613
+
564
614
  def _attach_subagent_result(blocks, obj):
565
615
  """Attach the record-level ``toolUseResult`` agentId + meta (#166) onto the
566
616
  tool_result block, but ONLY when the record carries exactly one tool_result
567
617
  block — the unambiguous subagent-spawn result shape. Zero or >1 tool_result
568
618
  blocks: no-op (the kernel then degrades that subagent card to title-only).
569
619
  The kind (subagent_type) is captured separately on the spawn tool_use block;
570
- the kernel joins the two on tool_use_id. ``agentId`` == the subagent file's
571
- ``_subagent_key``. Meta keys are normalized to snake_case here so the kernel
572
- stays a pure pass-through (same posture as is_error / tool_use_id)."""
620
+ the kernel identifies a spawn by the tool NAME (Agent/Task), so an untyped
621
+ general-purpose spawn no subagent_type recorded still links, with the
622
+ kernel defaulting its kind to ``general-purpose`` (#225). The kernel joins
623
+ the two on tool_use_id. ``agentId`` == the subagent file's ``_subagent_key``.
624
+ Meta keys are normalized to snake_case here so the kernel stays a pure
625
+ pass-through (same posture as is_error / tool_use_id)."""
573
626
  tur = obj.get("toolUseResult")
574
627
  if not isinstance(tur, dict):
575
628
  return
@@ -580,6 +633,9 @@ def _attach_subagent_result(blocks, obj):
580
633
  if len(results) != 1:
581
634
  return
582
635
  block = results[0]
636
+ # The record-level toolUseResult is the AUTHORITATIVE source — it overwrites
637
+ # any nested-text stamp (#217 S1 / U6) for the same id (the two shapes do not
638
+ # co-occur in real data, so this is only a deterministic-precedence guard).
583
639
  block["agent_id"] = agent_id
584
640
  meta = {}
585
641
  for src, dst in _SUBAGENT_META_KEYS:
@@ -977,11 +1033,142 @@ def _bound_input(inp):
977
1033
  return v
978
1034
 
979
1035
  bounded = walk(inp, 0)
1036
+ # #217 S1 / U5 (data-contract honesty): the total-cap backstop must actually
1037
+ # CLIP, not merely flag. The four per-axis caps above bound any SINGLE leaf /
1038
+ # key / node-count / depth, but many sub-leaf-cap leaves can still sum past
1039
+ # _INPUT_TOTAL_CAP — and the old code only set truncated=True there, serving
1040
+ # an over-cap payload. _clip_payload_input threads a shared remaining-WIRE-char
1041
+ # budget through the (already per-axis-bounded) dict — measured in the SAME
1042
+ # json.dumps(separators=(",",":")) wire form this check uses (and blocks_json
1043
+ # stores) — so the SERVED payload satisfies
1044
+ # len(json.dumps(bounded, separators=(",",":"))) <= _INPUT_TOTAL_CAP whenever it
1045
+ # clips. Non-ASCII leaves escape to \uXXXX (6 wire chars/char), so a plain char
1046
+ # budget would clip ~6x too late on the wire (round-2 P1 fix).
980
1047
  if len(json.dumps(bounded, separators=(",", ":"))) > _INPUT_TOTAL_CAP:
1048
+ bounded, _ = _clip_payload_input(bounded, _INPUT_TOTAL_CAP)
981
1049
  state["truncated"] = True
982
1050
  return (bounded, state["truncated"])
983
1051
 
984
1052
 
1053
+ # #217 S1 / U5: the payload-clip helpers live HERE (the parser/ingest module, low
1054
+ # in the dependency graph) so BOTH _bound_input's total-cap backstop (above) and
1055
+ # read_full_payload's on-demand input clip (the query kernel, which imports this
1056
+ # module) share one bound-guaranteeing implementation. The query layer never
1057
+ # imports back into the parser's callers, so this is the import-safe home (query
1058
+ # depends on the parser, not vice-versa — verified at #217 implementation).
1059
+ def _wire_len(s):
1060
+ """Serialized char-length of a string leaf in the WIRE form — the SAME
1061
+ ``json.dumps(separators=(",",":"))`` (default ``ensure_ascii=True``) used both
1062
+ by ``_bound_input``'s total-cap check and the ``blocks_json`` storage at the
1063
+ top of this module. For ASCII this equals ``len(s)``; for non-ASCII each char
1064
+ escapes to ``\\uXXXX`` (6 chars) so the wire cost is larger — measuring against
1065
+ it (not ``len(s)``) is what makes the clip's post-condition hold on the wire.
1066
+ The surrounding quotes are part of the per-leaf structural cost the post-walk
1067
+ backstop absorbs, so this counts only the escaped CONTENT (no quotes)."""
1068
+ # json.dumps wraps the string in quotes; strip them to get the content cost.
1069
+ return len(json.dumps(s, separators=(",", ":"))) - 2
1070
+
1071
+
1072
+ def _clip_string_to_wire_budget(s, budget):
1073
+ """Return the longest prefix of ``s`` whose WIRE length is ``<= budget`` (>= 0).
1074
+ ASCII is a fast 1:1 slice; for non-ASCII we walk char-by-char accumulating the
1075
+ per-char wire cost so a 6-chars-per-char escape never overshoots the budget."""
1076
+ if budget <= 0:
1077
+ return ""
1078
+ if s.isascii():
1079
+ return s[:budget]
1080
+ out, used = [], 0
1081
+ for ch in s:
1082
+ c = _wire_len(ch)
1083
+ if used + c > budget:
1084
+ break
1085
+ out.append(ch)
1086
+ used += c
1087
+ return "".join(out)
1088
+
1089
+
1090
+ def _clip_payload_input(inp, ceiling):
1091
+ """Clip a structured input so the returned dict serializes to ``ceiling`` chars
1092
+ or fewer IN THE WIRE FORM, and report whether anything was clipped — the
1093
+ input-side analogue of the result-side ceiling. A degenerate multi-MB input
1094
+ (one giant leaf OR many sub-ceiling leaves that sum past the ceiling) is bounded
1095
+ so the HTTP server / browser is protected, while every real payload returns
1096
+ whole.
1097
+
1098
+ The guarantee is AGGREGATE, not merely per-leaf: a shared remaining-WIRE-char
1099
+ budget is threaded through the walk (mirroring ``_bound_input``'s total-size
1100
+ backstop) — each string leaf is clipped against the running budget, measured in
1101
+ the wire form, and the budget is decremented by the leaf's wire cost as we go,
1102
+ so once it is exhausted later leaves clip to ''. ``truncated`` is True iff any
1103
+ leaf was clipped (or the post-walk serialized size still exceeds ``ceiling``,
1104
+ the structural-overhead backstop).
1105
+
1106
+ The measurement is the WIRE form ``json.dumps(separators=(",",":"))`` (default
1107
+ ``ensure_ascii=True``) — the SAME serialization ``_bound_input`` checks against
1108
+ and the ``blocks_json`` storage uses — NOT ``ensure_ascii=False``. For non-ASCII
1109
+ payloads each char escapes to ``\\uXXXX`` (6 wire chars), so an ``ensure_ascii=
1110
+ False`` measurement clips ~6x too late on the wire; measuring in the wire form
1111
+ is what makes the post-condition real. Post-condition:
1112
+ ``len(json.dumps(clipped, separators=(",",":"))) <= ceiling`` always."""
1113
+ truncated = False
1114
+ remaining = [ceiling] # boxed so the nested walk can decrement it (WIRE chars)
1115
+
1116
+ def walk(v):
1117
+ nonlocal truncated
1118
+ if isinstance(v, str):
1119
+ if _wire_len(v) > remaining[0]:
1120
+ truncated = True
1121
+ v = _clip_string_to_wire_budget(v, remaining[0])
1122
+ remaining[0] -= _wire_len(v)
1123
+ return v
1124
+ if isinstance(v, dict):
1125
+ return {k: walk(x) for k, x in v.items()}
1126
+ if isinstance(v, list):
1127
+ return [walk(x) for x in v]
1128
+ return v
1129
+
1130
+ clipped = walk(inp)
1131
+ # Backstop: structural JSON overhead (braces/quotes/keys) can push a
1132
+ # budget-exact payload a few chars past the ceiling. Hard-clip the largest
1133
+ # remaining string leaf(s) until the whole dict serializes within the ceiling —
1134
+ # measured in the wire form so the loop terminates against the same size the
1135
+ # post-condition guards.
1136
+ while len(json.dumps(clipped, separators=(",", ":"))) > ceiling:
1137
+ truncated = True
1138
+ if not _shrink_largest_leaf(clipped):
1139
+ break # no string leaf left to shrink (e.g. pure numeric/structural)
1140
+ return clipped, truncated
1141
+
1142
+
1143
+ def _shrink_largest_leaf(obj):
1144
+ """Halve the wire-longest string leaf reachable in ``obj`` (a dict/list/scalar),
1145
+ in place, and return True if one was shrunk — the post-walk backstop for the
1146
+ rare structural-overhead overshoot. "Longest" is measured in the WIRE form (so
1147
+ a non-ASCII leaf, 6 wire chars per char, is correctly preferred over a longer
1148
+ ASCII leaf with fewer wire chars). A leaf already at length 1 is truncated to
1149
+ ''. Returns False when no non-empty string leaf exists."""
1150
+ best = {"len": 0, "container": None, "key": None}
1151
+
1152
+ def scan(v, container, key):
1153
+ if isinstance(v, str):
1154
+ wl = _wire_len(v)
1155
+ if wl > best["len"]:
1156
+ best.update(len=wl, container=container, key=key)
1157
+ elif isinstance(v, dict):
1158
+ for k, x in v.items():
1159
+ scan(x, v, k)
1160
+ elif isinstance(v, list):
1161
+ for i, x in enumerate(v):
1162
+ scan(x, v, i)
1163
+
1164
+ scan(obj, None, None)
1165
+ if best["container"] is None or best["len"] == 0:
1166
+ return False
1167
+ s = best["container"][best["key"]]
1168
+ best["container"][best["key"]] = s[: len(s) // 2]
1169
+ return True
1170
+
1171
+
985
1172
  def _derive_search_columns(blocks):
986
1173
  """(search_tool, search_thinking) from the FINAL normalized blocks list.
987
1174
 
@@ -1038,6 +1225,38 @@ def _aux_strings(v):
1038
1225
  yield from _aux_strings(vv)
1039
1226
 
1040
1227
 
1228
+ # #217 S2 / I-3: the WRITE-class file tools — the only tools that actually mutate
1229
+ # a file on disk. Read / Bash / Grep / Glob are deliberately EXCLUDED: a file
1230
+ # "touch" for the file-path search axis means an edit/create, not a read or a
1231
+ # shell that merely names a path. The axis answers "which sessions modified this
1232
+ # file."
1233
+ _FILE_TOUCH_TOOLS = {"Edit", "MultiEdit", "Write", "NotebookEdit"}
1234
+
1235
+
1236
+ def _derive_file_touches(blocks):
1237
+ """Yield ``(file_path, tool)`` for every WRITE-class tool_use block (#217 S2 /
1238
+ I-3). Reads the same bounded ``input.file_path`` the renderer reads (file paths
1239
+ are well under the leaf cap, so they survive _bound_input intact).
1240
+
1241
+ Tolerates BOTH block shapes: the STORED ``blocks_json`` form keys the kind on
1242
+ ``kind`` (the _normalize output that _fill_file_touches reparses), while raw
1243
+ API content blocks key it on ``type``. We accept either so the same chokepoint
1244
+ derives touches from the persisted rows AND from a raw-shape unit fixture."""
1245
+ out = []
1246
+ for b in (blocks or []):
1247
+ if not isinstance(b, dict):
1248
+ continue
1249
+ if (b.get("kind") or b.get("type")) != "tool_use":
1250
+ continue
1251
+ if b.get("name") not in _FILE_TOUCH_TOOLS:
1252
+ continue
1253
+ inp = b.get("input")
1254
+ fp = inp.get("file_path") if isinstance(inp, dict) else None
1255
+ if isinstance(fp, str) and fp:
1256
+ out.append((fp, b["name"]))
1257
+ return out
1258
+
1259
+
1041
1260
  _PREVIEW_FIELDS = {
1042
1261
  "Read": "file_path", "Write": "file_path", "Edit": "file_path",
1043
1262
  "MultiEdit": "file_path", "NotebookEdit": "file_path",
@@ -0,0 +1,325 @@
1
+ """Pure, I/O-free Markdown serializer for a conversation session (issue #217 S5,
2
+ F1/F5). No DB / filesystem / locks — fed the assembled ``items`` (+
3
+ ``subagent_meta``) from ``_assemble_session`` in ``_lib_conversation_query.py``,
4
+ so the renderer stays golden-testable (the same purity contract as
5
+ ``_lib_share.py``). Golden-tested in ``tests/test_conversation_export.py``.
6
+
7
+ Public surface::
8
+
9
+ export_session_markdown(items, scope, *, subagent_meta=None,
10
+ title=None, session_id=None) -> str
11
+
12
+ ``scope`` is one of ``all`` / ``chat`` / ``prompts`` / ``recipe`` (the four
13
+ export scopes; the handler validates the value, so an unknown scope here falls
14
+ back to the ``all`` renderer). ``subagent_meta`` supplies subagent labels
15
+ (Codex P1-2); the serializer groups subagent turns by ``subagent_key`` in
16
+ document order — a deliberate simplification of the reader's full nesting tree.
17
+
18
+ Item / block shape (matches ``_assemble_session``): each item is a dict with
19
+ ``kind`` (``human``/``assistant``/``tool_result``/``meta``), ``anchor`` (with
20
+ ``uuid``), ``ts``, ``text``, ``blocks`` (list), ``subagent_key`` (``None`` for
21
+ the main session), ``is_sidechain``, plus ``meta_kind`` on meta items and
22
+ ``model`` on assistant turns. Tool blocks are ``kind == "tool_call"`` with
23
+ ``name`` / ``input`` (bounded dict) / ``input_truncated`` / ``result``
24
+ (``{text, truncated, is_error, ...}`` or ``None``) / optional ``edit_stat`` /
25
+ optional ``stderr`` (the structured Bash stderr suffix of ``result.text``).
26
+ """
27
+
28
+ # Deliberately the narrow {Edit, MultiEdit, Write} set — NOT the wider
29
+ # `_FILE_TOUCH_TOOLS` (which includes NotebookEdit). Edit-family tool calls
30
+ # render as a fenced ```diff block.
31
+ _EDIT_TOOLS = {"edit", "multiedit", "write"}
32
+ _TRUNCATED = " … [truncated]"
33
+
34
+
35
+ def export_session_markdown(items, scope, *, subagent_meta=None, title=None,
36
+ session_id=None):
37
+ """Render a whole assembled session to Markdown for one export scope."""
38
+ items = list(items or [])
39
+ subagent_meta = subagent_meta or {}
40
+ if scope == "recipe":
41
+ return _render_recipe(items, title)
42
+ if scope == "prompts":
43
+ return _render_prompts(items, title)
44
+ if scope == "chat":
45
+ return _render_chat(items, title)
46
+ return _render_all(items, subagent_meta, title) # "all" (default)
47
+
48
+
49
+ # ---- shared predicates / helpers ------------------------------------------
50
+
51
+ def _is_main(it):
52
+ """True for a main-session turn (not a subagent thread, not a sidechain)."""
53
+ return it.get("subagent_key") is None and not it.get("is_sidechain")
54
+
55
+
56
+ def _item_text(it):
57
+ return (it.get("text") or "").strip()
58
+
59
+
60
+ def _human_prompts(items):
61
+ """Main-session human turns with non-empty prose, in document order."""
62
+ return [it for it in items
63
+ if it.get("kind") == "human" and _is_main(it) and _item_text(it)]
64
+
65
+
66
+ def extract_prompt_entries(items):
67
+ """Ordered main-thread human prompts as ``[{"uuid", "text"}]``.
68
+
69
+ Single source of truth for the structured prompt spine (#217 S7) — reuses
70
+ the same main-thread predicate (``_human_prompts``) and the same prose
71
+ extraction (``_item_text``) as the recipe/prompts export, so the
72
+ ``/prompts`` route and the export can never drift. The uuid is the turn's
73
+ ``anchor`` uuid — the same identity ``get_conversation_outline`` emits — so
74
+ the spine aligns 1:1 with the outline's human turns.
75
+ """
76
+ return [{"uuid": it["anchor"]["uuid"], "text": _item_text(it)}
77
+ for it in _human_prompts(items)]
78
+
79
+
80
+ def _title_header(title):
81
+ """An H1 title block (with a trailing blank line) or empty string."""
82
+ t = (title or "").strip()
83
+ return f"# {t}\n\n" if t else ""
84
+
85
+
86
+ def _role_header(it):
87
+ kind = it.get("kind")
88
+ ts = it.get("ts") or ""
89
+ if kind == "human":
90
+ label = "## 👤 Human"
91
+ elif kind == "assistant":
92
+ label = "## 🤖 Assistant"
93
+ elif kind == "meta":
94
+ label = f"## ⚙️ {_meta_label(it)}"
95
+ else: # tool_result orphan / other
96
+ label = "## 🔧 Tool result"
97
+ return f"{label}{f' · {ts}' if ts else ''}"
98
+
99
+
100
+ def _meta_label(it):
101
+ mk = (it.get("meta_kind") or "note")
102
+ return mk.replace("_", " ").title()
103
+
104
+
105
+ def _blockquote(text):
106
+ """Render ``text`` as a Markdown blockquote (one ``> `` per line)."""
107
+ lines = (text or "").splitlines() or [""]
108
+ return "\n".join(f"> {ln}" if ln else ">" for ln in lines)
109
+
110
+
111
+ # ---- tool-call rendering ---------------------------------------------------
112
+
113
+ def _render_tool_call(b):
114
+ """Render one ``tool_call`` block to a Markdown chunk."""
115
+ name = b.get("name") or "Tool"
116
+ nm = name.lower()
117
+ inp = b.get("input") if isinstance(b.get("input"), dict) else {}
118
+ if nm in _EDIT_TOOLS:
119
+ return _render_edit_call(name, inp, b)
120
+ if nm == "bash":
121
+ return _render_bash_call(inp, b)
122
+ return _render_generic_call(name, b)
123
+
124
+
125
+ def _edit_diff_lines(name, inp):
126
+ """Reconstruct +/- diff lines for an Edit/MultiEdit/Write call from the
127
+ bounded input (best-effort — bounded leaves may be clipped)."""
128
+ nm = (name or "").lower()
129
+ lines = []
130
+ if nm == "write":
131
+ content = inp.get("content")
132
+ if isinstance(content, str):
133
+ for ln in content.splitlines():
134
+ lines.append("+" + ln)
135
+ return lines
136
+ if nm == "multiedit":
137
+ edits = inp.get("edits")
138
+ if isinstance(edits, list):
139
+ for e in edits:
140
+ e = e if isinstance(e, dict) else {}
141
+ lines.extend(_one_edit_lines(e.get("old_string"),
142
+ e.get("new_string")))
143
+ return lines
144
+ # edit
145
+ return _one_edit_lines(inp.get("old_string"), inp.get("new_string"))
146
+
147
+
148
+ def _one_edit_lines(old, new):
149
+ out = []
150
+ if isinstance(old, str):
151
+ for ln in old.splitlines():
152
+ out.append("-" + ln)
153
+ if isinstance(new, str):
154
+ for ln in new.splitlines():
155
+ out.append("+" + ln)
156
+ return out
157
+
158
+
159
+ def _render_edit_call(name, inp, b):
160
+ path = inp.get("file_path")
161
+ path = path if isinstance(path, str) and path else "(file)"
162
+ diff = "\n".join(_edit_diff_lines(name, inp))
163
+ trunc = _TRUNCATED if b.get("input_truncated") else ""
164
+ head = f"**{name}** `{path}`{trunc}"
165
+ return f"{head}\n\n```diff\n{diff}\n```"
166
+
167
+
168
+ def _render_bash_call(inp, b):
169
+ command = inp.get("command")
170
+ command = command if isinstance(command, str) else ""
171
+ res = b.get("result") if isinstance(b.get("result"), dict) else None
172
+ out_lines = [f"$ {command}"]
173
+ if res is not None:
174
+ text = res.get("text") or ""
175
+ stderr = b.get("stderr")
176
+ stdout = text
177
+ # result.text == stdout + stderr (the empirical Bash storage shape); when
178
+ # the structured stderr suffix is present, split it out for clarity.
179
+ if isinstance(stderr, str) and stderr and text.endswith(stderr):
180
+ stdout = text[: len(text) - len(stderr)]
181
+ if stdout:
182
+ out_lines.append(stdout.rstrip("\n"))
183
+ if isinstance(stderr, str) and stderr:
184
+ out_lines.append("# stderr")
185
+ out_lines.append(stderr.rstrip("\n"))
186
+ if res.get("truncated"):
187
+ out_lines.append(_TRUNCATED.strip())
188
+ body = "\n".join(out_lines)
189
+ return f"```bash\n{body}\n```"
190
+
191
+
192
+ def _render_generic_call(name, b):
193
+ parts = [f"**Tool: {name}**"]
194
+ summary = b.get("input_summary")
195
+ if isinstance(summary, str) and summary.strip() and summary.strip() != "{}":
196
+ parts.append(f"`{summary.strip()}`")
197
+ res = b.get("result") if isinstance(b.get("result"), dict) else None
198
+ if res is not None:
199
+ text = (res.get("text") or "").rstrip("\n")
200
+ trunc = _TRUNCATED if res.get("truncated") else ""
201
+ if text:
202
+ parts.append(f"```\n{text}\n```{trunc}" if not trunc
203
+ else f"```\n{text}\n```\n{trunc.strip()}")
204
+ elif trunc:
205
+ parts.append(trunc.strip())
206
+ return "\n\n".join(parts)
207
+
208
+
209
+ # ---- per-turn rendering (the `all` matrix) ---------------------------------
210
+
211
+ def _render_turn_body(it):
212
+ """The body chunks of a single turn (prose, thinking, tool calls, meta)."""
213
+ chunks = []
214
+ kind = it.get("kind")
215
+ prose = _item_text(it)
216
+
217
+ if kind == "meta":
218
+ # A meta turn renders its body as a labeled blockquote.
219
+ if prose:
220
+ chunks.append(_blockquote(prose))
221
+ return chunks
222
+
223
+ if prose:
224
+ chunks.append(prose)
225
+
226
+ for b in it.get("blocks", []):
227
+ bk = b.get("kind")
228
+ if bk == "thinking":
229
+ tx = (b.get("text") or "").strip()
230
+ if tx:
231
+ chunks.append("> 💭 Thinking\n>\n" + _blockquote(tx))
232
+ elif bk in ("tool_call", "tool_use"):
233
+ chunks.append(_render_tool_call(b))
234
+ elif bk == "tool_result":
235
+ # orphan tool_result block (rare — a result the kernel could not fold)
236
+ tx = (b.get("text") or "").rstrip("\n")
237
+ trunc = _TRUNCATED.strip() if b.get("truncated") else ""
238
+ if tx:
239
+ chunks.append(f"**Tool result**\n\n```\n{tx}\n```"
240
+ + (f"\n{trunc}" if trunc else ""))
241
+ return chunks
242
+
243
+
244
+ def _render_all(items, subagent_meta, title):
245
+ """Full-fidelity, document-order render. Main-session turns render inline;
246
+ consecutive subagent turns (same subagent_key) group under one heading."""
247
+ parts = [_title_header(title)] if _title_header(title) else []
248
+ cur_subagent = None # the subagent_key whose heading is currently open
249
+
250
+ for it in items:
251
+ sk = it.get("subagent_key")
252
+ if sk is not None or it.get("is_sidechain"):
253
+ # Subagent turn — open a grouping heading on a new key.
254
+ if sk != cur_subagent:
255
+ label = _subagent_label(sk, subagent_meta)
256
+ parts.append(f"### ⎇ Subagent: {label}")
257
+ cur_subagent = sk
258
+ else:
259
+ cur_subagent = None # back to the main thread
260
+
261
+ body = _render_turn_body(it)
262
+ block = [_role_header(it)]
263
+ block.extend(body)
264
+ parts.append("\n\n".join(p for p in block if p))
265
+
266
+ return "\n\n".join(p for p in parts if p).rstrip() + "\n"
267
+
268
+
269
+ def _subagent_label(sk, subagent_meta):
270
+ meta = subagent_meta.get(sk) if isinstance(subagent_meta, dict) else None
271
+ if isinstance(meta, dict):
272
+ kind = meta.get("kind") or meta.get("subagent_type")
273
+ if isinstance(kind, str) and kind.strip():
274
+ return kind.strip()
275
+ return sk if isinstance(sk, str) and sk else "subagent"
276
+
277
+
278
+ # ---- the `chat` scope (prose-only, main-session) --------------------------
279
+
280
+ def _render_chat(items, title):
281
+ """Human + assistant prose only — no thinking, no tools, no meta;
282
+ main-session only. Deliberately leaner than the live 'chat' focus mode
283
+ (which retains thinking) — an intentional divergence (spec §1)."""
284
+ parts = [_title_header(title)] if _title_header(title) else []
285
+ for it in items:
286
+ if it.get("kind") not in ("human", "assistant"):
287
+ continue
288
+ if not _is_main(it):
289
+ continue
290
+ prose = _item_text(it)
291
+ if not prose:
292
+ continue
293
+ parts.append(f"{_role_header(it)}\n\n{prose}")
294
+ return "\n\n".join(p for p in parts if p).rstrip() + "\n"
295
+
296
+
297
+ # ---- the `prompts` scope --------------------------------------------------
298
+
299
+ def _render_prompts(items, title):
300
+ """Main-session human turns only, each as ``## Prompt N`` + full text."""
301
+ parts = [_title_header(title)] if _title_header(title) else []
302
+ for n, it in enumerate(_human_prompts(items), start=1):
303
+ parts.append(f"## Prompt {n}\n\n{_item_text(it)}")
304
+ return "\n\n".join(p for p in parts if p).rstrip() + "\n"
305
+
306
+
307
+ # ---- the `recipe` scope (F5) ----------------------------------------------
308
+
309
+ def _render_recipe(items, title):
310
+ """A ``# Replay recipe`` header + a numbered list of main-session prompts —
311
+ the re-runnable script form."""
312
+ prompts = _human_prompts(items)
313
+ out = ["# Replay recipe"]
314
+ if title and title.strip():
315
+ out.append(f"_{title.strip()}_")
316
+ if prompts:
317
+ numbered = []
318
+ for n, it in enumerate(prompts, start=1):
319
+ # Collapse internal newlines so each prompt is one numbered step.
320
+ text = " ".join(_item_text(it).split())
321
+ numbered.append(f"{n}. {text}")
322
+ out.append("\n".join(numbered))
323
+ else:
324
+ out.append("_(no prompts)_")
325
+ return "\n\n".join(out).rstrip() + "\n"