scrollback 0.1.0__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.
- scrollback/__init__.py +8 -0
- scrollback/assets/icon-256.png +0 -0
- scrollback/assets/icon.icns +0 -0
- scrollback/cli.py +1139 -0
- scrollback/clipboard.py +34 -0
- scrollback/export.py +293 -0
- scrollback/fts.py +307 -0
- scrollback/highlight.py +128 -0
- scrollback/katexbundle.py +81 -0
- scrollback/launcher_install.py +209 -0
- scrollback/launchers/scrollback.bat +19 -0
- scrollback/launchers/scrollback.command +19 -0
- scrollback/launchers/scrollback.desktop +10 -0
- scrollback/launchers/scrollback.sh +12 -0
- scrollback/mathspan.py +180 -0
- scrollback/minimd.py +205 -0
- scrollback/models.py +135 -0
- scrollback/serialize.py +83 -0
- scrollback/serverconfig.py +66 -0
- scrollback/sources/__init__.py +6 -0
- scrollback/sources/aider.py +244 -0
- scrollback/sources/base.py +117 -0
- scrollback/sources/claudecode.py +631 -0
- scrollback/sources/codex.py +281 -0
- scrollback/sources/opencode.py +357 -0
- scrollback/sources/registry.py +39 -0
- scrollback/store.py +384 -0
- scrollback/termrender.py +170 -0
- scrollback/web/__init__.py +1 -0
- scrollback/web/app.py +359 -0
- scrollback/web/static/app.js +1245 -0
- scrollback/web/static/apple-touch-icon.png +0 -0
- scrollback/web/static/favicon.png +0 -0
- scrollback/web/static/favicon.svg +41 -0
- scrollback/web/static/index.html +75 -0
- scrollback/web/static/style.css +628 -0
- scrollback/web/static/vendor/highlight.min.js +1213 -0
- scrollback/web/static/vendor/hljs-dark.min.css +10 -0
- scrollback/web/static/vendor/hljs-light.min.css +10 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/katex.min.css +1 -0
- scrollback/web/static/vendor/katex/katex.min.js +1 -0
- scrollback/web/static/vendor/marked.min.js +6 -0
- scrollback/web/static/vendor/purify.min.js +3 -0
- scrollback/webopen.py +96 -0
- scrollback-0.1.0.dist-info/METADATA +391 -0
- scrollback-0.1.0.dist-info/RECORD +69 -0
- scrollback-0.1.0.dist-info/WHEEL +4 -0
- scrollback-0.1.0.dist-info/entry_points.txt +4 -0
- scrollback-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Codex CLI source adapter (read-only JSONL rollouts).
|
|
2
|
+
|
|
3
|
+
Codex (OpenAI's terminal coding agent) records each session as a "rollout"
|
|
4
|
+
JSONL file under::
|
|
5
|
+
|
|
6
|
+
~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<timestamp>-<uuid>.jsonl
|
|
7
|
+
|
|
8
|
+
Each line is a JSON event. The first line is typically a session-meta
|
|
9
|
+
record (``{"type": "session_meta", ...}`` or a payload carrying ``id`` /
|
|
10
|
+
``cwd`` / ``timestamp`` / ``instructions``); subsequent lines are response
|
|
11
|
+
items, of which the conversational ones look like::
|
|
12
|
+
|
|
13
|
+
{"type": "response_item", "payload": {"type": "message",
|
|
14
|
+
"role": "user"|"assistant", "content": [{"type": "input_text"|
|
|
15
|
+
"output_text", "text": "..."}]}}
|
|
16
|
+
|
|
17
|
+
and tool/function calls appear as ``function_call`` / ``local_shell_call``
|
|
18
|
+
payloads. The exact shape has evolved across Codex versions, so this parser
|
|
19
|
+
is intentionally tolerant: it pulls role + text from whatever message-like
|
|
20
|
+
records it recognizes and skips the rest.
|
|
21
|
+
|
|
22
|
+
NOTE: this adapter is written to the documented/observed Codex format but
|
|
23
|
+
has not been verified against a live ~/.codex/sessions store on the
|
|
24
|
+
development machine; field handling is deliberately defensive.
|
|
25
|
+
|
|
26
|
+
All reads are read-only file reads; the rollout files are never modified.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
from collections.abc import Iterator
|
|
35
|
+
from datetime import datetime
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from ..models import Message, Part, Session, _to_dt
|
|
40
|
+
from .base import Source
|
|
41
|
+
|
|
42
|
+
_DEFAULT_ROOT = Path.home() / ".codex" / "sessions"
|
|
43
|
+
|
|
44
|
+
# rollout-2025-01-31T12-34-56-<uuid>.jsonl -> capture the uuid tail.
|
|
45
|
+
_ROLLOUT_RE = re.compile(r"rollout-(?P<ts>[\dT:-]+)-(?P<uuid>[0-9a-fA-F-]{8,})\.jsonl$")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _env_root() -> Path:
|
|
49
|
+
override = os.environ.get("SCROLLBACK_CODEX_DIR")
|
|
50
|
+
if override:
|
|
51
|
+
p = Path(override).expanduser()
|
|
52
|
+
return p / "sessions" if (p / "sessions").is_dir() else p
|
|
53
|
+
return _DEFAULT_ROOT
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CodexSource(Source):
|
|
57
|
+
name = "codex"
|
|
58
|
+
label = "Codex"
|
|
59
|
+
|
|
60
|
+
def __init__(self, root: Path | None = None) -> None:
|
|
61
|
+
self._root = root or _env_root()
|
|
62
|
+
|
|
63
|
+
def resume_command(self, session) -> str | None:
|
|
64
|
+
# Codex resumes a recorded session with `codex resume <id>` (documented;
|
|
65
|
+
# not verified on this machine). Run from the session directory.
|
|
66
|
+
import shlex
|
|
67
|
+
|
|
68
|
+
cmd = f"codex resume {session.id}"
|
|
69
|
+
if session.directory:
|
|
70
|
+
return f"cd {shlex.quote(session.directory)} && {cmd}"
|
|
71
|
+
return cmd
|
|
72
|
+
|
|
73
|
+
# -- availability / location -------------------------------------------
|
|
74
|
+
|
|
75
|
+
def is_available(self) -> bool:
|
|
76
|
+
return self._root.is_dir()
|
|
77
|
+
|
|
78
|
+
def location(self) -> Path | None:
|
|
79
|
+
return self._root if self.is_available() else None
|
|
80
|
+
|
|
81
|
+
# -- discovery ----------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _rollout_files(self) -> Iterator[Path]:
|
|
84
|
+
# Rollouts are nested under YYYY/MM/DD; rglob keeps us version-proof
|
|
85
|
+
# against minor layout changes.
|
|
86
|
+
yield from sorted(self._root.rglob("rollout-*.jsonl"))
|
|
87
|
+
|
|
88
|
+
def _session_id_for(self, path: Path) -> str:
|
|
89
|
+
m = _ROLLOUT_RE.search(path.name)
|
|
90
|
+
return m.group("uuid") if m else path.stem
|
|
91
|
+
|
|
92
|
+
def _find_path(self, session_id: str) -> Path | None:
|
|
93
|
+
for f in self._rollout_files():
|
|
94
|
+
if self._session_id_for(f) == session_id or f.stem == session_id:
|
|
95
|
+
return f
|
|
96
|
+
cands = [f for f in self._rollout_files() if self._session_id_for(f).startswith(session_id)]
|
|
97
|
+
return cands[0] if len(cands) == 1 else None
|
|
98
|
+
|
|
99
|
+
# -- listing ------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def list_sessions(self) -> Iterator[Session]:
|
|
102
|
+
if not self.is_available():
|
|
103
|
+
return iter(())
|
|
104
|
+
return self._list_sessions()
|
|
105
|
+
|
|
106
|
+
def _list_sessions(self) -> Iterator[Session]:
|
|
107
|
+
for f in self._rollout_files():
|
|
108
|
+
meta = _scan_meta(f)
|
|
109
|
+
if meta is None:
|
|
110
|
+
continue
|
|
111
|
+
yield Session(
|
|
112
|
+
id=self._session_id_for(f),
|
|
113
|
+
source=self.name,
|
|
114
|
+
title=meta["title"],
|
|
115
|
+
directory=meta["cwd"],
|
|
116
|
+
created=_to_dt(meta["first_ts"]),
|
|
117
|
+
updated=_to_dt(meta["last_ts"]),
|
|
118
|
+
model=meta["model"],
|
|
119
|
+
message_count=meta["msg_count"],
|
|
120
|
+
raw={"path": str(f)},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# -- single session -----------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def load_session(self, session_id: str) -> Session | None:
|
|
126
|
+
if not self.is_available():
|
|
127
|
+
return None
|
|
128
|
+
path = self._find_path(session_id)
|
|
129
|
+
if path is None:
|
|
130
|
+
return None
|
|
131
|
+
meta = _scan_meta(path) or _empty_meta(path)
|
|
132
|
+
messages = list(_iter_messages(path))
|
|
133
|
+
return Session(
|
|
134
|
+
id=self._session_id_for(path),
|
|
135
|
+
source=self.name,
|
|
136
|
+
title=meta["title"],
|
|
137
|
+
directory=meta["cwd"],
|
|
138
|
+
created=_to_dt(meta["first_ts"]),
|
|
139
|
+
updated=_to_dt(meta["last_ts"]),
|
|
140
|
+
model=meta["model"],
|
|
141
|
+
message_count=len(messages),
|
|
142
|
+
messages=tuple(messages),
|
|
143
|
+
raw={"path": str(path)},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# -- parsing helpers -------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _iter_lines(path: Path) -> Iterator[dict[str, Any]]:
|
|
151
|
+
try:
|
|
152
|
+
with path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
153
|
+
for line in fh:
|
|
154
|
+
line = line.strip()
|
|
155
|
+
if not line:
|
|
156
|
+
continue
|
|
157
|
+
try:
|
|
158
|
+
obj = json.loads(line)
|
|
159
|
+
except json.JSONDecodeError:
|
|
160
|
+
continue
|
|
161
|
+
if isinstance(obj, dict):
|
|
162
|
+
yield obj
|
|
163
|
+
except OSError:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _payload(obj: dict[str, Any]) -> dict[str, Any]:
|
|
168
|
+
"""Codex wraps records in {'type':..., 'payload': {...}} in newer
|
|
169
|
+
versions; older ones are flat. Return the inner dict either way."""
|
|
170
|
+
p = obj.get("payload")
|
|
171
|
+
return p if isinstance(p, dict) else obj
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _record_text(p: dict[str, Any]) -> str:
|
|
175
|
+
"""Extract human-readable text from a message-like payload."""
|
|
176
|
+
content = p.get("content")
|
|
177
|
+
if isinstance(content, str):
|
|
178
|
+
return content
|
|
179
|
+
if isinstance(content, list):
|
|
180
|
+
out: list[str] = []
|
|
181
|
+
for block in content:
|
|
182
|
+
if isinstance(block, dict):
|
|
183
|
+
t = block.get("text") or block.get("input_text") or block.get("output_text")
|
|
184
|
+
if t:
|
|
185
|
+
out.append(t)
|
|
186
|
+
elif isinstance(block, str):
|
|
187
|
+
out.append(block)
|
|
188
|
+
return "\n".join(out)
|
|
189
|
+
# some versions use a flat "text"
|
|
190
|
+
return p.get("text") or ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _scan_meta(path: Path) -> dict[str, Any] | None:
|
|
194
|
+
cwd: str | None = None
|
|
195
|
+
model: str | None = None
|
|
196
|
+
title: str | None = None
|
|
197
|
+
first_ts: str | int | None = None
|
|
198
|
+
last_ts: str | int | None = None
|
|
199
|
+
msg_count = 0
|
|
200
|
+
seen = False
|
|
201
|
+
|
|
202
|
+
for obj in _iter_lines(path):
|
|
203
|
+
seen = True
|
|
204
|
+
ts = obj.get("timestamp") or obj.get("ts")
|
|
205
|
+
if ts is not None:
|
|
206
|
+
if first_ts is None:
|
|
207
|
+
first_ts = ts
|
|
208
|
+
last_ts = ts
|
|
209
|
+
p = _payload(obj)
|
|
210
|
+
if cwd is None:
|
|
211
|
+
cwd = obj.get("cwd") or p.get("cwd")
|
|
212
|
+
if model is None:
|
|
213
|
+
model = obj.get("model") or p.get("model")
|
|
214
|
+
role = p.get("role")
|
|
215
|
+
ptype = p.get("type") or obj.get("type")
|
|
216
|
+
if role in ("user", "assistant") or ptype == "message":
|
|
217
|
+
msg_count += 1
|
|
218
|
+
if title is None and role == "user":
|
|
219
|
+
txt = _record_text(p).strip()
|
|
220
|
+
if txt and not txt.startswith("<"):
|
|
221
|
+
title = " ".join(txt.split())[:60]
|
|
222
|
+
|
|
223
|
+
if not seen:
|
|
224
|
+
return None
|
|
225
|
+
if cwd and not title:
|
|
226
|
+
title = Path(cwd).name
|
|
227
|
+
return {
|
|
228
|
+
"cwd": cwd,
|
|
229
|
+
"model": model,
|
|
230
|
+
"title": title or path.stem[:16],
|
|
231
|
+
"first_ts": first_ts,
|
|
232
|
+
"last_ts": last_ts,
|
|
233
|
+
"msg_count": msg_count,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _empty_meta(path: Path) -> dict[str, Any]:
|
|
238
|
+
return {"cwd": None, "model": None, "title": path.stem[:16],
|
|
239
|
+
"first_ts": None, "last_ts": None, "msg_count": 0}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _iter_messages(path: Path) -> Iterator[Message]:
|
|
243
|
+
idx = 0
|
|
244
|
+
for obj in _iter_lines(path):
|
|
245
|
+
p = _payload(obj)
|
|
246
|
+
role = p.get("role")
|
|
247
|
+
ptype = p.get("type") or obj.get("type")
|
|
248
|
+
created = _to_dt(obj.get("timestamp") or obj.get("ts"))
|
|
249
|
+
|
|
250
|
+
if role in ("user", "assistant"):
|
|
251
|
+
text = _record_text(p)
|
|
252
|
+
if not text.strip():
|
|
253
|
+
continue
|
|
254
|
+
yield Message(
|
|
255
|
+
id=f"{path.stem}:{idx}",
|
|
256
|
+
role=role,
|
|
257
|
+
created=created,
|
|
258
|
+
parts=(Part(id=f"{path.stem}:{idx}:0", type="text", text=text),),
|
|
259
|
+
model=p.get("model"),
|
|
260
|
+
raw=obj,
|
|
261
|
+
)
|
|
262
|
+
idx += 1
|
|
263
|
+
elif ptype in ("function_call", "local_shell_call", "tool_call"):
|
|
264
|
+
name = p.get("name") or p.get("tool") or "tool"
|
|
265
|
+
args = p.get("arguments") or p.get("input") or p.get("command")
|
|
266
|
+
text = f"$ {name} {json.dumps(args, ensure_ascii=False)}" if args is not None else f"$ {name}"
|
|
267
|
+
yield Message(
|
|
268
|
+
id=f"{path.stem}:{idx}",
|
|
269
|
+
role="assistant",
|
|
270
|
+
created=created,
|
|
271
|
+
parts=(Part(id=f"{path.stem}:{idx}:0", type="tool", text=text,
|
|
272
|
+
tool_name=name, tool_status="call"),),
|
|
273
|
+
raw=obj,
|
|
274
|
+
)
|
|
275
|
+
idx += 1
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _now() -> datetime:
|
|
279
|
+
from datetime import timezone
|
|
280
|
+
|
|
281
|
+
return datetime.now(timezone.utc)
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""opencode source adapter (read-only SQLite).
|
|
2
|
+
|
|
3
|
+
opencode stores sessions in a SQLite database (default
|
|
4
|
+
~/.local/share/opencode/opencode.db) with three relevant tables:
|
|
5
|
+
|
|
6
|
+
session(id, title, directory, time_created, time_updated, model, agent,
|
|
7
|
+
parent_id, ...)
|
|
8
|
+
message(id, session_id, time_created, data) -- data is JSON
|
|
9
|
+
part(id, message_id, session_id, time_created, data) -- data is JSON
|
|
10
|
+
|
|
11
|
+
We open the database strictly read-only (URI `mode=ro`) so we never lock
|
|
12
|
+
it for writing or interfere with a running opencode. The DB may be large
|
|
13
|
+
and live (WAL active); read-only queries are safe and see a consistent
|
|
14
|
+
snapshot.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sqlite3
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from ..models import Message, Part, Session, _to_dt
|
|
27
|
+
from .base import Source
|
|
28
|
+
|
|
29
|
+
_DEFAULT_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db"
|
|
30
|
+
|
|
31
|
+
# Map opencode part `type` values to our PartType, with a renderer each.
|
|
32
|
+
_TEXT_PART_TYPES = {"text", "reasoning"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _env_db() -> Path:
|
|
36
|
+
override = os.environ.get("SCROLLBACK_OPENCODE_DB")
|
|
37
|
+
return Path(override).expanduser() if override else _DEFAULT_DB
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OpenCodeSource(Source):
|
|
41
|
+
name = "opencode"
|
|
42
|
+
label = "opencode"
|
|
43
|
+
|
|
44
|
+
def __init__(self, db_path: Path | None = None) -> None:
|
|
45
|
+
self._db_path = db_path or _env_db()
|
|
46
|
+
|
|
47
|
+
def resume_command(self, session) -> str | None:
|
|
48
|
+
# `opencode --session <id>` resumes a session (verified via --help).
|
|
49
|
+
# Run it from the session's directory so the project context matches.
|
|
50
|
+
import shlex
|
|
51
|
+
|
|
52
|
+
cmd = f"opencode --session {session.id}"
|
|
53
|
+
if session.directory:
|
|
54
|
+
return f"cd {shlex.quote(session.directory)} && {cmd}"
|
|
55
|
+
return cmd
|
|
56
|
+
|
|
57
|
+
# -- availability / location -------------------------------------------
|
|
58
|
+
|
|
59
|
+
def is_available(self) -> bool:
|
|
60
|
+
return self._db_path.is_file()
|
|
61
|
+
|
|
62
|
+
def location(self) -> Path | None:
|
|
63
|
+
return self._db_path if self.is_available() else None
|
|
64
|
+
|
|
65
|
+
# -- read-only connection ----------------------------------------------
|
|
66
|
+
|
|
67
|
+
def _connect(self) -> sqlite3.Connection:
|
|
68
|
+
# `mode=ro` => read-only; never creates or writes. `immutable=0`
|
|
69
|
+
# so SQLite still consults the WAL for a consistent live snapshot.
|
|
70
|
+
uri = f"file:{self._db_path}?mode=ro"
|
|
71
|
+
conn = sqlite3.connect(uri, uri=True, timeout=5.0)
|
|
72
|
+
conn.row_factory = sqlite3.Row
|
|
73
|
+
return conn
|
|
74
|
+
|
|
75
|
+
# -- listing ------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def list_sessions(self) -> Iterator[Session]:
|
|
78
|
+
if not self.is_available():
|
|
79
|
+
return iter(())
|
|
80
|
+
return self._list_sessions()
|
|
81
|
+
|
|
82
|
+
def _list_sessions(self) -> Iterator[Session]:
|
|
83
|
+
with self._connect() as conn:
|
|
84
|
+
rows = conn.execute(
|
|
85
|
+
"""
|
|
86
|
+
SELECT s.id, s.title, s.directory, s.time_created,
|
|
87
|
+
s.time_updated, s.model, s.agent, s.parent_id,
|
|
88
|
+
s.cost, s.tokens_input, s.tokens_output,
|
|
89
|
+
(SELECT COUNT(*) FROM message m WHERE m.session_id = s.id)
|
|
90
|
+
AS msg_count
|
|
91
|
+
FROM session s
|
|
92
|
+
ORDER BY s.time_updated DESC
|
|
93
|
+
"""
|
|
94
|
+
).fetchall()
|
|
95
|
+
for r in rows:
|
|
96
|
+
yield Session(
|
|
97
|
+
id=r["id"],
|
|
98
|
+
source=self.name,
|
|
99
|
+
title=r["title"] or "(untitled)",
|
|
100
|
+
directory=r["directory"],
|
|
101
|
+
created=_to_dt(r["time_created"]),
|
|
102
|
+
updated=_to_dt(r["time_updated"]),
|
|
103
|
+
model=_parse_model(r["model"]),
|
|
104
|
+
agent=r["agent"],
|
|
105
|
+
parent_id=r["parent_id"],
|
|
106
|
+
message_count=r["msg_count"],
|
|
107
|
+
cost=r["cost"],
|
|
108
|
+
tokens_input=r["tokens_input"],
|
|
109
|
+
tokens_output=r["tokens_output"],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# -- single session -----------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def load_session(self, session_id: str) -> Session | None:
|
|
115
|
+
if not self.is_available():
|
|
116
|
+
return None
|
|
117
|
+
with self._connect() as conn:
|
|
118
|
+
srow = conn.execute(
|
|
119
|
+
"SELECT * FROM session WHERE id = ?", (session_id,)
|
|
120
|
+
).fetchone()
|
|
121
|
+
if srow is None:
|
|
122
|
+
return None
|
|
123
|
+
mrows = conn.execute(
|
|
124
|
+
"""
|
|
125
|
+
SELECT id, time_created, data FROM message
|
|
126
|
+
WHERE session_id = ?
|
|
127
|
+
ORDER BY time_created, id
|
|
128
|
+
""",
|
|
129
|
+
(session_id,),
|
|
130
|
+
).fetchall()
|
|
131
|
+
prows = conn.execute(
|
|
132
|
+
"""
|
|
133
|
+
SELECT id, message_id, time_created, data FROM part
|
|
134
|
+
WHERE session_id = ?
|
|
135
|
+
ORDER BY time_created, id
|
|
136
|
+
""",
|
|
137
|
+
(session_id,),
|
|
138
|
+
).fetchall()
|
|
139
|
+
|
|
140
|
+
parts_by_message: dict[str, list[Part]] = {}
|
|
141
|
+
for pr in prows:
|
|
142
|
+
data = _loads(pr["data"])
|
|
143
|
+
part = _to_part(pr["id"], data)
|
|
144
|
+
if part is None:
|
|
145
|
+
continue
|
|
146
|
+
parts_by_message.setdefault(pr["message_id"], []).append(part)
|
|
147
|
+
|
|
148
|
+
messages: list[Message] = []
|
|
149
|
+
for mr in mrows:
|
|
150
|
+
data = _loads(mr["data"])
|
|
151
|
+
messages.append(
|
|
152
|
+
Message(
|
|
153
|
+
id=mr["id"],
|
|
154
|
+
role=data.get("role", "assistant"),
|
|
155
|
+
created=_to_dt(mr["time_created"]),
|
|
156
|
+
parts=tuple(parts_by_message.get(mr["id"], ())),
|
|
157
|
+
model=_model_from_message(data),
|
|
158
|
+
raw=data,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return self._session_from_row(srow, tuple(messages), message_count=len(messages))
|
|
163
|
+
|
|
164
|
+
def _session_from_row(
|
|
165
|
+
self, srow: sqlite3.Row, messages: tuple[Message, ...], *, message_count: int
|
|
166
|
+
) -> Session:
|
|
167
|
+
return Session(
|
|
168
|
+
id=srow["id"],
|
|
169
|
+
source=self.name,
|
|
170
|
+
title=srow["title"] or "(untitled)",
|
|
171
|
+
directory=srow["directory"],
|
|
172
|
+
created=_to_dt(srow["time_created"]),
|
|
173
|
+
updated=_to_dt(srow["time_updated"]),
|
|
174
|
+
model=_parse_model(srow["model"]),
|
|
175
|
+
agent=srow["agent"],
|
|
176
|
+
parent_id=srow["parent_id"],
|
|
177
|
+
message_count=message_count,
|
|
178
|
+
cost=_col(srow, "cost"),
|
|
179
|
+
tokens_input=_col(srow, "tokens_input"),
|
|
180
|
+
tokens_output=_col(srow, "tokens_output"),
|
|
181
|
+
messages=messages,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# -- windowed loading ---------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def load_session_meta(self, session_id: str) -> Session | None:
|
|
187
|
+
if not self.is_available():
|
|
188
|
+
return None
|
|
189
|
+
with self._connect() as conn:
|
|
190
|
+
srow = conn.execute(
|
|
191
|
+
"SELECT * FROM session WHERE id = ?", (session_id,)
|
|
192
|
+
).fetchone()
|
|
193
|
+
if srow is None:
|
|
194
|
+
return None
|
|
195
|
+
count = conn.execute(
|
|
196
|
+
"SELECT COUNT(*) AS c FROM message WHERE session_id = ?", (session_id,)
|
|
197
|
+
).fetchone()["c"]
|
|
198
|
+
return self._session_from_row(srow, (), message_count=count)
|
|
199
|
+
|
|
200
|
+
def load_messages(
|
|
201
|
+
self, session_id: str, *, offset: int = 0, limit: int | None = None
|
|
202
|
+
) -> list[Message]:
|
|
203
|
+
if not self.is_available():
|
|
204
|
+
return []
|
|
205
|
+
with self._connect() as conn:
|
|
206
|
+
lim = -1 if limit is None else limit
|
|
207
|
+
mrows = conn.execute(
|
|
208
|
+
"""
|
|
209
|
+
SELECT id, time_created, data FROM message
|
|
210
|
+
WHERE session_id = ?
|
|
211
|
+
ORDER BY time_created, id
|
|
212
|
+
LIMIT ? OFFSET ?
|
|
213
|
+
""",
|
|
214
|
+
(session_id, lim, offset),
|
|
215
|
+
).fetchall()
|
|
216
|
+
if not mrows:
|
|
217
|
+
return []
|
|
218
|
+
ids = [mr["id"] for mr in mrows]
|
|
219
|
+
# Fetch parts in chunks: a single IN (...) can exceed SQLite's
|
|
220
|
+
# SQLITE_MAX_VARIABLE_NUMBER (999 on older builds) for big sessions.
|
|
221
|
+
prows = []
|
|
222
|
+
for chunk in _chunks(ids, 900):
|
|
223
|
+
placeholders = ",".join("?" * len(chunk))
|
|
224
|
+
prows.extend(
|
|
225
|
+
conn.execute(
|
|
226
|
+
f"""
|
|
227
|
+
SELECT id, message_id, time_created, data FROM part
|
|
228
|
+
WHERE message_id IN ({placeholders})
|
|
229
|
+
ORDER BY time_created, id
|
|
230
|
+
""",
|
|
231
|
+
chunk,
|
|
232
|
+
).fetchall()
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
parts_by_message: dict[str, list[Part]] = {}
|
|
236
|
+
for pr in prows:
|
|
237
|
+
part = _to_part(pr["id"], _loads(pr["data"]))
|
|
238
|
+
if part is not None:
|
|
239
|
+
parts_by_message.setdefault(pr["message_id"], []).append(part)
|
|
240
|
+
|
|
241
|
+
messages: list[Message] = []
|
|
242
|
+
for mr in mrows:
|
|
243
|
+
data = _loads(mr["data"])
|
|
244
|
+
messages.append(
|
|
245
|
+
Message(
|
|
246
|
+
id=mr["id"],
|
|
247
|
+
role=data.get("role", "assistant"),
|
|
248
|
+
created=_to_dt(mr["time_created"]),
|
|
249
|
+
parts=tuple(parts_by_message.get(mr["id"], ())),
|
|
250
|
+
model=_model_from_message(data),
|
|
251
|
+
raw=data,
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
return messages
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# -- helpers ---------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _chunks(seq: list, size: int):
|
|
261
|
+
"""Yield successive `size`-length chunks of `seq`."""
|
|
262
|
+
for i in range(0, len(seq), size):
|
|
263
|
+
yield seq[i : i + size]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _col(row: sqlite3.Row, key: str) -> Any:
|
|
267
|
+
"""Read a column from a Row, returning None if absent."""
|
|
268
|
+
try:
|
|
269
|
+
return row[key]
|
|
270
|
+
except (IndexError, KeyError):
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _loads(s: str | None) -> dict[str, Any]:
|
|
275
|
+
if not s:
|
|
276
|
+
return {}
|
|
277
|
+
try:
|
|
278
|
+
obj = json.loads(s)
|
|
279
|
+
return obj if isinstance(obj, dict) else {}
|
|
280
|
+
except (json.JSONDecodeError, TypeError):
|
|
281
|
+
return {}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _parse_model(model_field: str | None) -> str | None:
|
|
285
|
+
"""session.model is JSON like {"id": "...", "providerID": "..."}."""
|
|
286
|
+
if not model_field:
|
|
287
|
+
return None
|
|
288
|
+
try:
|
|
289
|
+
obj = json.loads(model_field)
|
|
290
|
+
if isinstance(obj, dict):
|
|
291
|
+
return obj.get("id") or obj.get("modelID")
|
|
292
|
+
except (json.JSONDecodeError, TypeError):
|
|
293
|
+
pass
|
|
294
|
+
return model_field
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _model_from_message(data: dict[str, Any]) -> str | None:
|
|
298
|
+
m = data.get("model")
|
|
299
|
+
if isinstance(m, dict):
|
|
300
|
+
return m.get("modelID") or m.get("id")
|
|
301
|
+
return data.get("modelID")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _to_part(part_id: str, data: dict[str, Any]) -> Part | None:
|
|
305
|
+
ptype = data.get("type", "unknown")
|
|
306
|
+
if ptype in _TEXT_PART_TYPES:
|
|
307
|
+
return Part(
|
|
308
|
+
id=part_id,
|
|
309
|
+
type=ptype,
|
|
310
|
+
text=data.get("text", "") or "",
|
|
311
|
+
raw=data,
|
|
312
|
+
)
|
|
313
|
+
if ptype == "tool":
|
|
314
|
+
state = data.get("state", {}) or {}
|
|
315
|
+
tool_name = data.get("tool")
|
|
316
|
+
status = state.get("status")
|
|
317
|
+
text = _render_tool(tool_name, state)
|
|
318
|
+
return Part(
|
|
319
|
+
id=part_id,
|
|
320
|
+
type="tool",
|
|
321
|
+
text=text,
|
|
322
|
+
tool_name=tool_name,
|
|
323
|
+
tool_status=status,
|
|
324
|
+
raw=data,
|
|
325
|
+
)
|
|
326
|
+
# Other part types (step-start/step-finish/patch/file/compaction) are
|
|
327
|
+
# kept with empty text; export/search can opt in via raw.
|
|
328
|
+
return Part(id=part_id, type=ptype if ptype in _KNOWN else "unknown", raw=data)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
_KNOWN = {
|
|
332
|
+
"text",
|
|
333
|
+
"reasoning",
|
|
334
|
+
"tool",
|
|
335
|
+
"file",
|
|
336
|
+
"patch",
|
|
337
|
+
"step-start",
|
|
338
|
+
"step-finish",
|
|
339
|
+
"compaction",
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _render_tool(tool_name: str | None, state: dict[str, Any]) -> str:
|
|
344
|
+
"""A compact, searchable rendering of a tool call and its result."""
|
|
345
|
+
parts: list[str] = []
|
|
346
|
+
inp = state.get("input")
|
|
347
|
+
if inp is not None:
|
|
348
|
+
parts.append(f"$ {tool_name} {json.dumps(inp, ensure_ascii=False)}")
|
|
349
|
+
out = state.get("output")
|
|
350
|
+
if isinstance(out, str) and out:
|
|
351
|
+
parts.append(out)
|
|
352
|
+
elif out is not None:
|
|
353
|
+
parts.append(json.dumps(out, ensure_ascii=False))
|
|
354
|
+
err = state.get("error")
|
|
355
|
+
if err:
|
|
356
|
+
parts.append(f"[error] {err}")
|
|
357
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Registry of available source adapters.
|
|
2
|
+
|
|
3
|
+
To add a new agent: implement a `Source` subclass and append it to
|
|
4
|
+
`ALL_SOURCES`. Everything else (CLI, search, export, web) picks it up
|
|
5
|
+
automatically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .aider import AiderSource
|
|
11
|
+
from .base import Source
|
|
12
|
+
from .claudecode import ClaudeCodeSource
|
|
13
|
+
from .codex import CodexSource
|
|
14
|
+
from .opencode import OpenCodeSource
|
|
15
|
+
|
|
16
|
+
#: Every adapter the program knows about, in display order.
|
|
17
|
+
ALL_SOURCES: tuple[type[Source], ...] = (
|
|
18
|
+
OpenCodeSource,
|
|
19
|
+
ClaudeCodeSource,
|
|
20
|
+
CodexSource,
|
|
21
|
+
AiderSource,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def all_sources() -> list[Source]:
|
|
26
|
+
"""Instantiate every registered adapter (cheap; no I/O yet)."""
|
|
27
|
+
return [cls() for cls in ALL_SOURCES]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def available_sources() -> list[Source]:
|
|
31
|
+
"""Only adapters whose data store exists on this machine."""
|
|
32
|
+
return [s for s in all_sources() if s.is_available()]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_source(name: str) -> Source | None:
|
|
36
|
+
for s in all_sources():
|
|
37
|
+
if s.name == name:
|
|
38
|
+
return s
|
|
39
|
+
return None
|