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.
- agent_decoder-0.3.2.dist-info/METADATA +387 -0
- agent_decoder-0.3.2.dist-info/RECORD +89 -0
- agent_decoder-0.3.2.dist-info/WHEEL +4 -0
- agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
- agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
- clau_decode/__init__.py +8 -0
- clau_decode/__main__.py +11 -0
- clau_decode/_auth_env.py +118 -0
- clau_decode/_pty_preexec.py +42 -0
- clau_decode/analytics/__init__.py +0 -0
- clau_decode/analytics/aggregator.py +55 -0
- clau_decode/analytics/cost.py +76 -0
- clau_decode/analytics/extractor.py +32 -0
- clau_decode/analytics/fast.py +289 -0
- clau_decode/analytics/models.py +49 -0
- clau_decode/analytics/pricing.py +162 -0
- clau_decode/analytics/prompt.py +34 -0
- clau_decode/analytics/service.py +23 -0
- clau_decode/analytics/stats.py +117 -0
- clau_decode/analytics/tips.py +176 -0
- clau_decode/btw_capture.py +290 -0
- clau_decode/cli.py +504 -0
- clau_decode/config.py +144 -0
- clau_decode/db.py +2054 -0
- clau_decode/driver_manager.py +387 -0
- clau_decode/drivers/__init__.py +27 -0
- clau_decode/drivers/base.py +170 -0
- clau_decode/drivers/registry.py +113 -0
- clau_decode/drivers/tmux_driver.py +488 -0
- clau_decode/editor.py +182 -0
- clau_decode/events_bus.py +69 -0
- clau_decode/locks.py +388 -0
- clau_decode/migrate.py +1313 -0
- clau_decode/models.py +285 -0
- clau_decode/parser.py +466 -0
- clau_decode/providers/__init__.py +30 -0
- clau_decode/providers/base.py +132 -0
- clau_decode/providers/claude.py +81 -0
- clau_decode/providers/codex.py +441 -0
- clau_decode/providers/registry.py +89 -0
- clau_decode/pty_native.py +39 -0
- clau_decode/pty_runner.py +2406 -0
- clau_decode/pty_screen_state.py +65 -0
- clau_decode/recap_runner.py +230 -0
- clau_decode/reporter.py +305 -0
- clau_decode/scanner.py +125 -0
- clau_decode/server.py +2841 -0
- clau_decode/static/android-chrome-192x192.png +0 -0
- clau_decode/static/android-chrome-512x512.png +0 -0
- clau_decode/static/app-icon.png +0 -0
- clau_decode/static/apple-touch-icon.png +0 -0
- clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
- clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
- clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
- clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
- clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
- clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
- clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
- clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
- clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
- clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
- clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
- clau_decode/static/assets/client-DTSXvAb8.js +1 -0
- clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
- clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
- clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
- clau_decode/static/assets/index-BHdCOIkq.css +10 -0
- clau_decode/static/assets/index-DfKltZ6z.js +5 -0
- clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
- clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
- clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
- clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
- clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
- clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
- clau_decode/static/assets/react-Bi_azaFt.js +9 -0
- clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
- clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
- clau_decode/static/assets/shared-DdVGprvF.js +1 -0
- clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
- clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
- clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
- clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
- clau_decode/static/favicon-16x16.png +0 -0
- clau_decode/static/favicon-32x32.png +0 -0
- clau_decode/static/favicon.ico +0 -0
- clau_decode/static/index.html +24 -0
- clau_decode/static/site.webmanifest +1 -0
- clau_decode/update_check.py +51 -0
- 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
|
clau_decode/reporter.py
ADDED
|
@@ -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)
|