cctally 1.72.0 → 1.74.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/_cctally_cache.py +271 -55
  3. package/bin/_cctally_core.py +20 -40
  4. package/bin/_cctally_dashboard.py +35 -1
  5. package/bin/_cctally_dashboard_conversation.py +731 -94
  6. package/bin/_cctally_dashboard_share.py +38 -3
  7. package/bin/_cctally_dashboard_sources.py +812 -74
  8. package/bin/_cctally_db.py +104 -1
  9. package/bin/_cctally_doctor.py +86 -4
  10. package/bin/_cctally_parser.py +17 -4
  11. package/bin/_cctally_record.py +162 -33
  12. package/bin/_cctally_refresh.py +58 -36
  13. package/bin/_cctally_source_analytics.py +288 -3
  14. package/bin/_cctally_statusline.py +811 -82
  15. package/bin/_cctally_transcript.py +223 -7
  16. package/bin/_cctally_tui.py +17 -14
  17. package/bin/_lib_aggregators.py +48 -34
  18. package/bin/_lib_codex_conversation_export.py +124 -0
  19. package/bin/_lib_codex_conversation_query.py +500 -28
  20. package/bin/_lib_codex_conversation_watch.py +323 -0
  21. package/bin/_lib_conversation_dispatch.py +269 -7
  22. package/bin/_lib_conversation_query.py +88 -0
  23. package/bin/_lib_dashboard_sources.py +20 -7
  24. package/bin/_lib_doctor.py +106 -0
  25. package/bin/_lib_jsonl.py +11 -0
  26. package/bin/_lib_pricing.py +7 -4
  27. package/bin/_lib_pricing_check.py +7 -1
  28. package/bin/_lib_quota.py +12 -11
  29. package/bin/_lib_statusline_candidates.py +734 -0
  30. package/bin/_lib_view_models.py +50 -14
  31. package/bin/cctally +31 -2
  32. package/dashboard/static/assets/index-DeQjEMO6.js +80 -0
  33. package/dashboard/static/assets/index-ZiPO8veo.css +1 -0
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +4 -1
  36. package/dashboard/static/assets/index-BWadAHxC.js +0 -80
  37. package/dashboard/static/assets/index-D2nwo_ln.css +0 -1
@@ -48,12 +48,43 @@ def cmd_transcript(args) -> int:
48
48
 
49
49
  # ---- export ----------------------------------------------------------------
50
50
 
51
+ _SPEED_ONLY_CODEX_MSG = "transcript: --speed applies only to Codex conversations"
52
+ _PENDING_EXPORT_MSG = (
53
+ "transcript: Codex conversation is not yet normalized "
54
+ "(migration 025 runs on the next cache open) — retry shortly")
55
+
56
+
57
+ def _emit_export(md: str, output) -> None:
58
+ """Byte-exact emission shared by the bare and qualified export paths: the exact
59
+ UTF-8 bytes to ``--output`` or to stdout (no ``print``, no added trailing
60
+ newline — the render already ends in exactly one), so the CLI byte-matches
61
+ ``GET /api/conversation/<id>/export``."""
62
+ data = md.encode("utf-8")
63
+ if output:
64
+ pathlib.Path(output).write_bytes(data)
65
+ else:
66
+ sys.stdout.buffer.write(data)
67
+ sys.stdout.buffer.flush()
68
+
69
+
51
70
  def _cmd_transcript_export(args) -> int:
52
71
  c = _cctally()
53
72
  session_id = args.session_id
54
73
  scope = getattr(args, "scope", "all")
55
74
  raw = bool(getattr(args, "raw", False))
56
75
  output = getattr(args, "output", None)
76
+ speed_arg = getattr(args, "speed", None) # None sentinel = flag omitted
77
+
78
+ if session_id.startswith("v1."):
79
+ return _cmd_transcript_export_qualified(
80
+ c, session_id, scope, raw, output, speed_arg)
81
+
82
+ # Legacy bare Claude path — byte-untouched. --speed is Codex pricing behavior,
83
+ # so an explicit value on any non-Codex ref is a usage error (resolved-source
84
+ # rule, §4.1) — never a silent no-op.
85
+ if speed_arg is not None:
86
+ eprint(_SPEED_ONLY_CODEX_MSG)
87
+ return 2
57
88
 
58
89
  conn = c.open_cache_db()
59
90
  try:
@@ -69,19 +100,68 @@ def _cmd_transcript_export(args) -> int:
69
100
  finally:
70
101
  conn.close()
71
102
 
72
- data = md.encode("utf-8")
73
- if output:
74
- pathlib.Path(output).write_bytes(data)
75
- else:
76
- # Byte-exact: raw bytes to stdout, no `print`, no added trailing newline.
77
- sys.stdout.buffer.write(data)
78
- sys.stdout.buffer.flush()
103
+ _emit_export(md, output)
104
+ return 0
105
+
106
+
107
+ def _cmd_transcript_export_qualified(
108
+ c, session_id, scope, raw, output, speed_arg) -> int:
109
+ """Qualified (``v1.``) export via the neutral dispatch layer (§4.1). Anonymized
110
+ by default with the QUALIFIED provider-aware plan (§3.6); ``--raw`` escapes.
111
+ Byte-matches ``GET /api/conversation/<v1key>/export`` in both modes."""
112
+ disp = c._load_sibling("_lib_conversation_dispatch")
113
+ cref = disp.resolve_conversation_ref(session_id)
114
+ # Resolved-source --speed rejection (§4.1): explicit --speed (any value,
115
+ # including auto) is a usage error unless the ref resolves to source == codex.
116
+ if speed_arg is not None and (cref is None or cref.source != "codex"):
117
+ eprint(_SPEED_ONLY_CODEX_MSG)
118
+ return 2
119
+ speed = c._resolve_codex_speed(speed_arg or "auto")
120
+
121
+ conn = c.open_cache_db()
122
+ try:
123
+ env = disp.neutral_export(
124
+ conn, session_id, scope=scope, effective_speed=speed)
125
+ status = env.get("status")
126
+ if status == "normalization_pending":
127
+ eprint(_PENDING_EXPORT_MSG)
128
+ return 1
129
+ if status == "validation_error":
130
+ eprint(f"transcript: scope {scope!r} is not supported for a Codex "
131
+ f"conversation (only the whole-conversation export)")
132
+ return 2
133
+ if status != "ok":
134
+ eprint(f"transcript: no conversation found for key {session_id!r}")
135
+ return 1
136
+ md = env["markdown"]
137
+ if not raw:
138
+ cq = c._load_sibling("_lib_conversation_query")
139
+ anon = c._load_sibling("_lib_conversation_anon")
140
+ plan = cq.build_anon_plan_for_sources(
141
+ conn, home_dir=os.path.expanduser("~"),
142
+ sources={cref.source})
143
+ md = anon.scrub_text(md, plan)
144
+ finally:
145
+ conn.close()
146
+
147
+ _emit_export(md, output)
79
148
  return 0
80
149
 
81
150
 
82
151
  # ---- search ----------------------------------------------------------------
83
152
 
84
153
  def _cmd_transcript_search(args) -> int:
154
+ if getattr(args, "source", "claude") == "codex":
155
+ return _cmd_transcript_search_codex(args)
156
+ # Legacy Claude path (byte-frozen). --cursor is Codex-only pagination — using
157
+ # it with --source claude is a usage error, never a silent no-op (§4.3).
158
+ if getattr(args, "cursor", None) is not None:
159
+ eprint("transcript: --cursor requires --source codex")
160
+ return 2
161
+ return _cmd_transcript_search_claude(args)
162
+
163
+
164
+ def _cmd_transcript_search_claude(args) -> int:
85
165
  c = _cctally()
86
166
  query = args.query
87
167
  kind = getattr(args, "kind", "all")
@@ -131,6 +211,142 @@ def _cmd_transcript_search(args) -> int:
131
211
  return 0
132
212
 
133
213
 
214
+ # ---- search (Codex) --------------------------------------------------------
215
+
216
+ _CODEX_SEARCH_PENDING_MSG = (
217
+ "transcript: Codex conversations are not yet normalized "
218
+ "(migration 025 runs on the next cache open); no results yet")
219
+
220
+
221
+ def _cmd_transcript_search_codex(args) -> int:
222
+ """``transcript search --source codex`` (§4.3). The Codex search kernel has no
223
+ filter axes and paginates by opaque cursor, so ``--offset`` and the Claude-only
224
+ filter flags are usage errors, and ``--cursor`` carries the external cursor."""
225
+ c = _cctally()
226
+ query = args.query
227
+ kind = getattr(args, "kind", "all")
228
+ limit = getattr(args, "limit", 50)
229
+ cursor = getattr(args, "cursor", None)
230
+ as_json = bool(getattr(args, "json", False))
231
+
232
+ # Pagination + filter axes the Codex kernel does not have → exit 2 (silently
233
+ # ignoring a filter would fabricate results).
234
+ if getattr(args, "offset", 0):
235
+ eprint("transcript: --offset is not supported with --source codex "
236
+ "(use --cursor)")
237
+ return 2
238
+ rejected = []
239
+ if getattr(args, "project", None):
240
+ rejected.append("--project")
241
+ if getattr(args, "model", None):
242
+ rejected.append("--model")
243
+ if getattr(args, "date_from", None):
244
+ rejected.append("--date-from")
245
+ if getattr(args, "date_to", None):
246
+ rejected.append("--date-to")
247
+ if getattr(args, "cost_min", None) is not None:
248
+ rejected.append("--cost-min")
249
+ if getattr(args, "cost_max", None) is not None:
250
+ rejected.append("--cost-max")
251
+ if getattr(args, "rebuild_min", None) is not None:
252
+ rejected.append("--rebuild-min")
253
+ if rejected:
254
+ eprint(f"transcript: {', '.join(rejected)} not supported with "
255
+ f"--source codex")
256
+ return 2
257
+
258
+ disp = c._load_sibling("_lib_conversation_dispatch")
259
+ # Validate the external cursor up front → exit 2 on a bad token.
260
+ if cursor is not None:
261
+ try:
262
+ disp.decode_search_cursor(cursor)
263
+ except disp.InvalidSearchCursor:
264
+ eprint("transcript: invalid --cursor")
265
+ return 2
266
+
267
+ conn = c.open_cache_db()
268
+ try:
269
+ result = disp.neutral_search(
270
+ conn, query, source="codex", kind=kind,
271
+ effective_speed=c._resolve_codex_speed("auto"),
272
+ limit=limit, cursor=cursor)
273
+ finally:
274
+ conn.close()
275
+
276
+ # normalization_pending: an empty, exit-0 answer with one stderr note — search
277
+ # is navigation, and "nothing yet" is truthful (§4.3).
278
+ if result.get("status") == "normalization_pending":
279
+ eprint(_CODEX_SEARCH_PENDING_MSG)
280
+
281
+ if as_json:
282
+ payload = c.stamp_schema_version(_codex_search_to_camel(result, query))
283
+ print(_json_dumps(payload))
284
+ return 0
285
+ _render_codex_search_table(args, result)
286
+ return 0
287
+
288
+
289
+ def _codex_search_to_camel(result: dict, query: str) -> dict:
290
+ """The pinned, stamped-first camelCase Codex search envelope (§4.3):
291
+ ``{schemaVersion, source, query, mode, total, hits[...], nextCursor}``. The
292
+ leading ``schemaVersion`` is inserted by ``stamp_schema_version``."""
293
+ hits = [
294
+ {
295
+ "conversationKey": h.get("conversation_key"),
296
+ "itemKey": h.get("item_key"),
297
+ "title": h.get("title"),
298
+ "snippet": h.get("snippet"),
299
+ "badges": h.get("badges", []),
300
+ "lastActivityUtc": h.get("last_activity_utc"),
301
+ "projectLabel": h.get("project_label"),
302
+ }
303
+ for h in result.get("hits", [])
304
+ ]
305
+ return {
306
+ "source": "codex",
307
+ "query": result.get("query", query),
308
+ "mode": result.get("mode"),
309
+ "total": result.get("total", 0),
310
+ "hits": hits,
311
+ "nextCursor": (result.get("page") or {}).get("cursor"),
312
+ }
313
+
314
+
315
+ def _render_codex_search_table(args, result: dict) -> None:
316
+ """Codex human table: Key / When / Project / Kinds / Snippet (§4.3). ``Key`` is
317
+ the full untruncated ``v1.`` conversation key (so search → export pipes);
318
+ ``When`` renders ``last_activity_utc`` through the display-tz chokepoint;
319
+ ``Project`` is ``—`` when null; ``Kinds`` are the hit badges."""
320
+ c = _cctally()
321
+ hits = result.get("hits", [])
322
+ if not hits:
323
+ print("No matching transcripts.")
324
+ return
325
+ tz = c.resolve_display_tz(args, c.load_config())
326
+ rows = []
327
+ for h in hits:
328
+ last = h.get("last_activity_utc")
329
+ when = c.format_display_dt(
330
+ last, tz, fmt="%Y-%m-%d %H:%M", suffix=True) if last else ""
331
+ kinds = ",".join(h.get("badges") or []) or "-"
332
+ rows.append([
333
+ h.get("conversation_key") or "", # full, untruncated key
334
+ when,
335
+ h.get("project_label") or "—",
336
+ kinds,
337
+ (h.get("snippet") or "").strip(),
338
+ ])
339
+ table = _boxed_table(
340
+ ["Key", "When", "Project", "Kinds", "Snippet"], rows,
341
+ ["left", "left", "left", "left", "left"])
342
+ print(table)
343
+ total = result.get("total", len(hits))
344
+ print(f"\n{len(hits)} of {total} match(es)")
345
+ next_cursor = (result.get("page") or {}).get("cursor")
346
+ if next_cursor:
347
+ print(f"next: --cursor {next_cursor}")
348
+
349
+
134
350
  def _json_dumps(payload) -> str:
135
351
  import json
136
352
  return json.dumps(payload, indent=2)
@@ -227,6 +227,7 @@ from _lib_fmt import stable_sum
227
227
  # shared no-op singleton), so the _tui_build_snapshot seam wraps below cost
228
228
  # nothing on the default path.
229
229
  import _lib_perf as _perf
230
+ import _lib_log
230
231
 
231
232
  import importlib.util as _ilu
232
233
 
@@ -261,7 +262,6 @@ from _lib_forecast import _compute_forecast, ForecastInputs, ForecastOutput, Bud
261
262
  _ensure_sibling_loaded("_lib_dashboard_sources")
262
263
  _ensure_sibling_loaded("_cctally_dashboard_sources")
263
264
  from _cctally_dashboard_sources import (
264
- CodexProjectionIncoherent,
265
265
  DashboardReadContext,
266
266
  build_codex_source_state,
267
267
  refresh_codex_source_clock,
@@ -2520,8 +2520,18 @@ def _tui_build_source_bundle(
2520
2520
  else unavailable_source_state("codex", warning)
2521
2521
  )
2522
2522
  else:
2523
- codex = reuse_coherent_source_state(
2524
- prior_codex, data_version=codex_version,
2523
+ # Projection coherence can recover when S2 writes its certificate
2524
+ # without changing physical accounting. A current, hero-scoped
2525
+ # incoherence generation is intentionally not retained through
2526
+ # that repair opportunity; all other fresh partial states remain
2527
+ # eligible for exact reuse.
2528
+ codex = (
2529
+ None if prior_codex is not None and any(
2530
+ warning.code == "codex_projection_incoherent"
2531
+ for warning in prior_codex.warnings
2532
+ ) else reuse_coherent_source_state(
2533
+ prior_codex, data_version=codex_version,
2534
+ )
2525
2535
  )
2526
2536
  if codex is None:
2527
2537
  try:
@@ -2539,18 +2549,11 @@ def _tui_build_source_bundle(
2539
2549
  ),
2540
2550
  data_version=codex_version,
2541
2551
  )
2542
- except CodexProjectionIncoherent:
2543
- warning = SourceDashboardWarning(
2544
- "codex_projection_incoherent",
2545
- "Codex quota projection is unavailable.",
2546
- "quota",
2547
- )
2548
- codex = (
2549
- degrade_source_state(prior_codex, warning)
2550
- if prior_codex is not None
2551
- else unavailable_source_state("codex", warning)
2552
- )
2553
2552
  except Exception:
2553
+ _lib_log.get_logger("dashboard").error(
2554
+ "codex_read_model source build failed",
2555
+ exc_info=True,
2556
+ )
2554
2557
  warning = SourceDashboardWarning(
2555
2558
  "source_build_failed", "Source data could not be built.", "read_model",
2556
2559
  )
@@ -42,7 +42,7 @@ import os
42
42
  import pathlib
43
43
  import sys
44
44
  from dataclasses import dataclass
45
- from typing import Any, Callable
45
+ from typing import Any, Callable, Iterable
46
46
 
47
47
 
48
48
  def _cctally():
@@ -414,6 +414,17 @@ class CodexSessionUsage:
414
414
  codex_root: str = ""
415
415
 
416
416
 
417
+ @dataclass(frozen=True)
418
+ class CodexSessionIdentity:
419
+ """Caller-supplied identity for the pure Codex session accumulator."""
420
+
421
+ group_key: tuple[str, str]
422
+ session_id_path: str
423
+ session_file: str
424
+ directory: str
425
+ codex_root: str
426
+
427
+
417
428
  @dataclass
418
429
  class ClaudeSessionUsage:
419
430
  """Aggregated Claude usage for one sessionId (may span multiple JSONL files)."""
@@ -619,41 +630,19 @@ def _codex_home_root_from_prefix(root_prefix: str) -> str:
619
630
  return s
620
631
 
621
632
 
622
- def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard") -> list[CodexSessionUsage]:
623
- """Group by session file path (upstream-compatible).
624
-
625
- Sessions are keyed by the full relative-path-without-.jsonl rather than
626
- the inner UUID. Result is sorted by last_activity descending (most
627
- recent first), matching upstream's default view.
628
-
629
- Per-model breakdowns include `isFallback: bool` — true when the model is
630
- absent from CODEX_MODEL_PRICING.
631
- """
633
+ def _aggregate_codex_sessions_keyed(
634
+ entries: Iterable[tuple[CodexEntry, CodexSessionIdentity]],
635
+ speed: str = "standard",
636
+ ) -> list[CodexSessionUsage]:
637
+ """Pure Codex session arithmetic over caller-owned file identities."""
632
638
  by_session: dict[tuple[str, str], dict[str, Any]] = {}
633
- for entry in entries:
634
- id_path, file_name, directory = _session_path_parts(entry.source_path)
635
- # Disambiguate identical relative paths under DIFFERENT $CODEX_HOME
636
- # roots (issue #108). _session_path_parts strips the matched root, so
637
- # <rootA>/sessions/2026/04/17/rollout-x.jsonl and the same relative
638
- # path under <rootB> both yield id_path "2026/04/17/rollout-x";
639
- # grouping on id_path alone would silently merge two distinct sessions
640
- # (summed tokens, one UUID). Key on (root_prefix, id_path), where
641
- # root_prefix is source_path with the id_path tail removed. Single-root
642
- # data — and the bare-relative fixture form — has a constant prefix, so
643
- # the grouping, insertion order, and every golden stay byte-identical;
644
- # only a genuine cross-root collision splits into separate rows.
645
- suffix = id_path + ".jsonl"
646
- sp = entry.source_path
647
- root_prefix = sp[: -len(suffix)] if sp.endswith(suffix) else sp
648
- sess = by_session.setdefault((root_prefix, id_path), {
639
+ for entry, identity in entries:
640
+ sess = by_session.setdefault(identity.group_key, {
649
641
  "session_id_uuid": entry.session_id,
650
- "session_id_path": id_path,
651
- "session_file": file_name,
652
- "directory": directory,
653
- # Matched $CODEX_HOME root (home-root form) — issue #110 display
654
- # disambiguator. Derived from the same root_prefix that keys the
655
- # group, so it's constant per group.
656
- "codex_root": _codex_home_root_from_prefix(root_prefix),
642
+ "session_id_path": identity.session_id_path,
643
+ "session_file": identity.session_file,
644
+ "directory": identity.directory,
645
+ "codex_root": identity.codex_root,
657
646
  "input": 0, "cached_input": 0, "output": 0, "reasoning": 0,
658
647
  "cost": 0.0, "models": {}, "models_order": [],
659
648
  "last": entry.timestamp,
@@ -724,6 +713,31 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
724
713
  return result
725
714
 
726
715
 
716
+ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard") -> list[CodexSessionUsage]:
717
+ """Group CLI Codex entries by their existing filesystem-derived identity.
718
+
719
+ The wrapper preserves the historic CLI path parsing and byte-stable output;
720
+ only the accumulation itself lives in the keyed pure seam above.
721
+ """
722
+ keyed: list[tuple[CodexEntry, CodexSessionIdentity]] = []
723
+ for entry in entries:
724
+ id_path, file_name, directory = _session_path_parts(entry.source_path)
725
+ # Disambiguate equal relative paths under separate configured roots.
726
+ suffix = id_path + ".jsonl"
727
+ source_path = entry.source_path
728
+ root_prefix = (
729
+ source_path[: -len(suffix)] if source_path.endswith(suffix) else source_path
730
+ )
731
+ keyed.append((entry, CodexSessionIdentity(
732
+ group_key=(root_prefix, id_path),
733
+ session_id_path=id_path,
734
+ session_file=file_name,
735
+ directory=directory,
736
+ codex_root=_codex_home_root_from_prefix(root_prefix),
737
+ )))
738
+ return _aggregate_codex_sessions_keyed(keyed, speed=speed)
739
+
740
+
727
741
  def _aggregate_claude_sessions(
728
742
  entries: list["_JoinedClaudeEntry"],
729
743
  mode: str = "auto",
@@ -0,0 +1,124 @@
1
+ """#294 S7 — deterministic Codex conversation Markdown renderer (§3.3).
2
+
3
+ The Codex sibling of ``_lib_conversation_export`` (Claude). Pure and I/O-free: the
4
+ whole input is the neutral detail envelope ``get_codex_conversation`` produces
5
+ (items + blocks + per-turn cost + tokens + threading), so the render is a pure
6
+ function of a fixed DB + effective_speed — no ambient width, version, or clock
7
+ inputs, so a fixture golden only stales on a real data/pricing change.
8
+
9
+ Provider-truthful throughout: native Codex token labels
10
+ (``input``/``output``/``cached_input``/``reasoning_output``, never Claude cache
11
+ vocabulary), per-turn cost with the unattributed bucket rendered as an explicit
12
+ line, and children listed as ``v1.`` conversation-key references — NEVER inlined
13
+ (a Codex child is its own conversation; inlining would double-export and break
14
+ cost-once).
15
+ """
16
+ from __future__ import annotations
17
+
18
+
19
+ def _money(value: float | None) -> str:
20
+ """Deterministic USD formatting (no locale / ambient inputs)."""
21
+ return f"${(value or 0.0):.4f}"
22
+
23
+
24
+ def _token_line(tokens: dict | None) -> str:
25
+ """Provider-native token summary — Codex fields only."""
26
+ t = tokens or {}
27
+ return (f"input {int(t.get('input', 0) or 0)} · "
28
+ f"output {int(t.get('output', 0) or 0)} · "
29
+ f"cached_input {int(t.get('cached_input', 0) or 0)} · "
30
+ f"reasoning_output {int(t.get('reasoning_output', 0) or 0)}")
31
+
32
+
33
+ def _blockquote(text: str) -> str:
34
+ """Render ``text`` as a Markdown blockquote (one ``> `` per line)."""
35
+ lines = (text or "").splitlines() or [""]
36
+ return "\n".join(f"> {ln}" if ln else ">" for ln in lines)
37
+
38
+
39
+ def _fence(text: str) -> str:
40
+ return f"```\n{text}\n```" if text else "_(empty)_"
41
+
42
+
43
+ _ITEM_HEADER = {
44
+ "user": "## 👤 User",
45
+ "assistant": "## 🤖 Assistant",
46
+ "event": "## 🗓 Event",
47
+ }
48
+
49
+
50
+ def _render_block(b: dict) -> str:
51
+ bk = b.get("kind")
52
+ text = (b.get("text") or "").strip()
53
+ if bk == "reasoning":
54
+ return "> 💭 Reasoning\n>\n" + _blockquote(text) if text else ""
55
+ if bk == "tool_call":
56
+ detail = b.get("detail") if isinstance(b.get("detail"), dict) else {}
57
+ name = detail.get("name")
58
+ head = f"**🔧 Tool call: {name}**" if name else "**🔧 Tool call**"
59
+ chunks = [head]
60
+ if text:
61
+ chunks.append(_fence(text))
62
+ output = b.get("output")
63
+ if isinstance(output, dict):
64
+ otext = (output.get("text") or "").strip()
65
+ chunks.append("**Output**")
66
+ chunks.append(_fence(otext))
67
+ return "\n\n".join(c for c in chunks if c)
68
+ if bk == "tool_output":
69
+ return "**🔧 Tool output**\n\n" + _fence(text)
70
+ if bk == "event":
71
+ return f"> 🗓 {text}" if text else ""
72
+ # user / assistant prose block
73
+ return text
74
+
75
+
76
+ def _render_item(it: dict) -> str:
77
+ kind = it.get("kind")
78
+ header = _ITEM_HEADER.get(kind, f"## {kind}")
79
+ ts = it.get("timestamp_utc") or ""
80
+ if ts:
81
+ header += f" · {ts}"
82
+ model = it.get("model")
83
+ if kind == "assistant" and model:
84
+ header += f" · {model}"
85
+ chunks = [header]
86
+ for b in it.get("blocks", []):
87
+ rendered = _render_block(b)
88
+ if rendered:
89
+ chunks.append(rendered)
90
+ cost = it.get("cost_usd")
91
+ if cost is not None:
92
+ chunks.append(f"_Cost: {_money(cost)} · {_token_line(it.get('tokens'))}_")
93
+ return "\n\n".join(c for c in chunks if c)
94
+
95
+
96
+ def render_codex_conversation_markdown(detail: dict) -> str:
97
+ """Render a whole Codex conversation (an ``ok`` detail envelope) to Markdown.
98
+
99
+ Deterministic for a fixed ``detail``. Children appear only as ``v1.`` reference
100
+ lines — never inlined. The pending / not_found envelopes are handled upstream
101
+ (this renderer only ever sees ``ok``)."""
102
+ title = (detail.get("title") or "").strip()
103
+ parts = [f"# {title}" if title else "# Codex conversation"]
104
+ parent = detail.get("parent")
105
+ if isinstance(parent, dict) and parent.get("conversation_key"):
106
+ parts.append(
107
+ f"_Parent: {(parent.get('title') or '').strip()} "
108
+ f"(`{parent['conversation_key']}`)_")
109
+ for it in detail.get("items", []):
110
+ parts.append(_render_item(it))
111
+ unattr = detail.get("unattributed_cost_usd")
112
+ if unattr:
113
+ parts.append(f"_Unattributed cost: {_money(unattr)}_")
114
+ parts.append(
115
+ f"_Total cost: {_money(detail.get('total_cost_usd'))} · "
116
+ f"{_token_line(detail.get('tokens'))}_")
117
+ children = detail.get("children") or []
118
+ if children:
119
+ lines = ["## Child conversations"]
120
+ for c in children:
121
+ lines.append(
122
+ f"- {(c.get('title') or '').strip()} (`{c.get('conversation_key')}`)")
123
+ parts.append("\n".join(lines))
124
+ return "\n\n".join(p for p in parts if p).rstrip() + "\n"