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
clau_decode/parser.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""JSONL parser — reads a session file and returns structured messages.
|
|
2
|
+
|
|
3
|
+
Contract (for Agent 1 to implement):
|
|
4
|
+
parse_session(path: Path) -> tuple[Session, list[Message]]
|
|
5
|
+
- Read every line of the JSONL file
|
|
6
|
+
- Build Session metadata from non-message records (custom-title, permission-mode, etc.)
|
|
7
|
+
- Build Message objects from user/assistant records
|
|
8
|
+
- Return (session, messages) — messages are flat, NOT yet threaded
|
|
9
|
+
|
|
10
|
+
build_message_tree(messages: list[Message]) -> list[MessageTree]
|
|
11
|
+
- Walk parentUuid chains to build parent→child tree
|
|
12
|
+
- Sidechain messages (isSidechain=True) are children of their parent
|
|
13
|
+
- Root messages (parentId=None) are top-level entries
|
|
14
|
+
- Preserves chronological order within siblings
|
|
15
|
+
|
|
16
|
+
SOLID notes:
|
|
17
|
+
- Single Responsibility: this module only parses; it does not write to DB
|
|
18
|
+
- Open/Closed: add new record type handlers without changing existing ones
|
|
19
|
+
- The _RECORD_HANDLERS dict is the extension point
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .models import (
|
|
31
|
+
ContentBlock,
|
|
32
|
+
ImageBlock,
|
|
33
|
+
Message,
|
|
34
|
+
MessageTree,
|
|
35
|
+
Session,
|
|
36
|
+
TextBlock,
|
|
37
|
+
ThinkingBlock,
|
|
38
|
+
ToolResultBlock,
|
|
39
|
+
ToolUseBlock,
|
|
40
|
+
TokenUsage,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# UUID validation pattern
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Shared title inference helper
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _infer_title_from_messages(messages: list[Message]) -> str | None:
|
|
56
|
+
"""Infer a session title from the first non-meta user TextBlock.
|
|
57
|
+
|
|
58
|
+
Strips system XML tags, takes the first non-empty line, returns it or None
|
|
59
|
+
if no suitable text is found. Used by both the Claude parser and the
|
|
60
|
+
CodexAdapter so title logic stays in one place.
|
|
61
|
+
|
|
62
|
+
The tag-name class allows underscores (``[a-z0-9_-]``) so wrapper tags like
|
|
63
|
+
Codex's ``<environment_context>…</environment_context>`` — which is the
|
|
64
|
+
entire first user message in real Codex rollouts — get stripped; otherwise
|
|
65
|
+
every Codex session's title would be the literal ``<environment_context>``.
|
|
66
|
+
"""
|
|
67
|
+
for m in messages:
|
|
68
|
+
if m.role == "user" and not m.is_meta:
|
|
69
|
+
for block in m.content_blocks:
|
|
70
|
+
if isinstance(block, TextBlock) and block.text.strip():
|
|
71
|
+
text = re.sub(
|
|
72
|
+
r"<[a-z][a-z0-9_-]*>[\s\S]*?</[a-z][a-z0-9_-]*>",
|
|
73
|
+
"",
|
|
74
|
+
block.text,
|
|
75
|
+
).strip()
|
|
76
|
+
text = text.splitlines()[0].strip() if text else ""
|
|
77
|
+
if text:
|
|
78
|
+
return text
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Public API
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_session(path: Path) -> tuple[Session, list[Message]]:
|
|
88
|
+
"""Parse a JSONL session file into a Session + flat message list.
|
|
89
|
+
|
|
90
|
+
Session id resolution order:
|
|
91
|
+
1. If the filename stem is a valid UUID, use it.
|
|
92
|
+
2. Otherwise scan the file content for a sessionId field.
|
|
93
|
+
Raise ValueError if no valid UUID can be found.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
FileNotFoundError: if path does not exist
|
|
97
|
+
ValueError: if no valid UUID session ID is found
|
|
98
|
+
"""
|
|
99
|
+
if not path.exists():
|
|
100
|
+
raise FileNotFoundError(f"Session file not found: {path}")
|
|
101
|
+
|
|
102
|
+
# Derive session id: prefer filename UUID, fall back to content
|
|
103
|
+
try:
|
|
104
|
+
session_id = _derive_session_id(path)
|
|
105
|
+
except ValueError:
|
|
106
|
+
session_id = _session_id_from_content(path)
|
|
107
|
+
|
|
108
|
+
project_id = _unmangle_project_id(path.parent.name)
|
|
109
|
+
|
|
110
|
+
session = Session(
|
|
111
|
+
id=session_id,
|
|
112
|
+
project_id=project_id,
|
|
113
|
+
file_path=str(path),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
messages: list[Message] = []
|
|
117
|
+
cwd_set = False
|
|
118
|
+
git_branch_set = False
|
|
119
|
+
model_set = False
|
|
120
|
+
|
|
121
|
+
with path.open(encoding="utf-8") as fh:
|
|
122
|
+
for raw_line in fh:
|
|
123
|
+
raw_line = raw_line.strip()
|
|
124
|
+
if not raw_line:
|
|
125
|
+
continue
|
|
126
|
+
try:
|
|
127
|
+
record = json.loads(raw_line)
|
|
128
|
+
except json.JSONDecodeError:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
record_type = record.get("type")
|
|
132
|
+
|
|
133
|
+
# --- metadata records ---
|
|
134
|
+
if record_type == "permission-mode":
|
|
135
|
+
session.permission_mode = record.get("permissionMode")
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
if record_type == "custom-title":
|
|
139
|
+
session.title = record.get("customTitle")
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if record_type == "worktree-state":
|
|
143
|
+
session.is_worktree = True
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
if record_type in ("clau-decode-fork", "clau-decode-backup"):
|
|
147
|
+
session.is_fork = True
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
# --- mid-stream queued commands ---
|
|
151
|
+
# When the user types a message while the assistant is working, the session
|
|
152
|
+
# stores it as an "attachment" record with type "queued_command".
|
|
153
|
+
# We surface it as a regular user message so it appears in the thread.
|
|
154
|
+
if record_type == "attachment":
|
|
155
|
+
attachment = record.get("attachment", {})
|
|
156
|
+
if attachment.get("type") == "queued_command":
|
|
157
|
+
prompt = attachment.get("prompt", "")
|
|
158
|
+
# prompt is either a plain string or a list of content blocks
|
|
159
|
+
if isinstance(prompt, str):
|
|
160
|
+
blocks_qa = [TextBlock(text=prompt)] if prompt else []
|
|
161
|
+
elif isinstance(prompt, list):
|
|
162
|
+
blocks_qa = _parse_content_blocks(prompt)
|
|
163
|
+
else:
|
|
164
|
+
blocks_qa = []
|
|
165
|
+
if blocks_qa:
|
|
166
|
+
uuid = record.get("uuid", "")
|
|
167
|
+
parent_uuid = record.get("parentUuid")
|
|
168
|
+
timestamp_str = record.get("timestamp")
|
|
169
|
+
timestamp_qa: datetime | None = None
|
|
170
|
+
if timestamp_str:
|
|
171
|
+
try:
|
|
172
|
+
ts = timestamp_str
|
|
173
|
+
if ts.endswith("Z"):
|
|
174
|
+
ts = ts[:-1] + "+00:00"
|
|
175
|
+
timestamp_qa = datetime.fromisoformat(ts)
|
|
176
|
+
except ValueError:
|
|
177
|
+
pass
|
|
178
|
+
msg = Message(
|
|
179
|
+
id=uuid,
|
|
180
|
+
session_id=session_id,
|
|
181
|
+
parent_id=parent_uuid,
|
|
182
|
+
role="user",
|
|
183
|
+
content_blocks=blocks_qa,
|
|
184
|
+
timestamp=timestamp_qa,
|
|
185
|
+
is_sidechain=bool(record.get("isSidechain", False)),
|
|
186
|
+
is_meta=False,
|
|
187
|
+
cwd=record.get("cwd"),
|
|
188
|
+
git_branch=record.get("gitBranch"),
|
|
189
|
+
)
|
|
190
|
+
messages.append(msg)
|
|
191
|
+
if timestamp_qa:
|
|
192
|
+
if (
|
|
193
|
+
session.started_at is None
|
|
194
|
+
or timestamp_qa < session.started_at
|
|
195
|
+
):
|
|
196
|
+
session.started_at = timestamp_qa
|
|
197
|
+
if (
|
|
198
|
+
session.updated_at is None
|
|
199
|
+
or timestamp_qa > session.updated_at
|
|
200
|
+
):
|
|
201
|
+
session.updated_at = timestamp_qa
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# --- message records ---
|
|
205
|
+
if record_type not in ("user", "assistant"):
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
is_meta = bool(record.get("isMeta", False))
|
|
209
|
+
is_sidechain = bool(record.get("isSidechain", False))
|
|
210
|
+
uuid = record.get("uuid", "")
|
|
211
|
+
parent_uuid = record.get("parentUuid")
|
|
212
|
+
timestamp_str = record.get("timestamp")
|
|
213
|
+
timestamp: datetime | None = None
|
|
214
|
+
if timestamp_str:
|
|
215
|
+
# Handle trailing Z or offset
|
|
216
|
+
try:
|
|
217
|
+
ts = timestamp_str
|
|
218
|
+
if ts.endswith("Z"):
|
|
219
|
+
ts = ts[:-1] + "+00:00"
|
|
220
|
+
timestamp = datetime.fromisoformat(ts)
|
|
221
|
+
except ValueError:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
msg_cwd = record.get("cwd")
|
|
225
|
+
msg_git_branch = record.get("gitBranch")
|
|
226
|
+
request_id = record.get("requestId")
|
|
227
|
+
source_tool_uuid = record.get("sourceToolAssistantUUID")
|
|
228
|
+
|
|
229
|
+
# Collect cwd / git_branch from first record that has them
|
|
230
|
+
if not cwd_set and msg_cwd:
|
|
231
|
+
session.cwd = msg_cwd
|
|
232
|
+
cwd_set = True
|
|
233
|
+
if not git_branch_set and msg_git_branch:
|
|
234
|
+
session.git_branch = msg_git_branch
|
|
235
|
+
git_branch_set = True
|
|
236
|
+
|
|
237
|
+
# Parse content
|
|
238
|
+
if record_type == "assistant":
|
|
239
|
+
msg_record = record.get("message", {})
|
|
240
|
+
raw_content = msg_record.get("content", [])
|
|
241
|
+
msg_model = msg_record.get("model")
|
|
242
|
+
if not model_set and msg_model:
|
|
243
|
+
session.model = msg_model
|
|
244
|
+
model_set = True
|
|
245
|
+
content_blocks = _parse_content_blocks(raw_content)
|
|
246
|
+
raw_usage = msg_record.get("usage")
|
|
247
|
+
msg_usage: TokenUsage | None = None
|
|
248
|
+
if isinstance(raw_usage, dict):
|
|
249
|
+
msg_usage = TokenUsage(
|
|
250
|
+
input_tokens=raw_usage.get("input_tokens", 0),
|
|
251
|
+
output_tokens=raw_usage.get("output_tokens", 0),
|
|
252
|
+
cache_creation_input_tokens=raw_usage.get(
|
|
253
|
+
"cache_creation_input_tokens", 0
|
|
254
|
+
),
|
|
255
|
+
cache_read_input_tokens=raw_usage.get(
|
|
256
|
+
"cache_read_input_tokens", 0
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
msg = Message(
|
|
260
|
+
id=uuid,
|
|
261
|
+
session_id=session_id,
|
|
262
|
+
parent_id=parent_uuid,
|
|
263
|
+
role="assistant",
|
|
264
|
+
content_blocks=content_blocks,
|
|
265
|
+
timestamp=timestamp,
|
|
266
|
+
model=msg_model,
|
|
267
|
+
is_sidechain=is_sidechain,
|
|
268
|
+
is_meta=is_meta,
|
|
269
|
+
cwd=msg_cwd,
|
|
270
|
+
git_branch=msg_git_branch,
|
|
271
|
+
request_id=request_id,
|
|
272
|
+
source_tool_assistant_uuid=source_tool_uuid,
|
|
273
|
+
usage=msg_usage,
|
|
274
|
+
)
|
|
275
|
+
else:
|
|
276
|
+
# user record
|
|
277
|
+
msg_record = record.get("message", {})
|
|
278
|
+
raw_content = msg_record.get("content", [])
|
|
279
|
+
content_blocks = _parse_content_blocks(raw_content)
|
|
280
|
+
msg = Message(
|
|
281
|
+
id=uuid,
|
|
282
|
+
session_id=session_id,
|
|
283
|
+
parent_id=parent_uuid,
|
|
284
|
+
role="user",
|
|
285
|
+
content_blocks=content_blocks,
|
|
286
|
+
timestamp=timestamp,
|
|
287
|
+
is_sidechain=is_sidechain,
|
|
288
|
+
is_meta=is_meta,
|
|
289
|
+
cwd=msg_cwd,
|
|
290
|
+
git_branch=msg_git_branch,
|
|
291
|
+
request_id=request_id,
|
|
292
|
+
source_tool_assistant_uuid=source_tool_uuid,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
messages.append(msg)
|
|
296
|
+
|
|
297
|
+
# Track session timestamps
|
|
298
|
+
if timestamp:
|
|
299
|
+
if session.started_at is None or timestamp < session.started_at:
|
|
300
|
+
session.started_at = timestamp
|
|
301
|
+
if session.updated_at is None or timestamp > session.updated_at:
|
|
302
|
+
session.updated_at = timestamp
|
|
303
|
+
|
|
304
|
+
session.message_count = len(messages)
|
|
305
|
+
session.user_message_count = sum(
|
|
306
|
+
1 for m in messages if m.role == "user" and not m.is_meta
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Infer title from first user message when no custom-title record exists
|
|
310
|
+
if session.title is None:
|
|
311
|
+
session.title = _infer_title_from_messages(messages)
|
|
312
|
+
|
|
313
|
+
return session, messages
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def build_message_tree(messages: list[Message]) -> list[MessageTree]:
|
|
317
|
+
"""Convert a flat message list into a parent→child tree.
|
|
318
|
+
|
|
319
|
+
Root messages (parent_id=None, is_sidechain=False) are returned as the
|
|
320
|
+
top-level list. Sidechain branches hang off their parent as children.
|
|
321
|
+
"""
|
|
322
|
+
# Build node map
|
|
323
|
+
nodes: dict[str, MessageTree] = {m.id: MessageTree(message=m) for m in messages}
|
|
324
|
+
|
|
325
|
+
roots: list[MessageTree] = []
|
|
326
|
+
|
|
327
|
+
for m in messages:
|
|
328
|
+
node = nodes[m.id]
|
|
329
|
+
|
|
330
|
+
if m.is_sidechain:
|
|
331
|
+
# Sidechain: always attach to parent
|
|
332
|
+
if m.parent_id and m.parent_id in nodes:
|
|
333
|
+
nodes[m.parent_id].children.append(node)
|
|
334
|
+
# If parent not found, treat as orphan root (edge case)
|
|
335
|
+
else:
|
|
336
|
+
roots.append(node)
|
|
337
|
+
elif m.parent_id is None:
|
|
338
|
+
# True root: no parent, not sidechain
|
|
339
|
+
roots.append(node)
|
|
340
|
+
else:
|
|
341
|
+
# Normal child message with a parent
|
|
342
|
+
if m.parent_id in nodes:
|
|
343
|
+
nodes[m.parent_id].children.append(node)
|
|
344
|
+
else:
|
|
345
|
+
# Orphaned non-root: add to roots as fallback
|
|
346
|
+
roots.append(node)
|
|
347
|
+
|
|
348
|
+
# Sort children by timestamp within each node
|
|
349
|
+
def _sort_node(node: MessageTree) -> None:
|
|
350
|
+
node.children.sort(
|
|
351
|
+
key=lambda n: n.message.timestamp or datetime.min.replace(tzinfo=None)
|
|
352
|
+
)
|
|
353
|
+
for child in node.children:
|
|
354
|
+
_sort_node(child)
|
|
355
|
+
|
|
356
|
+
for root in roots:
|
|
357
|
+
_sort_node(root)
|
|
358
|
+
|
|
359
|
+
# Sort roots by timestamp
|
|
360
|
+
roots.sort(key=lambda n: n.message.timestamp or datetime.min.replace(tzinfo=None))
|
|
361
|
+
|
|
362
|
+
return roots
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
# Internal helpers — implement these, tests cover them individually
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _session_id_from_content(path: Path) -> str:
|
|
371
|
+
"""Scan the first few lines of a JSONL file for a valid UUID sessionId.
|
|
372
|
+
|
|
373
|
+
Raises ValueError if none is found.
|
|
374
|
+
"""
|
|
375
|
+
with path.open(encoding="utf-8") as fh:
|
|
376
|
+
for raw_line in fh:
|
|
377
|
+
raw_line = raw_line.strip()
|
|
378
|
+
if not raw_line:
|
|
379
|
+
continue
|
|
380
|
+
try:
|
|
381
|
+
record = json.loads(raw_line)
|
|
382
|
+
except json.JSONDecodeError:
|
|
383
|
+
continue
|
|
384
|
+
session_id = record.get("sessionId", "")
|
|
385
|
+
if session_id and _UUID_RE.match(str(session_id)):
|
|
386
|
+
return str(session_id)
|
|
387
|
+
raise ValueError(
|
|
388
|
+
f"No valid UUID sessionId found in '{path}'. "
|
|
389
|
+
"This does not appear to be a valid session file."
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _parse_content_blocks(raw_content: Any) -> list[ContentBlock]:
|
|
394
|
+
"""Convert raw message.content (str or list) into typed ContentBlock list."""
|
|
395
|
+
if isinstance(raw_content, str):
|
|
396
|
+
return [TextBlock(text=raw_content)]
|
|
397
|
+
|
|
398
|
+
if not isinstance(raw_content, list):
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
blocks: list[ContentBlock] = []
|
|
402
|
+
for item in raw_content:
|
|
403
|
+
if not isinstance(item, dict):
|
|
404
|
+
continue
|
|
405
|
+
block_type = item.get("type")
|
|
406
|
+
if block_type == "text":
|
|
407
|
+
blocks.append(TextBlock(text=item.get("text", "")))
|
|
408
|
+
elif block_type == "thinking":
|
|
409
|
+
blocks.append(
|
|
410
|
+
ThinkingBlock(
|
|
411
|
+
thinking=item.get("thinking", ""),
|
|
412
|
+
signature=item.get("signature"),
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
elif block_type == "tool_use":
|
|
416
|
+
blocks.append(
|
|
417
|
+
ToolUseBlock(
|
|
418
|
+
id=item.get("id", ""),
|
|
419
|
+
name=item.get("name", ""),
|
|
420
|
+
input=item.get("input", {}),
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
elif block_type == "tool_result":
|
|
424
|
+
blocks.append(
|
|
425
|
+
ToolResultBlock(
|
|
426
|
+
tool_use_id=item.get("tool_use_id", ""),
|
|
427
|
+
content=item.get("content"),
|
|
428
|
+
is_error=item.get("is_error", False),
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
elif block_type == "image":
|
|
432
|
+
blocks.append(ImageBlock(source=item.get("source", {})))
|
|
433
|
+
# Unknown types are silently skipped
|
|
434
|
+
|
|
435
|
+
return blocks
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _derive_session_id(path: Path) -> str:
|
|
439
|
+
"""Extract UUID string from filename, raise ValueError if not valid."""
|
|
440
|
+
stem = path.stem # filename without extension
|
|
441
|
+
if not _UUID_RE.match(stem):
|
|
442
|
+
raise ValueError(
|
|
443
|
+
f"Filename '{path.name}' is not a valid UUID session file. "
|
|
444
|
+
f"Expected pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.jsonl"
|
|
445
|
+
)
|
|
446
|
+
return stem
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _unmangle_project_id(directory_name: str) -> str:
|
|
450
|
+
"""Convert directory name like '-Volumes-ExternalDrive-Work-foo' → 'Volumes/ExternalDrive/Work/foo'.
|
|
451
|
+
|
|
452
|
+
Rules:
|
|
453
|
+
- Strip the leading '-'
|
|
454
|
+
- Double hyphens '--' become a literal '-' in the path
|
|
455
|
+
- Single hyphens '-' become '/'
|
|
456
|
+
"""
|
|
457
|
+
if directory_name.startswith("-"):
|
|
458
|
+
directory_name = directory_name[1:]
|
|
459
|
+
|
|
460
|
+
# Replace double hyphens with a placeholder, convert single hyphens to /,
|
|
461
|
+
# then restore the placeholder as literal hyphens.
|
|
462
|
+
_PLACEHOLDER = "\x00"
|
|
463
|
+
result = directory_name.replace("--", _PLACEHOLDER)
|
|
464
|
+
result = result.replace("-", "/")
|
|
465
|
+
result = result.replace(_PLACEHOLDER, "-")
|
|
466
|
+
return result
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Provider abstraction layer — adapters bridge external AI tools into clau-decode.
|
|
2
|
+
|
|
3
|
+
Each supported tool (Claude Code, Codex, etc.) ships as a ``ProviderAdapter``
|
|
4
|
+
subclass registered via ``providers.registry``. The server and scanner never
|
|
5
|
+
import concrete adapter modules directly; they work against the abstract seam
|
|
6
|
+
defined in ``providers.base``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from . import (
|
|
10
|
+
registry,
|
|
11
|
+
) # re-exported for convenience: ``from .providers import registry``
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register_builtins() -> None:
|
|
15
|
+
"""Register all built-in provider adapters (idempotent).
|
|
16
|
+
|
|
17
|
+
Call this once near application startup (e.g. inside ``create_app`` or
|
|
18
|
+
``_do_scan``) before iterating ``registry.all_adapters()``. Registering
|
|
19
|
+
the same adapter name twice simply overwrites the entry, so it is safe to
|
|
20
|
+
call multiple times.
|
|
21
|
+
|
|
22
|
+
Import is deferred inside the function body to avoid import-time side
|
|
23
|
+
effects and circular imports — concrete adapter modules may themselves
|
|
24
|
+
import from ``..models`` or ``..scanner``.
|
|
25
|
+
"""
|
|
26
|
+
from .claude import ClaudeAdapter
|
|
27
|
+
from .codex import CodexAdapter
|
|
28
|
+
|
|
29
|
+
registry.register(ClaudeAdapter())
|
|
30
|
+
registry.register(CodexAdapter())
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Abstract base classes for provider adapters.
|
|
2
|
+
|
|
3
|
+
A ``ProviderAdapter`` bridges an external AI tool's on-disk format into
|
|
4
|
+
clau-decode's domain model. Every adapter must declare its ``capabilities``
|
|
5
|
+
and implement the four operations the server uses:
|
|
6
|
+
|
|
7
|
+
configured_roots — which paths to scan, per-provider config knowledge
|
|
8
|
+
discover — async-generate (Project, session_file_path) pairs
|
|
9
|
+
parse — turn a session file into (Session, [Message])
|
|
10
|
+
owns_path — quick membership test for dispatcher routing
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import abc
|
|
16
|
+
from collections.abc import AsyncIterator
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from ..models import AppConfig, Message, Project, Session
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Capabilities descriptor
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProviderCaps(BaseModel):
|
|
30
|
+
"""Declares which interactive operations this provider supports.
|
|
31
|
+
|
|
32
|
+
The frontend (and server) check these before enabling UI affordances so
|
|
33
|
+
that read-only providers (e.g. an imported Codex export) never see a
|
|
34
|
+
send / resume / fork button.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
can_send: bool
|
|
38
|
+
can_resume: bool
|
|
39
|
+
can_fork: bool
|
|
40
|
+
can_edit: bool
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Abstract adapter
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProviderAdapter(abc.ABC):
|
|
49
|
+
"""Seam every concrete provider must implement.
|
|
50
|
+
|
|
51
|
+
Subclasses set the class attribute ``name`` to a short, unique identifier
|
|
52
|
+
(e.g. ``"claude"``, ``"codex"``). The registry keys on this string.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
name: str # e.g. "claude" — concrete subclasses assign this at class level
|
|
56
|
+
|
|
57
|
+
# -- Capability declaration -----------------------------------------------
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
@abc.abstractmethod
|
|
61
|
+
def capabilities(self) -> ProviderCaps:
|
|
62
|
+
"""Return this adapter's static capability descriptor."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
# -- Config-aware root resolution -----------------------------------------
|
|
66
|
+
|
|
67
|
+
@abc.abstractmethod
|
|
68
|
+
def configured_roots(self, config: AppConfig) -> list[Path]:
|
|
69
|
+
"""Return the expanded root directories this provider should scan.
|
|
70
|
+
|
|
71
|
+
Centralises the knowledge of which ``AppConfig`` fields belong to this
|
|
72
|
+
provider so callers never need to know the field names.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
config: The live application configuration.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Fully-expanded ``Path`` objects (no ``~`` / env vars).
|
|
79
|
+
"""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
# -- Discovery (async generator) ------------------------------------------
|
|
83
|
+
|
|
84
|
+
@abc.abstractmethod
|
|
85
|
+
async def discover(self, roots: list[Path]) -> AsyncIterator[tuple[Project, Path]]:
|
|
86
|
+
"""Yield ``(Project, session_file_path)`` pairs found under *roots*.
|
|
87
|
+
|
|
88
|
+
Mirrors the contract of ``scanner.scan_paths``. Declared as an async
|
|
89
|
+
generator so I/O can be interleaved with DB writes in the scan loop.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
roots: Expanded root directories to walk.
|
|
93
|
+
|
|
94
|
+
Yields:
|
|
95
|
+
Two-tuples of ``(Project, Path)`` where ``Path`` points to an
|
|
96
|
+
individual session file.
|
|
97
|
+
"""
|
|
98
|
+
# Abstract async generators must have a body. Subclasses override
|
|
99
|
+
# the entire method; this line is never reached.
|
|
100
|
+
raise NotImplementedError # pragma: no cover
|
|
101
|
+
yield # makes Python treat this as a generator function
|
|
102
|
+
|
|
103
|
+
# -- Parsing (synchronous) ------------------------------------------------
|
|
104
|
+
|
|
105
|
+
@abc.abstractmethod
|
|
106
|
+
def parse(self, path: Path) -> tuple[Session, list[Message]]:
|
|
107
|
+
"""Parse *path* into a ``(Session, messages)`` pair.
|
|
108
|
+
|
|
109
|
+
Intentionally synchronous — callers wrap this in
|
|
110
|
+
``asyncio.to_thread`` when they need non-blocking behaviour.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
path: Absolute path to a session file.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
``(Session, list[Message])`` populated from the file contents.
|
|
117
|
+
"""
|
|
118
|
+
...
|
|
119
|
+
|
|
120
|
+
# -- Path ownership -------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
@abc.abstractmethod
|
|
123
|
+
def owns_path(self, path: Path) -> bool:
|
|
124
|
+
"""Return True if this adapter recognises and can parse *path*.
|
|
125
|
+
|
|
126
|
+
Used by the registry dispatcher to route incoming file paths to the
|
|
127
|
+
correct adapter without opening the file.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
path: Absolute path to a candidate session file.
|
|
131
|
+
"""
|
|
132
|
+
...
|