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.
- package/CHANGELOG.md +47 -0
- package/bin/_cctally_cache.py +233 -17
- package/bin/_cctally_dashboard.py +169 -26
- package/bin/_cctally_db.py +348 -11
- package/bin/_cctally_doctor.py +69 -0
- package/bin/_cctally_record.py +36 -16
- package/bin/_lib_conversation.py +231 -12
- package/bin/_lib_conversation_export.py +325 -0
- package/bin/_lib_conversation_query.py +1191 -331
- package/bin/_lib_doctor.py +85 -0
- package/dashboard/static/assets/index-DXzPdFUE.js +78 -0
- package/dashboard/static/assets/index-Yau7oYp8.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CqBt0IM7.js +0 -68
- package/dashboard/static/assets/index-klTHJA52.css +0 -1
package/bin/_lib_conversation.py
CHANGED
|
@@ -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).
|
|
218
|
-
#
|
|
219
|
-
#
|
|
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.
|
|
466
|
-
#
|
|
467
|
-
#
|
|
465
|
+
# attribution is top-level on the JSONL object. The non-prose index is
|
|
466
|
+
# the split search_tool/search_thinking columns — tool/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
|
|
571
|
-
|
|
572
|
-
|
|
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"
|