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,81 @@
|
|
|
1
|
+
"""Claude Code provider adapter.
|
|
2
|
+
|
|
3
|
+
Wraps the existing ``scanner.scan_paths`` and ``parser.parse_session``
|
|
4
|
+
implementations behind the ``ProviderAdapter`` seam. No scanning or parsing
|
|
5
|
+
logic lives here — this is pure delegation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ..models import AppConfig, Message, Project, Session
|
|
14
|
+
from .base import ProviderAdapter, ProviderCaps
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClaudeAdapter(ProviderAdapter):
|
|
18
|
+
"""Adapter for Claude Code's on-disk JSONL session format.
|
|
19
|
+
|
|
20
|
+
Session layout::
|
|
21
|
+
|
|
22
|
+
<root>/projects/<mangled-dir>/<uuid>.jsonl
|
|
23
|
+
|
|
24
|
+
All four interactive operations (send / resume / fork / edit) are
|
|
25
|
+
supported because Claude Code exposes a full PTY interface.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
name = "claude"
|
|
29
|
+
|
|
30
|
+
# -- Capability declaration -----------------------------------------------
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def capabilities(self) -> ProviderCaps:
|
|
34
|
+
return ProviderCaps(
|
|
35
|
+
can_send=True, can_resume=True, can_fork=True, can_edit=True
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# -- Config-aware root resolution -----------------------------------------
|
|
39
|
+
|
|
40
|
+
def configured_roots(self, config: AppConfig) -> list[Path]:
|
|
41
|
+
"""Return the expanded root directories configured for Claude Code.
|
|
42
|
+
|
|
43
|
+
Mirrors ``server._all_scan_roots()`` — reads ``config.get_all_scan_paths()``
|
|
44
|
+
and expands ``~`` / env vars.
|
|
45
|
+
"""
|
|
46
|
+
return [Path(p).expanduser() for p in config.get_all_scan_paths()]
|
|
47
|
+
|
|
48
|
+
# -- Discovery (async generator) ------------------------------------------
|
|
49
|
+
|
|
50
|
+
async def discover(self, roots: list[Path]) -> AsyncIterator[tuple[Project, Path]]:
|
|
51
|
+
"""Yield ``(Project, session_file_path)`` by delegating to ``scan_paths``."""
|
|
52
|
+
from ..scanner import scan_paths
|
|
53
|
+
|
|
54
|
+
async for item in scan_paths(roots):
|
|
55
|
+
yield item
|
|
56
|
+
|
|
57
|
+
# -- Parsing (synchronous) ------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def parse(self, path: Path) -> tuple[Session, list[Message]]:
|
|
60
|
+
"""Parse *path* by delegating to ``parse_session``.
|
|
61
|
+
|
|
62
|
+
Sets ``session.provider = "claude"`` explicitly even though
|
|
63
|
+
``Session`` already defaults to ``"claude"``, so the field is
|
|
64
|
+
always visibly owned by this adapter.
|
|
65
|
+
"""
|
|
66
|
+
from ..parser import parse_session
|
|
67
|
+
|
|
68
|
+
session, messages = parse_session(path)
|
|
69
|
+
session.provider = "claude"
|
|
70
|
+
return session, messages
|
|
71
|
+
|
|
72
|
+
# -- Path ownership -------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def owns_path(self, path: Path) -> bool:
|
|
75
|
+
"""Return True for ``.jsonl`` files nested under a ``projects/`` directory.
|
|
76
|
+
|
|
77
|
+
Claude sessions live at ``<root>/projects/<mangled-dir>/<uuid>.jsonl``.
|
|
78
|
+
Codex sessions live under ``.codex/sessions/…`` (no ``projects/`` dir),
|
|
79
|
+
so these two sets are disjoint.
|
|
80
|
+
"""
|
|
81
|
+
return path.suffix == ".jsonl" and "projects" in path.parts
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""OpenAI Codex CLI provider adapter (read-only, Phase 2).
|
|
2
|
+
|
|
3
|
+
Decodes Codex rollout JSONL files (rollout-*.jsonl) into clau-decode's
|
|
4
|
+
normalized domain model. Interactive operations (send / resume / fork /
|
|
5
|
+
edit) are disabled in this phase; Phase 4 will flip can_send / can_resume.
|
|
6
|
+
|
|
7
|
+
Rollout file structure (one JSON object per line, each with
|
|
8
|
+
``{"timestamp", "type", "payload"}``):
|
|
9
|
+
|
|
10
|
+
session_meta — session id, cwd, git branch, originator
|
|
11
|
+
turn_context — model name (first one wins)
|
|
12
|
+
response_item — the canonical message content (type in {message,
|
|
13
|
+
reasoning, function_call, function_call_output})
|
|
14
|
+
event_msg — UI telemetry; only token_count + task_complete are
|
|
15
|
+
ingested; the rest are ignored
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
from collections.abc import AsyncIterator
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from ..models import (
|
|
28
|
+
AppConfig,
|
|
29
|
+
Message,
|
|
30
|
+
Project,
|
|
31
|
+
Session,
|
|
32
|
+
TextBlock,
|
|
33
|
+
ThinkingBlock,
|
|
34
|
+
TokenUsage,
|
|
35
|
+
ToolResultBlock,
|
|
36
|
+
ToolUseBlock,
|
|
37
|
+
)
|
|
38
|
+
from ..parser import _infer_title_from_messages
|
|
39
|
+
from .base import ProviderAdapter, ProviderCaps
|
|
40
|
+
|
|
41
|
+
# Placeholder text emitted for encrypted reasoning blocks.
|
|
42
|
+
# The real encrypted_content is NEVER read or stored.
|
|
43
|
+
_REASONING_PLACEHOLDER = "🔒 Reasoning (encrypted)"
|
|
44
|
+
|
|
45
|
+
# Codex injects environment/state context as ``user``-role messages whose text
|
|
46
|
+
# is an XML wrapper (no human prose) — the actual user prompt arrives in a
|
|
47
|
+
# later user message. We flag these as ``is_meta`` so they're excluded from
|
|
48
|
+
# title inference and the user-message count, mirroring how the Claude parser
|
|
49
|
+
# marks injected context. Detection: the message text begins with one of these
|
|
50
|
+
# wrapper tags. ``<turn_aborted>`` is Codex's interruption note (also meta).
|
|
51
|
+
_CODEX_META_WRAPPERS = (
|
|
52
|
+
"<environment_context",
|
|
53
|
+
"<workspace_roots",
|
|
54
|
+
"<permission_profile",
|
|
55
|
+
"<user_instructions",
|
|
56
|
+
"<turn_aborted",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_injected_context(blocks: list[TextBlock]) -> bool:
|
|
61
|
+
"""True if a user message is Codex-injected context rather than a prompt."""
|
|
62
|
+
text = "".join(b.text for b in blocks).lstrip()
|
|
63
|
+
return text.startswith(_CODEX_META_WRAPPERS)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Project helper
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _codex_project(cwd: str | None, data_source: str) -> Project:
|
|
72
|
+
"""Build a Project from a Codex session's working directory.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
cwd: The ``cwd`` field from ``session_meta.payload``, or
|
|
76
|
+
``None`` if the field was absent.
|
|
77
|
+
data_source: The configured root path this session was found under.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A Project with a stable 16-hex-char id and human-readable display_name.
|
|
81
|
+
"""
|
|
82
|
+
if cwd:
|
|
83
|
+
project_id = hashlib.sha256(
|
|
84
|
+
f"{cwd}\0{data_source}\0codex".encode()
|
|
85
|
+
).hexdigest()[:16]
|
|
86
|
+
parts = [p for p in cwd.split("/") if p]
|
|
87
|
+
if len(parts) >= 2:
|
|
88
|
+
display_name = "/".join(parts[-2:])
|
|
89
|
+
elif parts:
|
|
90
|
+
display_name = parts[0]
|
|
91
|
+
else:
|
|
92
|
+
display_name = cwd
|
|
93
|
+
resolved: str | None = cwd if Path(cwd).exists() else None
|
|
94
|
+
return Project(
|
|
95
|
+
id=project_id,
|
|
96
|
+
display_name=display_name,
|
|
97
|
+
raw_path=cwd,
|
|
98
|
+
resolved_path=resolved,
|
|
99
|
+
data_source=data_source,
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
sentinel = "codex/(no project)"
|
|
103
|
+
project_id = hashlib.sha256(
|
|
104
|
+
f"{sentinel}\0{data_source}\0codex".encode()
|
|
105
|
+
).hexdigest()[:16]
|
|
106
|
+
return Project(
|
|
107
|
+
id=project_id,
|
|
108
|
+
display_name=sentinel,
|
|
109
|
+
raw_path=sentinel,
|
|
110
|
+
resolved_path=None,
|
|
111
|
+
data_source=data_source,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Timestamp helper
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parse_ts(ts: str) -> datetime:
|
|
121
|
+
"""Parse an ISO-8601 timestamp string, handling trailing ``Z``."""
|
|
122
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Adapter
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class CodexAdapter(ProviderAdapter):
|
|
131
|
+
"""Adapter for OpenAI Codex CLI rollout JSONL files (read-only).
|
|
132
|
+
|
|
133
|
+
Session layout::
|
|
134
|
+
|
|
135
|
+
<root>/**/rollout-<id>.jsonl
|
|
136
|
+
|
|
137
|
+
All four interactive operations are disabled (read-only Phase 2).
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
name = "codex"
|
|
141
|
+
|
|
142
|
+
# -- Capability declaration -----------------------------------------------
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def capabilities(self) -> ProviderCaps:
|
|
146
|
+
# Phase 4e: Codex is drivable (send + resume) via the tmux ProviderDriver.
|
|
147
|
+
# These are STATIC caps; the server reconciles them with runtime
|
|
148
|
+
# availability (``_effective_caps``) so a box without tmux/codex still
|
|
149
|
+
# degrades to read-only. fork/edit stay False — no flow exists yet and
|
|
150
|
+
# the Claude-JSONL editor would corrupt a Codex rollout.
|
|
151
|
+
return ProviderCaps(
|
|
152
|
+
can_send=True, can_resume=True, can_fork=False, can_edit=False
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# -- Config-aware root resolution -----------------------------------------
|
|
156
|
+
|
|
157
|
+
def configured_roots(self, config: AppConfig) -> list[Path]:
|
|
158
|
+
"""Return the expanded root directories configured for Codex sessions."""
|
|
159
|
+
return [Path(p).expanduser() for p in config.codex_data_paths]
|
|
160
|
+
|
|
161
|
+
# -- Path ownership -------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def owns_path(self, path: Path) -> bool:
|
|
164
|
+
"""Return True for ``rollout-*.jsonl`` files."""
|
|
165
|
+
return path.suffix == ".jsonl" and path.name.startswith("rollout-")
|
|
166
|
+
|
|
167
|
+
# -- Discovery (async generator) ------------------------------------------
|
|
168
|
+
|
|
169
|
+
async def discover(self, roots: list[Path]) -> AsyncIterator[tuple[Project, Path]]:
|
|
170
|
+
"""Yield ``(Project, session_file_path)`` pairs found under *roots*.
|
|
171
|
+
|
|
172
|
+
Reads only the first non-empty line of each file to extract the cwd;
|
|
173
|
+
no full parse is performed during discovery.
|
|
174
|
+
"""
|
|
175
|
+
for root in roots:
|
|
176
|
+
if not root.exists():
|
|
177
|
+
continue
|
|
178
|
+
for path in sorted(root.rglob("rollout-*.jsonl")):
|
|
179
|
+
cwd: str | None = None
|
|
180
|
+
try:
|
|
181
|
+
with path.open(encoding="utf-8") as fh:
|
|
182
|
+
for raw in fh:
|
|
183
|
+
raw = raw.strip()
|
|
184
|
+
if not raw:
|
|
185
|
+
continue
|
|
186
|
+
record: dict[str, Any] = json.loads(raw)
|
|
187
|
+
if record.get("type") == "session_meta":
|
|
188
|
+
cwd = record.get("payload", {}).get("cwd")
|
|
189
|
+
break # only the first non-empty line
|
|
190
|
+
except (OSError, json.JSONDecodeError):
|
|
191
|
+
pass
|
|
192
|
+
project = _codex_project(cwd, str(root))
|
|
193
|
+
yield project, path
|
|
194
|
+
|
|
195
|
+
# -- Parsing (synchronous) ------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def parse(self, path: Path) -> tuple[Session, list[Message]]:
|
|
198
|
+
"""Parse a Codex rollout JSONL file into ``(Session, list[Message])``.
|
|
199
|
+
|
|
200
|
+
Builds messages from ``response_item`` records only. ``event_msg``
|
|
201
|
+
records are used for token usage (``token_count``) and fallback title
|
|
202
|
+
(``task_complete``). Reasoning blocks carry a placeholder instead of
|
|
203
|
+
the encrypted content.
|
|
204
|
+
|
|
205
|
+
Linear ``id``/``parent_id`` chains are synthesised so that
|
|
206
|
+
``build_message_tree`` yields a single flat chronological thread.
|
|
207
|
+
"""
|
|
208
|
+
session_id: str | None = None
|
|
209
|
+
cwd: str | None = None
|
|
210
|
+
git_branch: str | None = None
|
|
211
|
+
session_started_at: datetime | None = None
|
|
212
|
+
model: str | None = None
|
|
213
|
+
last_usage: TokenUsage | None = None
|
|
214
|
+
fallback_title: str | None = None
|
|
215
|
+
|
|
216
|
+
# Pending assistant turn state
|
|
217
|
+
_turn_blocks: list[Any] = []
|
|
218
|
+
_turn_ts: datetime | None = None
|
|
219
|
+
_turn_has_reasoning: bool = False
|
|
220
|
+
|
|
221
|
+
messages: list[Message] = []
|
|
222
|
+
|
|
223
|
+
def flush() -> None:
|
|
224
|
+
"""Finalise the open assistant turn into one Message."""
|
|
225
|
+
nonlocal _turn_blocks, _turn_ts, _turn_has_reasoning
|
|
226
|
+
if not _turn_blocks:
|
|
227
|
+
return
|
|
228
|
+
msg = Message(
|
|
229
|
+
id="__placeholder__", # replaced after all messages collected
|
|
230
|
+
session_id=session_id or "",
|
|
231
|
+
role="assistant",
|
|
232
|
+
content_blocks=list(_turn_blocks),
|
|
233
|
+
timestamp=_turn_ts,
|
|
234
|
+
model=model,
|
|
235
|
+
usage=last_usage,
|
|
236
|
+
provider="codex",
|
|
237
|
+
)
|
|
238
|
+
messages.append(msg)
|
|
239
|
+
_turn_blocks = []
|
|
240
|
+
_turn_ts = None
|
|
241
|
+
_turn_has_reasoning = False
|
|
242
|
+
|
|
243
|
+
def open_turn(ts: datetime) -> None:
|
|
244
|
+
"""Start an assistant turn if none is open."""
|
|
245
|
+
nonlocal _turn_ts
|
|
246
|
+
if _turn_ts is None:
|
|
247
|
+
_turn_ts = ts
|
|
248
|
+
|
|
249
|
+
with path.open(encoding="utf-8") as fh:
|
|
250
|
+
for raw_line in fh:
|
|
251
|
+
raw_line = raw_line.strip()
|
|
252
|
+
if not raw_line:
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
record: dict[str, Any] = json.loads(raw_line)
|
|
256
|
+
except json.JSONDecodeError:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
rec_type = record.get("type")
|
|
260
|
+
payload: dict[str, Any] = record.get("payload", {}) or {}
|
|
261
|
+
ts_str: str = record.get("timestamp", "")
|
|
262
|
+
ts: datetime | None = None
|
|
263
|
+
if ts_str:
|
|
264
|
+
try:
|
|
265
|
+
ts = _parse_ts(ts_str)
|
|
266
|
+
except ValueError:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
# ── session_meta ───────────────────────────────────────────
|
|
270
|
+
if rec_type == "session_meta":
|
|
271
|
+
session_id = payload.get("id")
|
|
272
|
+
cwd = payload.get("cwd")
|
|
273
|
+
git_branch = (payload.get("git") or {}).get("branch")
|
|
274
|
+
meta_ts_str = payload.get("timestamp", "")
|
|
275
|
+
if meta_ts_str:
|
|
276
|
+
try:
|
|
277
|
+
session_started_at = _parse_ts(meta_ts_str)
|
|
278
|
+
except ValueError:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
# ── turn_context ───────────────────────────────────────────
|
|
282
|
+
elif rec_type == "turn_context":
|
|
283
|
+
if model is None:
|
|
284
|
+
model = payload.get("model")
|
|
285
|
+
|
|
286
|
+
# ── response_item ──────────────────────────────────────────
|
|
287
|
+
elif rec_type == "response_item":
|
|
288
|
+
item_type = payload.get("type")
|
|
289
|
+
|
|
290
|
+
if item_type == "message":
|
|
291
|
+
role = payload.get("role", "")
|
|
292
|
+
content = payload.get("content") or []
|
|
293
|
+
|
|
294
|
+
if role == "user":
|
|
295
|
+
flush()
|
|
296
|
+
blocks = [
|
|
297
|
+
TextBlock(text=c["text"])
|
|
298
|
+
for c in content
|
|
299
|
+
if c.get("type") == "input_text"
|
|
300
|
+
]
|
|
301
|
+
msg = Message(
|
|
302
|
+
id="__placeholder__",
|
|
303
|
+
session_id=session_id or "",
|
|
304
|
+
role="user",
|
|
305
|
+
is_meta=_is_injected_context(blocks),
|
|
306
|
+
content_blocks=blocks,
|
|
307
|
+
timestamp=ts,
|
|
308
|
+
provider="codex",
|
|
309
|
+
)
|
|
310
|
+
messages.append(msg)
|
|
311
|
+
|
|
312
|
+
elif role == "developer":
|
|
313
|
+
flush()
|
|
314
|
+
blocks = [
|
|
315
|
+
TextBlock(text=c["text"])
|
|
316
|
+
for c in content
|
|
317
|
+
if c.get("type") == "input_text"
|
|
318
|
+
]
|
|
319
|
+
msg = Message(
|
|
320
|
+
id="__placeholder__",
|
|
321
|
+
session_id=session_id or "",
|
|
322
|
+
role="user",
|
|
323
|
+
is_meta=True,
|
|
324
|
+
content_blocks=blocks,
|
|
325
|
+
timestamp=ts,
|
|
326
|
+
provider="codex",
|
|
327
|
+
)
|
|
328
|
+
messages.append(msg)
|
|
329
|
+
|
|
330
|
+
elif role == "assistant":
|
|
331
|
+
if ts is not None:
|
|
332
|
+
open_turn(ts)
|
|
333
|
+
for c in content:
|
|
334
|
+
if c.get("type") == "output_text":
|
|
335
|
+
_turn_blocks.append(TextBlock(text=c["text"]))
|
|
336
|
+
|
|
337
|
+
elif item_type == "reasoning":
|
|
338
|
+
if ts is not None:
|
|
339
|
+
open_turn(ts)
|
|
340
|
+
if not _turn_has_reasoning:
|
|
341
|
+
_turn_blocks.append(
|
|
342
|
+
ThinkingBlock(thinking=_REASONING_PLACEHOLDER)
|
|
343
|
+
)
|
|
344
|
+
_turn_has_reasoning = True
|
|
345
|
+
|
|
346
|
+
elif item_type == "function_call":
|
|
347
|
+
if ts is not None:
|
|
348
|
+
open_turn(ts)
|
|
349
|
+
call_id: str = payload.get("call_id", "")
|
|
350
|
+
name: str = payload.get("name", "")
|
|
351
|
+
arguments: str = payload.get("arguments", "")
|
|
352
|
+
try:
|
|
353
|
+
parsed_args = json.loads(arguments)
|
|
354
|
+
if not isinstance(parsed_args, dict):
|
|
355
|
+
raise ValueError("not a dict")
|
|
356
|
+
except (json.JSONDecodeError, ValueError):
|
|
357
|
+
parsed_args = {"_raw": arguments}
|
|
358
|
+
_turn_blocks.append(
|
|
359
|
+
ToolUseBlock(id=call_id, name=name, input=parsed_args)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
elif item_type == "function_call_output":
|
|
363
|
+
if ts is not None:
|
|
364
|
+
open_turn(ts)
|
|
365
|
+
call_id = payload.get("call_id", "")
|
|
366
|
+
output: str = payload.get("output", "")
|
|
367
|
+
_turn_blocks.append(
|
|
368
|
+
ToolResultBlock(tool_use_id=call_id, content=output)
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# ── event_msg ──────────────────────────────────────────────
|
|
372
|
+
elif rec_type == "event_msg":
|
|
373
|
+
ev_type = payload.get("type")
|
|
374
|
+
|
|
375
|
+
if ev_type == "token_count":
|
|
376
|
+
info = payload.get("info")
|
|
377
|
+
if isinstance(info, dict):
|
|
378
|
+
ltu = info.get("last_token_usage")
|
|
379
|
+
if isinstance(ltu, dict):
|
|
380
|
+
last_usage = TokenUsage(
|
|
381
|
+
input_tokens=ltu.get("input_tokens", 0),
|
|
382
|
+
cache_read_input_tokens=ltu.get(
|
|
383
|
+
"cached_input_tokens", 0
|
|
384
|
+
),
|
|
385
|
+
output_tokens=ltu.get("output_tokens", 0),
|
|
386
|
+
cache_creation_input_tokens=0,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
elif ev_type == "task_complete":
|
|
390
|
+
msg_text = payload.get("last_agent_message")
|
|
391
|
+
if msg_text:
|
|
392
|
+
fallback_title = msg_text
|
|
393
|
+
|
|
394
|
+
# Finalise any open assistant turn
|
|
395
|
+
flush()
|
|
396
|
+
|
|
397
|
+
# Assign linear ids and parent_id chain
|
|
398
|
+
if not session_id:
|
|
399
|
+
session_id = path.stem # last resort: use filename stem
|
|
400
|
+
|
|
401
|
+
for i, msg in enumerate(messages):
|
|
402
|
+
msg.id = f"{session_id}-{i:04d}"
|
|
403
|
+
msg.session_id = session_id
|
|
404
|
+
msg.parent_id = messages[i - 1].id if i > 0 else None
|
|
405
|
+
|
|
406
|
+
# Build session timestamps (min/max over message timestamps)
|
|
407
|
+
all_ts = [m.timestamp for m in messages if m.timestamp is not None]
|
|
408
|
+
if session_started_at is not None:
|
|
409
|
+
all_ts.append(session_started_at)
|
|
410
|
+
|
|
411
|
+
started_at: datetime | None = session_started_at
|
|
412
|
+
updated_at: datetime | None = None
|
|
413
|
+
if all_ts:
|
|
414
|
+
started_at = min(all_ts)
|
|
415
|
+
updated_at = max(all_ts)
|
|
416
|
+
|
|
417
|
+
# Build project for project_id (best-effort; server stamps authoritative id)
|
|
418
|
+
project = _codex_project(cwd, "")
|
|
419
|
+
|
|
420
|
+
session = Session(
|
|
421
|
+
id=session_id,
|
|
422
|
+
project_id=project.id,
|
|
423
|
+
file_path=str(path),
|
|
424
|
+
cwd=cwd,
|
|
425
|
+
git_branch=git_branch,
|
|
426
|
+
model=model,
|
|
427
|
+
started_at=started_at,
|
|
428
|
+
updated_at=updated_at,
|
|
429
|
+
provider="codex",
|
|
430
|
+
message_count=len(messages),
|
|
431
|
+
user_message_count=sum(
|
|
432
|
+
1 for m in messages if m.role == "user" and not m.is_meta
|
|
433
|
+
),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Title: infer from first user message, fallback to task_complete, then hardcoded
|
|
437
|
+
session.title = (
|
|
438
|
+
_infer_title_from_messages(messages) or fallback_title or "Codex session"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
return session, messages
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Provider registry — runtime catalogue of registered ``ProviderAdapter`` instances.
|
|
2
|
+
|
|
3
|
+
Registration is explicit (call ``register(adapter)`` at import time or in an
|
|
4
|
+
app init hook). The registry is a plain module-level ordered dict so there
|
|
5
|
+
are no hidden singletons and tests can ``clear()`` between cases.
|
|
6
|
+
|
|
7
|
+
Typical usage::
|
|
8
|
+
|
|
9
|
+
from clau_decode.providers import registry
|
|
10
|
+
from clau_decode.providers.claude import ClaudeAdapter
|
|
11
|
+
|
|
12
|
+
registry.register(ClaudeAdapter())
|
|
13
|
+
|
|
14
|
+
# Dispatch by name:
|
|
15
|
+
adapter = registry.get("claude")
|
|
16
|
+
|
|
17
|
+
# Dispatch by file path (first adapter whose owns_path returns True):
|
|
18
|
+
adapter = registry.adapter_for_path(Path("/home/user/.claude/projects/…/sess.jsonl"))
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .base import ProviderAdapter
|
|
26
|
+
|
|
27
|
+
# Ordered dict keyed by adapter.name. Insertion order is preserved (Python
|
|
28
|
+
# 3.7+) and exposed via all_adapters().
|
|
29
|
+
_registry: dict[str, ProviderAdapter] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register(adapter: ProviderAdapter) -> None:
|
|
33
|
+
"""Register *adapter* under ``adapter.name``, replacing any prior entry.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
adapter: A concrete ``ProviderAdapter`` instance.
|
|
37
|
+
"""
|
|
38
|
+
_registry[adapter.name] = adapter
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get(name: str) -> ProviderAdapter:
|
|
42
|
+
"""Return the adapter registered under *name*.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
name: The short provider identifier (e.g. ``"claude"``).
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
KeyError: If no adapter with that name has been registered.
|
|
49
|
+
"""
|
|
50
|
+
if name not in _registry:
|
|
51
|
+
registered = list(_registry.keys())
|
|
52
|
+
raise KeyError(
|
|
53
|
+
f"No provider adapter registered for {name!r}. "
|
|
54
|
+
f"Registered providers: {registered}"
|
|
55
|
+
)
|
|
56
|
+
return _registry[name]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def all_adapters() -> list[ProviderAdapter]:
|
|
60
|
+
"""Return all registered adapters in insertion order."""
|
|
61
|
+
return list(_registry.values())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def adapter_for_path(path: Path) -> ProviderAdapter | None:
|
|
65
|
+
"""Return the first adapter that claims ownership of *path*, or ``None``.
|
|
66
|
+
|
|
67
|
+
Iterates in insertion order so registration order determines priority when
|
|
68
|
+
multiple adapters could theoretically match the same path.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
path: Absolute path to a candidate session file.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The first ``ProviderAdapter`` whose ``owns_path(path)`` returns True,
|
|
75
|
+
or ``None`` if no registered adapter claims it.
|
|
76
|
+
"""
|
|
77
|
+
for adapter in _registry.values():
|
|
78
|
+
if adapter.owns_path(path):
|
|
79
|
+
return adapter
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def clear() -> None:
|
|
84
|
+
"""Remove all registered adapters.
|
|
85
|
+
|
|
86
|
+
Intended for use in test teardown so registry state does not leak between
|
|
87
|
+
test cases.
|
|
88
|
+
"""
|
|
89
|
+
_registry.clear()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def decode_terminal_input(data: str) -> bytes:
|
|
8
|
+
return data.encode("utf-8", errors="surrogatepass")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def encode_pty_output_chunk(session_id: str, chunk: bytes) -> dict[str, Any]:
|
|
12
|
+
return {
|
|
13
|
+
"type": "pty_output_chunk",
|
|
14
|
+
"session_id": session_id,
|
|
15
|
+
"data_b64": base64.b64encode(chunk).decode("ascii"),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def encode_pty_snapshot(
|
|
20
|
+
*,
|
|
21
|
+
session_id: str,
|
|
22
|
+
ring: bytes,
|
|
23
|
+
ring_complete: bool,
|
|
24
|
+
rows: int,
|
|
25
|
+
cols: int,
|
|
26
|
+
alive: bool,
|
|
27
|
+
native_state: str,
|
|
28
|
+
decoded_input_safe: bool,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"session_id": session_id,
|
|
32
|
+
"ring_b64": base64.b64encode(ring).decode("ascii"),
|
|
33
|
+
"ring_complete": ring_complete,
|
|
34
|
+
"rows": rows,
|
|
35
|
+
"cols": cols,
|
|
36
|
+
"alive": alive,
|
|
37
|
+
"native_state": native_state,
|
|
38
|
+
"decoded_input_safe": decoded_input_safe,
|
|
39
|
+
}
|