agent-decoder 0.3.2__py3-none-any.whl

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 (89) hide show
  1. agent_decoder-0.3.2.dist-info/METADATA +387 -0
  2. agent_decoder-0.3.2.dist-info/RECORD +89 -0
  3. agent_decoder-0.3.2.dist-info/WHEEL +4 -0
  4. agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
  5. agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
  6. clau_decode/__init__.py +8 -0
  7. clau_decode/__main__.py +11 -0
  8. clau_decode/_auth_env.py +118 -0
  9. clau_decode/_pty_preexec.py +42 -0
  10. clau_decode/analytics/__init__.py +0 -0
  11. clau_decode/analytics/aggregator.py +55 -0
  12. clau_decode/analytics/cost.py +76 -0
  13. clau_decode/analytics/extractor.py +32 -0
  14. clau_decode/analytics/fast.py +289 -0
  15. clau_decode/analytics/models.py +49 -0
  16. clau_decode/analytics/pricing.py +162 -0
  17. clau_decode/analytics/prompt.py +34 -0
  18. clau_decode/analytics/service.py +23 -0
  19. clau_decode/analytics/stats.py +117 -0
  20. clau_decode/analytics/tips.py +176 -0
  21. clau_decode/btw_capture.py +290 -0
  22. clau_decode/cli.py +504 -0
  23. clau_decode/config.py +144 -0
  24. clau_decode/db.py +2054 -0
  25. clau_decode/driver_manager.py +387 -0
  26. clau_decode/drivers/__init__.py +27 -0
  27. clau_decode/drivers/base.py +170 -0
  28. clau_decode/drivers/registry.py +113 -0
  29. clau_decode/drivers/tmux_driver.py +488 -0
  30. clau_decode/editor.py +182 -0
  31. clau_decode/events_bus.py +69 -0
  32. clau_decode/locks.py +388 -0
  33. clau_decode/migrate.py +1313 -0
  34. clau_decode/models.py +285 -0
  35. clau_decode/parser.py +466 -0
  36. clau_decode/providers/__init__.py +30 -0
  37. clau_decode/providers/base.py +132 -0
  38. clau_decode/providers/claude.py +81 -0
  39. clau_decode/providers/codex.py +441 -0
  40. clau_decode/providers/registry.py +89 -0
  41. clau_decode/pty_native.py +39 -0
  42. clau_decode/pty_runner.py +2406 -0
  43. clau_decode/pty_screen_state.py +65 -0
  44. clau_decode/recap_runner.py +230 -0
  45. clau_decode/reporter.py +305 -0
  46. clau_decode/scanner.py +125 -0
  47. clau_decode/server.py +2841 -0
  48. clau_decode/static/android-chrome-192x192.png +0 -0
  49. clau_decode/static/android-chrome-512x512.png +0 -0
  50. clau_decode/static/app-icon.png +0 -0
  51. clau_decode/static/apple-touch-icon.png +0 -0
  52. clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
  53. clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
  54. clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
  55. clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
  56. clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
  57. clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
  58. clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
  59. clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
  60. clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
  61. clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
  62. clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
  63. clau_decode/static/assets/client-DTSXvAb8.js +1 -0
  64. clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
  65. clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
  66. clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
  67. clau_decode/static/assets/index-BHdCOIkq.css +10 -0
  68. clau_decode/static/assets/index-DfKltZ6z.js +5 -0
  69. clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
  70. clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
  71. clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
  72. clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
  73. clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
  74. clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
  75. clau_decode/static/assets/react-Bi_azaFt.js +9 -0
  76. clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
  77. clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  78. clau_decode/static/assets/shared-DdVGprvF.js +1 -0
  79. clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
  80. clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
  81. clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
  82. clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
  83. clau_decode/static/favicon-16x16.png +0 -0
  84. clau_decode/static/favicon-32x32.png +0 -0
  85. clau_decode/static/favicon.ico +0 -0
  86. clau_decode/static/index.html +24 -0
  87. clau_decode/static/site.webmanifest +1 -0
  88. clau_decode/update_check.py +51 -0
  89. clau_decode/watcher.py +63 -0
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class PtyScreenClassification:
9
+ state: str
10
+ decoded_input_safe: bool
11
+
12
+
13
+ # Strip ANSI / VT control sequences so substring matching sees readable text
14
+ # rather than escape-laden bytes.
15
+ _CSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
16
+ _OSC = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
17
+ _OTHER_ESC = re.compile(r"\x1b[@-Z\\-_]")
18
+
19
+
20
+ def _normalize(text: str) -> str:
21
+ text = _OSC.sub("", text)
22
+ text = _CSI.sub("", text)
23
+ text = _OTHER_ESC.sub("", text)
24
+ # Replace remaining control chars (except newline) with spaces, then
25
+ # collapse runs of spaces/tabs. Keep newlines so line-oriented phrases
26
+ # survive.
27
+ text = "".join(c if (c == "\n" or c.isprintable()) else " " for c in text)
28
+ return re.sub(r"[ \t]+", " ", text).lower()
29
+
30
+
31
+ def classify_screen(text: str) -> PtyScreenClassification:
32
+ """Best-effort classification of the live PTY screen for Native-View gating.
33
+
34
+ IMPORTANT: the input is the flattened output *ring* (a byte log with cursor
35
+ moves), not an emulator-rendered screen, so it is noisy and can contain
36
+ overwritten content. We therefore match only a small set of distinctive,
37
+ high-signal phrases and **err toward ``idle_chat_input`` (decoded input
38
+ safe) when unsure** — a false "native required" needlessly yanks the user
39
+ out of Decoded View and is the failure mode we most want to avoid. The
40
+ previous heuristics (bare ``allow`` + ``yes``/``no``, bare ``trust``,
41
+ ``?`` + ``select``) matched ordinary assistant output and caused exactly
42
+ those spurious switches. A fully robust state machine (slash palette, model
43
+ selector, etc.) would need real backend terminal emulation; that is out of
44
+ scope here.
45
+ """
46
+ s = _normalize(text)
47
+
48
+ # Auth/login — distinctive banners claude prints when not authenticated.
49
+ if "not logged in" in s or "please run /login" in s or "invalid api key" in s:
50
+ return PtyScreenClassification("login_required", False)
51
+
52
+ # Trust prompt — Claude Code: "Do you trust the files in this folder?"
53
+ if "do you trust the files" in s:
54
+ return PtyScreenClassification("trust_prompt", False)
55
+
56
+ # AskUserQuestion surfaced as a native prompt (tool name appears verbatim).
57
+ if "askuserquestion" in s:
58
+ return PtyScreenClassification("ask_user_question", False)
59
+
60
+ # Tool/permission prompt — Claude Code phrasings: "Do you want to proceed?"
61
+ # / "Do you want to allow ...". Specific enough to avoid prose false hits.
62
+ if "do you want to proceed" in s or "do you want to allow" in s:
63
+ return PtyScreenClassification("permission_prompt", False)
64
+
65
+ return PtyScreenClassification("idle_chat_input", True)
@@ -0,0 +1,230 @@
1
+ """Recap generation — drives a hidden PTY against a forked session.
2
+
3
+ The recap path uses the same subscription-backed TUI behavior as chat submit,
4
+ so it stays inside the no-additional-cost envelope.
5
+
6
+ How it works:
7
+ 1. The caller pre-mints a UUID for the fork and tombstones it in the
8
+ server's deleted-sessions set BEFORE invoking us — otherwise the
9
+ watcher would briefly index the fork JSONL into the sidebar between
10
+ file creation and our cleanup.
11
+ 2. We spawn ``claude --session-id <fork> --resume <source> --fork-session
12
+ --model haiku --permission-mode dontAsk`` in a hidden PTY (no UI).
13
+ 3. After the TUI signals ready, we write the recap prompt + Enter.
14
+ 4. We poll the fork's JSONL for an assistant entry with ``stop_reason``
15
+ set (the canonical "turn complete" signal) and return its text.
16
+ 5. The PTY is killed and the fork JSONL is deleted in the ``finally``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import json
23
+ import logging
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ from .pty_runner import DEFAULT_COLS, DEFAULT_ROWS, PtyChannel, _pty_env
28
+
29
+ _log = logging.getLogger(__name__)
30
+
31
+
32
+ async def generate_recap(
33
+ session_id: str,
34
+ *,
35
+ cwd: str,
36
+ bin_name: str,
37
+ prompt: str,
38
+ source_jsonl_path: Path,
39
+ fork_id: str,
40
+ timeout_seconds: float = 90.0,
41
+ ) -> Optional[str]:
42
+ """Drive a forked PTY against ``session_id`` to generate a recap.
43
+
44
+ Returns the assistant's text on success, or ``None`` on
45
+ spawn failure / readiness timeout / no assistant block within
46
+ ``timeout_seconds`` / non-text content.
47
+
48
+ The fork JSONL lives at ``source_jsonl_path.parent / f"{fork_id}.jsonl"``
49
+ (claude writes forks alongside the source) and is deleted on exit.
50
+ """
51
+ fork_jsonl = source_jsonl_path.parent / f"{fork_id}.jsonl"
52
+
53
+ # Note: we deliberately do NOT pass ``--model``. Earlier recap code forced
54
+ # ``haiku`` for cost reduction, but PTY-TUI turns are subscription-included
55
+ # so there's no per-call cost to minimise. Forcing a model alias also
56
+ # turned out to be brittle
57
+ # against cc-mirror profiles that map Claude aliases to their own
58
+ # backends (e.g. zai's GLM mapping silently dropped requests with
59
+ # ``--model haiku``). Letting the fork inherit the source session's
60
+ # model — what TUI does when ``--model`` is omitted — is both
61
+ # simpler and provider-agnostic.
62
+ argv = [
63
+ bin_name,
64
+ "--session-id",
65
+ fork_id,
66
+ "--resume",
67
+ session_id,
68
+ "--fork-session",
69
+ "--permission-mode",
70
+ "dontAsk",
71
+ ]
72
+ env = await _pty_env(DEFAULT_ROWS, DEFAULT_COLS, bin_name)
73
+
74
+ channel = PtyChannel(
75
+ session_id=f"recap-{fork_id}",
76
+ argv=argv,
77
+ cwd=cwd,
78
+ env=env,
79
+ # Skip the ownership sidecar — the fork JSONL doesn't exist yet
80
+ # and we're going to delete it on the way out, so there's nothing
81
+ # to lock or hand off.
82
+ jsonl_path=None,
83
+ )
84
+
85
+ try:
86
+ await channel.start()
87
+ ready = await channel.await_ready(timeout_s=5.0)
88
+ if not ready:
89
+ _log.warning(
90
+ "recap: TUI didn't signal ready within 5s (session %s, fork %s)",
91
+ session_id,
92
+ fork_id,
93
+ )
94
+ return None
95
+ # The fork has to load the source session's history before it can
96
+ # accept input — for sessions with 10+ KB of history the TUI is
97
+ # rendering its prompt box well before its input handler is
98
+ # actually attached. Settling 3 s post-ready is empirically
99
+ # reliable across the cwd-restore + permission-mode-banner
100
+ # bootstrap path observed against the zai TUI build.
101
+ await asyncio.sleep(3.0)
102
+ # Write the prompt one byte at a time with a small inter-byte
103
+ # delay. A single bulk ``os.write`` of the full prompt arrives
104
+ # faster than the fork's input loop drains it and the bytes
105
+ # appear to be discarded (no echo, no JSONL write, no error —
106
+ # zai TUI just sits at its prompt). Char-by-char mimics human
107
+ # typing and exercises the same input path interactive users
108
+ # take, so the fork picks the prompt up reliably.
109
+ body = prompt.replace("\n", "\x0a").encode("utf-8")
110
+ try:
111
+ for byte in body:
112
+ channel.write(bytes([byte]))
113
+ await asyncio.sleep(0.005)
114
+ channel.write(b"\r")
115
+ except Exception as exc:
116
+ _log.warning(
117
+ "recap: PTY write failed (session %s, fork %s): %s",
118
+ session_id,
119
+ fork_id,
120
+ exc,
121
+ )
122
+ return None
123
+ text = await _await_assistant_text(fork_jsonl, timeout_seconds)
124
+ if text is None:
125
+ _log.warning(
126
+ "recap: no assistant text in fork JSONL within %.1fs "
127
+ "(session %s, fork %s)",
128
+ timeout_seconds,
129
+ session_id,
130
+ fork_id,
131
+ )
132
+ return text
133
+ finally:
134
+ try:
135
+ await channel.kill()
136
+ except Exception as exc: # pragma: no cover — defensive
137
+ _log.debug("recap: kill raised (fork %s): %s", fork_id, exc)
138
+ try:
139
+ fork_jsonl.unlink()
140
+ except FileNotFoundError:
141
+ pass
142
+ except OSError as exc:
143
+ _log.debug("recap: fork JSONL unlink failed (%s): %s", fork_jsonl, exc)
144
+
145
+
146
+ async def _await_assistant_text(jsonl: Path, timeout_seconds: float) -> Optional[str]:
147
+ """Poll ``jsonl`` for an assistant entry with ``stop_reason`` set.
148
+
149
+ JSONL is the canonical channel (Phase 0 / Phase 1 findings) — TUI
150
+ ANSI output is fragile to parse. Returns the joined text content of
151
+ the final assistant block, or ``None`` on timeout.
152
+ """
153
+ loop = asyncio.get_running_loop()
154
+ deadline = loop.time() + timeout_seconds
155
+ last_size = 0
156
+ while loop.time() < deadline:
157
+ if jsonl.exists():
158
+ try:
159
+ content = jsonl.read_bytes()
160
+ except OSError:
161
+ content = b""
162
+ if len(content) > last_size:
163
+ last_size = len(content)
164
+ text = _final_assistant_text(content)
165
+ if text:
166
+ return text
167
+ await asyncio.sleep(0.1)
168
+ return None
169
+
170
+
171
+ def _final_assistant_text(content: bytes) -> Optional[str]:
172
+ """Return the assistant text for the *recap turn* — the assistant entry
173
+ written after the latest user entry.
174
+
175
+ A forked session's JSONL starts with the entire source conversation
176
+ copied in (that's how ``--fork-session`` works). Without anchoring on
177
+ "latest user entry", a naive reverse scan would return the source's
178
+ last assistant message and declare the recap done before the new turn
179
+ even started. We find the latest user entry first, then look for an
180
+ assistant entry after it whose ``stop_reason`` is set.
181
+ """
182
+ lines = content.decode("utf-8", errors="replace").splitlines()
183
+
184
+ # Index the latest user entry (the recap prompt we just wrote).
185
+ last_user_idx: Optional[int] = None
186
+ for i in range(len(lines) - 1, -1, -1):
187
+ line = lines[i].strip()
188
+ if not line:
189
+ continue
190
+ try:
191
+ d = json.loads(line)
192
+ except json.JSONDecodeError:
193
+ continue
194
+ if d.get("type") == "user":
195
+ last_user_idx = i
196
+ break
197
+ if last_user_idx is None:
198
+ return None
199
+
200
+ # Now find the latest assistant entry AFTER that user line. Streaming
201
+ # writes emit several partial assistant entries before the final one;
202
+ # only the entry where ``stop_reason`` is set is the turn-complete
203
+ # signal we want.
204
+ for i in range(len(lines) - 1, last_user_idx, -1):
205
+ line = lines[i].strip()
206
+ if not line:
207
+ continue
208
+ try:
209
+ d = json.loads(line)
210
+ except json.JSONDecodeError:
211
+ continue
212
+ if d.get("type") != "assistant":
213
+ continue
214
+ msg = d.get("message")
215
+ if not isinstance(msg, dict) or not msg.get("stop_reason"):
216
+ continue
217
+ parts: list[str] = []
218
+ c = msg.get("content")
219
+ if isinstance(c, list):
220
+ for block in c:
221
+ if (
222
+ isinstance(block, dict)
223
+ and block.get("type") == "text"
224
+ and isinstance(block.get("text"), str)
225
+ ):
226
+ parts.append(block["text"])
227
+ text = "\n".join(parts).strip()
228
+ if text:
229
+ return text
230
+ return None
@@ -0,0 +1,305 @@
1
+ """Reporting and export — JSON and Markdown session reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import TYPE_CHECKING
7
+
8
+ from .analytics.cost import SessionCost
9
+ from .analytics.pricing import ModelPricing
10
+ from .analytics.service import TokenAnalyticsService
11
+ from .models import Message, SessionDetail, TextBlock
12
+
13
+ if TYPE_CHECKING:
14
+ pass
15
+
16
+
17
+ def _text_content(message: Message) -> str:
18
+ """Extract plain text from a message's content blocks."""
19
+ parts: list[str] = []
20
+ for block in message.content_blocks:
21
+ if isinstance(block, TextBlock) and block.text:
22
+ parts.append(block.text)
23
+ return "\n".join(parts)
24
+
25
+
26
+ def _truncate(text: str, max_len: int = 200) -> str:
27
+ if len(text) <= max_len:
28
+ return text
29
+ return text[: max_len - 3] + "..."
30
+
31
+
32
+ _MIN_UTC = datetime.min.replace(tzinfo=timezone.utc)
33
+
34
+
35
+ def _timestamp_sort_key(value: datetime | str | None) -> datetime:
36
+ """Return an aware UTC datetime for mixed JSONL/ephemeral timestamps."""
37
+ if value is None:
38
+ return _MIN_UTC
39
+ if isinstance(value, datetime):
40
+ dt = value
41
+ elif isinstance(value, str):
42
+ if not value:
43
+ return _MIN_UTC
44
+ try:
45
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
46
+ except ValueError:
47
+ return _MIN_UTC
48
+ else:
49
+ return _MIN_UTC
50
+
51
+ if dt.tzinfo is None:
52
+ # Legacy ephemeral rows were written with naive local datetime.now().
53
+ # astimezone() intentionally interprets naive values in the process
54
+ # local timezone before converting them to UTC.
55
+ return dt.astimezone(timezone.utc)
56
+ return dt.astimezone(timezone.utc)
57
+
58
+
59
+ def export_json(
60
+ detail: SessionDetail,
61
+ cost: SessionCost | None = None,
62
+ prompts: list[dict] | None = None,
63
+ ephemerals: list[dict] | None = None,
64
+ ) -> dict:
65
+ """Produce a full structured JSON export of a session.
66
+
67
+ Args:
68
+ detail: SessionDetail with messages included.
69
+ cost: Optional SessionCost from the cost engine.
70
+ prompts: Optional list of PromptCost dicts from the analytics service.
71
+
72
+ Returns:
73
+ A dict ready for JSON serialization.
74
+ """
75
+ messages_out: list[dict] = []
76
+ for msg in detail.messages:
77
+ entry: dict = {
78
+ "id": msg.id,
79
+ "role": msg.role,
80
+ "timestamp": msg.timestamp.isoformat() if msg.timestamp else None,
81
+ "model": msg.model,
82
+ "text": _text_content(msg),
83
+ }
84
+ if msg.usage:
85
+ entry["usage"] = {
86
+ "input_tokens": msg.usage.input_tokens,
87
+ "output_tokens": msg.usage.output_tokens,
88
+ "cache_creation_input_tokens": msg.usage.cache_creation_input_tokens,
89
+ "cache_read_input_tokens": msg.usage.cache_read_input_tokens,
90
+ }
91
+ messages_out.append(entry)
92
+
93
+ result: dict = {
94
+ "session": {
95
+ "id": detail.id,
96
+ "title": detail.title,
97
+ "model": detail.model,
98
+ "started_at": detail.started_at.isoformat() if detail.started_at else None,
99
+ "updated_at": detail.updated_at.isoformat() if detail.updated_at else None,
100
+ "message_count": detail.message_count,
101
+ "cwd": detail.cwd,
102
+ "git_branch": detail.git_branch,
103
+ },
104
+ "messages": messages_out,
105
+ }
106
+
107
+ if cost:
108
+ result["cost"] = {
109
+ "model": cost.model,
110
+ "input_usd": float(cost.input_usd),
111
+ "output_usd": float(cost.output_usd),
112
+ "cache_write_usd": float(cost.cache_write_usd),
113
+ "cache_read_usd": float(cost.cache_read_usd),
114
+ "total_usd": float(cost.total_usd),
115
+ "pricing_known": cost.pricing is not None,
116
+ }
117
+
118
+ if prompts is not None:
119
+ result["prompts"] = prompts
120
+
121
+ # Always include the ephemerals array (empty list when none exist) so
122
+ # callers can reliably read result["ephemerals"] without key-checking.
123
+ result["ephemerals"] = ephemerals if ephemerals is not None else []
124
+
125
+ return result
126
+
127
+
128
+ def export_markdown(
129
+ detail: SessionDetail,
130
+ cost: SessionCost | None = None,
131
+ prompts: list[dict] | None = None,
132
+ pricing: ModelPricing | None = None,
133
+ all_models_usage: list[dict] | None = None,
134
+ ephemerals: list[dict] | None = None,
135
+ ) -> str:
136
+ """Produce a Markdown report with executive summary.
137
+
138
+ Args:
139
+ detail: SessionDetail with messages included.
140
+ cost: Optional SessionCost from the cost engine.
141
+ prompts: Optional list of PromptCost dicts.
142
+ pricing: Optional ModelPricing for the model used.
143
+ all_models_usage: Optional list of model usage dicts.
144
+
145
+ Returns:
146
+ A Markdown string.
147
+ """
148
+ lines: list[str] = []
149
+ title = detail.title or "Untitled Session"
150
+ lines.append(f"# {title}")
151
+ lines.append("")
152
+
153
+ # Executive summary
154
+ lines.append("## Executive Summary")
155
+ lines.append("")
156
+
157
+ summary_items: list[str] = []
158
+ summary_items.append(f"- **Session ID:** `{detail.id}`")
159
+ if detail.model:
160
+ summary_items.append(f"- **Model:** {detail.model}")
161
+ if detail.started_at:
162
+ summary_items.append(
163
+ f"- **Started:** {detail.started_at.strftime('%Y-%m-%d %H:%M UTC')}"
164
+ )
165
+ if detail.updated_at:
166
+ summary_items.append(
167
+ f"- **Last activity:** {detail.updated_at.strftime('%Y-%m-%d %H:%M UTC')}"
168
+ )
169
+ summary_items.append(f"- **Messages:** {detail.message_count}")
170
+ if detail.cwd:
171
+ summary_items.append(f"- **Working directory:** `{detail.cwd}`")
172
+ if detail.git_branch:
173
+ summary_items.append(f"- **Git branch:** `{detail.git_branch}`")
174
+
175
+ # Token totals
176
+ analytics = TokenAnalyticsService()
177
+ totals = analytics.session_totals(detail.messages)
178
+ summary_items.append(f"- **Total tokens:** {totals.total:,}")
179
+ summary_items.append(f" - Input: {totals.input_tokens:,}")
180
+ summary_items.append(f" - Output: {totals.output_tokens:,}")
181
+ summary_items.append(f" - Cache creation: {totals.cache_creation_tokens:,}")
182
+ summary_items.append(f" - Cache read: {totals.cache_read_tokens:,}")
183
+
184
+ if cost:
185
+ summary_items.append(f"- **Estimated cost:** ${float(cost.total_usd):.4f}")
186
+ if cost.pricing is not None:
187
+ summary_items.append(f" - Input: ${float(cost.input_usd):.4f}")
188
+ summary_items.append(f" - Output: ${float(cost.output_usd):.4f}")
189
+ summary_items.append(f" - Cache write: ${float(cost.cache_write_usd):.4f}")
190
+ summary_items.append(f" - Cache read: ${float(cost.cache_read_usd):.4f}")
191
+
192
+ lines.extend(summary_items)
193
+ lines.append("")
194
+
195
+ # Per-million-token pricing table
196
+ if pricing:
197
+ lines.append("## Pricing Table (per 1M tokens)")
198
+ lines.append("")
199
+ lines.append("| Category | USD / 1M tokens |")
200
+ lines.append("|---|---|")
201
+ lines.append(f"| Input | ${float(pricing.input_per_mtok):.2f} |")
202
+ lines.append(f"| Output | ${float(pricing.output_per_mtok):.2f} |")
203
+ lines.append(f"| Cache write | ${float(pricing.cache_write_per_mtok):.2f} |")
204
+ lines.append(f"| Cache read | ${float(pricing.cache_read_per_mtok):.2f} |")
205
+ lines.append("")
206
+
207
+ # Model usage breakdown
208
+ if all_models_usage:
209
+ lines.append("## Model Usage")
210
+ lines.append("")
211
+ lines.append(
212
+ "| Model | Messages | Input tokens | Output tokens | Total tokens |"
213
+ )
214
+ lines.append("|---|---|---|---|---|")
215
+ for entry in all_models_usage:
216
+ model = entry.get("model", "unknown")
217
+ lines.append(
218
+ f"| {model} | {entry.get('message_count', 0):,} "
219
+ f"| {entry.get('input_tokens', 0):,} "
220
+ f"| {entry.get('output_tokens', 0):,} "
221
+ f"| {entry.get('total_tokens', 0):,} |"
222
+ )
223
+ lines.append("")
224
+
225
+ # Per-prompt cost table
226
+ if prompts:
227
+ lines.append("## Prompt Breakdown")
228
+ lines.append("")
229
+ lines.append("| # | Input | Output | Cache create | Cache read | Total |")
230
+ lines.append("|---|---|---|---|---|---|")
231
+ for i, p in enumerate(prompts, 1):
232
+ bd = p.get("breakdown", p)
233
+ lines.append(
234
+ f"| {i} "
235
+ f"| {bd.get('input_tokens', 0):,} "
236
+ f"| {bd.get('output_tokens', 0):,} "
237
+ f"| {bd.get('cache_creation_tokens', 0):,} "
238
+ f"| {bd.get('cache_read_tokens', 0):,} "
239
+ f"| {bd.get('total', 0):,} |"
240
+ )
241
+ lines.append("")
242
+
243
+ # Conversation log — regular messages and ephemeral pairs interleaved by
244
+ # timestamp so the export reads chronologically.
245
+ lines.append("## Conversation")
246
+ lines.append("")
247
+
248
+ # Build ephemeral pair index: user row id → (user_row, assistant_row | None).
249
+ # Pairs are keyed by the user row; assistant rows (responds_to != None) are
250
+ # looked up from the same list. Unpaired user rows export without a reply.
251
+ eph_by_input_id: dict[int, dict] = {}
252
+ eph_response_by_responds_to: dict[int, dict] = {}
253
+ if ephemerals:
254
+ for row in ephemerals:
255
+ if row.get("responds_to") is None:
256
+ eph_by_input_id[row["id"]] = row
257
+ else:
258
+ eph_response_by_responds_to[row["responds_to"]] = row
259
+
260
+ # Collect regular messages and ephemeral user rows with normalized aware
261
+ # UTC sort keys. Legacy ephemerals may be naive local strings while JSONL
262
+ # message timestamps are UTC-aware datetimes.
263
+ # Sort everything by timestamp; None timestamps sort to the beginning.
264
+ timeline: list[tuple[datetime, str, object]] = []
265
+ for msg in detail.messages:
266
+ if msg.is_meta:
267
+ continue
268
+ timeline.append((_timestamp_sort_key(msg.timestamp), "message", msg))
269
+
270
+ for input_id, user_row in eph_by_input_id.items():
271
+ ts_key = _timestamp_sort_key(user_row.get("timestamp"))
272
+ timeline.append((ts_key, "ephemeral", user_row))
273
+
274
+ # Sort by timestamp ascending so the log reads in chronological order.
275
+ timeline.sort(key=lambda t: t[0])
276
+
277
+ for _, kind, obj in timeline:
278
+ if kind == "message":
279
+ msg = obj # type: ignore[assignment]
280
+ role_label = "**User**" if msg.role == "user" else "**Assistant**"
281
+ ts = msg.timestamp.strftime("%H:%M:%S") if msg.timestamp else ""
282
+ lines.append(f"### {role_label} {f'({ts})' if ts else ''}".strip())
283
+ lines.append("")
284
+ text = _text_content(msg)
285
+ if text:
286
+ lines.append(_truncate(text, 500))
287
+ lines.append("")
288
+ else:
289
+ # Ephemeral pair: blockquote marker matching the existing ### style
290
+ # but visually distinguished so readers recognise /btw exchanges.
291
+ user_row = obj # type: ignore[assignment]
292
+ user_ts = user_row.get("timestamp", "")
293
+ user_content = user_row.get("content", "")
294
+ eph_kind = user_row.get("kind", "btw")
295
+ resp_row = eph_response_by_responds_to.get(user_row["id"])
296
+
297
+ lines.append(f"> **[ephemeral · {eph_kind}]** {user_ts}")
298
+ lines.append(f"> {user_content}")
299
+ lines.append(">")
300
+ if resp_row:
301
+ resp_content = resp_row.get("content", "")
302
+ lines.append(f"> {resp_content}")
303
+ lines.append("")
304
+
305
+ return "\n".join(lines)