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.
Files changed (69) hide show
  1. scrollback/__init__.py +8 -0
  2. scrollback/assets/icon-256.png +0 -0
  3. scrollback/assets/icon.icns +0 -0
  4. scrollback/cli.py +1139 -0
  5. scrollback/clipboard.py +34 -0
  6. scrollback/export.py +293 -0
  7. scrollback/fts.py +307 -0
  8. scrollback/highlight.py +128 -0
  9. scrollback/katexbundle.py +81 -0
  10. scrollback/launcher_install.py +209 -0
  11. scrollback/launchers/scrollback.bat +19 -0
  12. scrollback/launchers/scrollback.command +19 -0
  13. scrollback/launchers/scrollback.desktop +10 -0
  14. scrollback/launchers/scrollback.sh +12 -0
  15. scrollback/mathspan.py +180 -0
  16. scrollback/minimd.py +205 -0
  17. scrollback/models.py +135 -0
  18. scrollback/serialize.py +83 -0
  19. scrollback/serverconfig.py +66 -0
  20. scrollback/sources/__init__.py +6 -0
  21. scrollback/sources/aider.py +244 -0
  22. scrollback/sources/base.py +117 -0
  23. scrollback/sources/claudecode.py +631 -0
  24. scrollback/sources/codex.py +281 -0
  25. scrollback/sources/opencode.py +357 -0
  26. scrollback/sources/registry.py +39 -0
  27. scrollback/store.py +384 -0
  28. scrollback/termrender.py +170 -0
  29. scrollback/web/__init__.py +1 -0
  30. scrollback/web/app.py +359 -0
  31. scrollback/web/static/app.js +1245 -0
  32. scrollback/web/static/apple-touch-icon.png +0 -0
  33. scrollback/web/static/favicon.png +0 -0
  34. scrollback/web/static/favicon.svg +41 -0
  35. scrollback/web/static/index.html +75 -0
  36. scrollback/web/static/style.css +628 -0
  37. scrollback/web/static/vendor/highlight.min.js +1213 -0
  38. scrollback/web/static/vendor/hljs-dark.min.css +10 -0
  39. scrollback/web/static/vendor/hljs-light.min.css +10 -0
  40. scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  41. scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  42. scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  43. scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  44. scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  45. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  46. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  47. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  48. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  49. scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  50. scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  51. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  52. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  53. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  54. scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  55. scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  56. scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  57. scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  58. scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  59. scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  60. scrollback/web/static/vendor/katex/katex.min.css +1 -0
  61. scrollback/web/static/vendor/katex/katex.min.js +1 -0
  62. scrollback/web/static/vendor/marked.min.js +6 -0
  63. scrollback/web/static/vendor/purify.min.js +3 -0
  64. scrollback/webopen.py +96 -0
  65. scrollback-0.1.0.dist-info/METADATA +391 -0
  66. scrollback-0.1.0.dist-info/RECORD +69 -0
  67. scrollback-0.1.0.dist-info/WHEEL +4 -0
  68. scrollback-0.1.0.dist-info/entry_points.txt +4 -0
  69. 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