claude-opencode-sessions 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 (33) hide show
  1. claude_opencode_sessions/__init__.py +10 -0
  2. claude_opencode_sessions/__main__.py +3 -0
  3. claude_opencode_sessions/cli.py +196 -0
  4. claude_opencode_sessions/cli_backend.py +140 -0
  5. claude_opencode_sessions/errors.py +39 -0
  6. claude_opencode_sessions/import_command.py +216 -0
  7. claude_opencode_sessions/importer.py +414 -0
  8. claude_opencode_sessions/locate.py +90 -0
  9. claude_opencode_sessions/models.py +96 -0
  10. claude_opencode_sessions/parse.py +224 -0
  11. claude_opencode_sessions/py.typed +0 -0
  12. claude_opencode_sessions/render.py +169 -0
  13. claude_opencode_sessions/scope.py +160 -0
  14. claude_opencode_sessions/service.py +149 -0
  15. claude_opencode_sessions/sqlite_backend.py +249 -0
  16. claude_opencode_sessions/tests/__init__.py +0 -0
  17. claude_opencode_sessions/tests/conftest.py +314 -0
  18. claude_opencode_sessions/tests/fixtures/schema_1x.sql +76 -0
  19. claude_opencode_sessions/tests/fixtures/schema_2x.sql +37 -0
  20. claude_opencode_sessions/tests/test_cli.py +140 -0
  21. claude_opencode_sessions/tests/test_cli_backend.py +116 -0
  22. claude_opencode_sessions/tests/test_importer.py +504 -0
  23. claude_opencode_sessions/tests/test_launcher.py +47 -0
  24. claude_opencode_sessions/tests/test_locate.py +62 -0
  25. claude_opencode_sessions/tests/test_parse.py +120 -0
  26. claude_opencode_sessions/tests/test_render.py +107 -0
  27. claude_opencode_sessions/tests/test_scope.py +121 -0
  28. claude_opencode_sessions/tests/test_sqlite_backend.py +157 -0
  29. claude_opencode_sessions-0.1.0.dist-info/METADATA +287 -0
  30. claude_opencode_sessions-0.1.0.dist-info/RECORD +33 -0
  31. claude_opencode_sessions-0.1.0.dist-info/WHEEL +4 -0
  32. claude_opencode_sessions-0.1.0.dist-info/entry_points.txt +2 -0
  33. claude_opencode_sessions-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,249 @@
1
+ """Read-only access to opencode's SQLite database.
2
+
3
+ Supported layouts (detected per database, and per session for messages):
4
+
5
+ * opencode 1.x (>= 1.2): ``session`` + ``message`` + ``part``.
6
+ * opencode 2.x: ``session_v2`` (or ``session`` on early 2.x builds) +
7
+ ``session_message``. Legacy 1.x rows that still live in the same file are
8
+ merged in, de-duplicated by session id (2.x wins).
9
+
10
+ The database is opened with ``mode=ro``: this module never writes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sqlite3
16
+ from collections.abc import Iterable
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from .errors import SchemaError
21
+ from .models import Message, Session, format_model
22
+ from .parse import loads, parse_v1_message, parse_v2_row
23
+
24
+ __all__ = ["SqliteBackend"]
25
+
26
+ _SESSION_COLUMNS = (
27
+ "id",
28
+ "title",
29
+ "directory",
30
+ "project_id",
31
+ "parent_id",
32
+ "time_created",
33
+ "time_updated",
34
+ "time_archived",
35
+ "agent",
36
+ "model",
37
+ "version",
38
+ "cost",
39
+ "tokens_input",
40
+ "tokens_output",
41
+ )
42
+ _REQUIRED_SESSION_COLUMNS = {"id", "directory", "time_updated"}
43
+ _REQUIRED_V2_MESSAGE_COLUMNS = {"id", "session_id", "type", "data", "seq"}
44
+ _REQUIRED_V1_MESSAGE_COLUMNS = {"id", "session_id", "data"}
45
+ _CONVERSATION_TYPES = ("user", "assistant")
46
+
47
+
48
+ def _placeholders(count: int) -> str:
49
+ return ",".join("?" * count)
50
+
51
+
52
+ class SqliteBackend:
53
+ name = "sqlite"
54
+
55
+ def __init__(self, path: str | Path, timeout: float = 2.0) -> None:
56
+ self.path = Path(path)
57
+ uri = f"{self.path.resolve().as_uri()}?mode=ro"
58
+ try:
59
+ self.conn = sqlite3.connect(uri, uri=True, timeout=timeout)
60
+ except sqlite3.Error as exc:
61
+ raise SchemaError(f"cannot open {self.path}: {exc}") from exc
62
+ self.conn.row_factory = sqlite3.Row
63
+ try:
64
+ self._detect()
65
+ except sqlite3.DatabaseError as exc:
66
+ self.conn.close()
67
+ raise SchemaError(f"cannot read {self.path}: {exc}") from exc
68
+ except SchemaError:
69
+ self.conn.close()
70
+ raise
71
+
72
+ # -- schema detection -------------------------------------------------
73
+
74
+ def _tables(self) -> set[str]:
75
+ rows = self.conn.execute(
76
+ "SELECT name FROM sqlite_master WHERE type = 'table'"
77
+ ).fetchall()
78
+ return {row[0] for row in rows}
79
+
80
+ def _columns(self, table: str) -> set[str]:
81
+ rows = self.conn.execute(f'PRAGMA table_info("{table}")').fetchall()
82
+ return {row[1] for row in rows}
83
+
84
+ def _detect(self) -> None:
85
+ tables = self._tables()
86
+ self.session_tables: list[str] = [
87
+ t
88
+ for t in ("session_v2", "session")
89
+ if t in tables and _REQUIRED_SESSION_COLUMNS <= self._columns(t)
90
+ ]
91
+ if not self.session_tables:
92
+ raise SchemaError(
93
+ f"{self.path} has no opencode session table "
94
+ "(needs opencode >= 1.2; JSON storage of older versions is "
95
+ "not supported)"
96
+ )
97
+ self.has_v2_messages = (
98
+ "session_message" in tables
99
+ and _REQUIRED_V2_MESSAGE_COLUMNS <= self._columns("session_message")
100
+ )
101
+ self.has_v1_messages = (
102
+ "message" in tables
103
+ and "part" in tables
104
+ and _REQUIRED_V1_MESSAGE_COLUMNS <= self._columns("message")
105
+ and {"message_id", "data"} <= self._columns("part")
106
+ )
107
+ if not (self.has_v2_messages or self.has_v1_messages):
108
+ raise SchemaError(f"{self.path} has no opencode message tables")
109
+
110
+ def describe(self) -> str:
111
+ """Human-readable description of the detected layout."""
112
+ layouts = []
113
+ if self.has_v2_messages and self._count("session_message"):
114
+ layouts.append("2.x session_message")
115
+ if self.has_v1_messages and self._count("message"):
116
+ layouts.append("1.x message/part")
117
+ if not layouts:
118
+ layouts.append("empty")
119
+ return f"{'+'.join(self.session_tables)} · {', '.join(layouts)}"
120
+
121
+ def _count(self, table: str) -> int:
122
+ row = self.conn.execute(f'SELECT count(*) FROM "{table}"').fetchone()
123
+ return int(row[0])
124
+
125
+ def table_report(self) -> dict[str, int]:
126
+ """Row counts of the tables this backend reads (for ``doctor``)."""
127
+ report: dict[str, int] = {}
128
+ tables = self._tables()
129
+ for table in ("session_v2", "session", "session_message", "message", "part"):
130
+ if table in tables:
131
+ report[table] = self._count(table)
132
+ return report
133
+
134
+ # -- sessions ---------------------------------------------------------
135
+
136
+ def _message_counts(self) -> dict[str, int]:
137
+ counts: dict[str, int] = {}
138
+ if self.has_v1_messages:
139
+ for row in self.conn.execute(
140
+ "SELECT session_id, count(*) FROM message GROUP BY session_id"
141
+ ):
142
+ counts[row[0]] = int(row[1])
143
+ if self.has_v2_messages:
144
+ sql = (
145
+ "SELECT session_id, count(*) FROM session_message "
146
+ f"WHERE type IN ({_placeholders(len(_CONVERSATION_TYPES))}) "
147
+ "GROUP BY session_id"
148
+ )
149
+ for row in self.conn.execute(sql, _CONVERSATION_TYPES):
150
+ counts[row[0]] = int(row[1])
151
+ return counts
152
+
153
+ def _select_sessions(self, table: str) -> Iterable[sqlite3.Row]:
154
+ available = self._columns(table)
155
+ exprs = [
156
+ f'"{col}" AS {col}' if col in available else f"NULL AS {col}"
157
+ for col in _SESSION_COLUMNS
158
+ ]
159
+ sql = f'SELECT {", ".join(exprs)} FROM "{table}"'
160
+ return self.conn.execute(sql)
161
+
162
+ def list_sessions(self) -> list[Session]:
163
+ counts = self._message_counts()
164
+ seen: dict[str, Session] = {}
165
+ for table in self.session_tables:
166
+ for row in self._select_sessions(table):
167
+ if row["id"] in seen:
168
+ continue
169
+ seen[row["id"]] = Session(
170
+ id=row["id"],
171
+ title=row["title"] or "(untitled)",
172
+ directory=row["directory"] or "",
173
+ project_id=row["project_id"],
174
+ parent_id=row["parent_id"],
175
+ time_created=row["time_created"],
176
+ time_updated=row["time_updated"],
177
+ time_archived=row["time_archived"],
178
+ agent=row["agent"],
179
+ model=format_model(row["model"]),
180
+ version=row["version"],
181
+ message_count=counts.get(row["id"], 0),
182
+ cost=row["cost"],
183
+ tokens_input=row["tokens_input"],
184
+ tokens_output=row["tokens_output"],
185
+ origin=table,
186
+ )
187
+ return list(seen.values())
188
+
189
+ # -- messages ---------------------------------------------------------
190
+
191
+ def _load_v2(self, session_id: str) -> tuple[list[Message], int]:
192
+ rows = self.conn.execute(
193
+ "SELECT id, type, seq, time_created, data FROM session_message "
194
+ "WHERE session_id = ? ORDER BY seq",
195
+ (session_id,),
196
+ ).fetchall()
197
+ messages: list[Message] = []
198
+ errors = 0
199
+ for row in rows:
200
+ message, ok = parse_v2_row(
201
+ row["id"], row["type"], row["data"], row["seq"], row["time_created"]
202
+ )
203
+ errors += 0 if ok else 1
204
+ messages.append(message)
205
+ return messages, errors
206
+
207
+ def _load_v1(self, session_id: str) -> tuple[list[Message], int]:
208
+ msg_rows = self.conn.execute(
209
+ "SELECT id, time_created, data FROM message "
210
+ "WHERE session_id = ? ORDER BY time_created, id",
211
+ (session_id,),
212
+ ).fetchall()
213
+ parts_by_message: dict[str, list[Any]] = {}
214
+ for row in self.conn.execute(
215
+ "SELECT message_id, data FROM part WHERE session_id = ? "
216
+ "ORDER BY message_id, id",
217
+ (session_id,),
218
+ ):
219
+ parts_by_message.setdefault(row["message_id"], []).append(
220
+ loads(row["data"])
221
+ )
222
+ messages: list[Message] = []
223
+ errors = 0
224
+ for index, row in enumerate(msg_rows, start=1):
225
+ info = loads(row["data"])
226
+ if not isinstance(info, dict):
227
+ errors += 1
228
+ info = {}
229
+ info.setdefault("id", row["id"])
230
+ info.setdefault("time", {"created": row["time_created"]})
231
+ parts = parts_by_message.get(row["id"], [])
232
+ errors += sum(1 for p in parts if p is None)
233
+ messages.append(
234
+ parse_v1_message(info, [p for p in parts if p is not None], index)
235
+ )
236
+ return messages, errors
237
+
238
+ def load_messages(self, session_id: str) -> tuple[list[Message], int]:
239
+ """Return ``(messages, parse_errors)`` for one session."""
240
+ if self.has_v2_messages:
241
+ messages, errors = self._load_v2(session_id)
242
+ if messages:
243
+ return messages, errors
244
+ if self.has_v1_messages:
245
+ return self._load_v1(session_id)
246
+ return [], 0
247
+
248
+ def close(self) -> None:
249
+ self.conn.close()
File without changes
@@ -0,0 +1,314 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import sqlite3
6
+ import subprocess
7
+ import sys
8
+ import textwrap
9
+ from collections.abc import Iterator
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import pytest
14
+
15
+ FIXTURES = Path(__file__).parent / "fixtures"
16
+ NOW = 1_790_000_000_000 # ms
17
+ HOUR = 3_600_000
18
+
19
+
20
+ class DbBuilder:
21
+ """Create opencode-like databases from the fixture schemas."""
22
+
23
+ def __init__(self, path: Path, schema: str) -> None:
24
+ self.path = path
25
+ self.conn = sqlite3.connect(path)
26
+ self.conn.executescript((FIXTURES / schema).read_text())
27
+ self._part = 0
28
+
29
+ def close(self) -> None:
30
+ if self.conn is not None:
31
+ self.conn.commit()
32
+ self.conn.close()
33
+ self.conn = None # type: ignore[assignment]
34
+
35
+ # -- 1.x --------------------------------------------------------------
36
+
37
+ def session_1x(
38
+ self,
39
+ sid: str,
40
+ directory: str,
41
+ *,
42
+ title: str = "",
43
+ updated: int = NOW,
44
+ project_id: str = "proj1",
45
+ parent_id: str | None = None,
46
+ archived: int | None = None,
47
+ messages: list[tuple[dict[str, Any], list[dict[str, Any]]]] | None = None,
48
+ table: str = "session",
49
+ ) -> None:
50
+ self.conn.execute(
51
+ f"INSERT INTO {table} (id, project_id, parent_id, slug, directory, title,"
52
+ " version, time_created, time_updated, time_archived, agent, model)"
53
+ " VALUES (?, ?, ?, 'slug', ?, ?, '1.18.32', ?, ?, ?, 'build', ?)",
54
+ (
55
+ sid,
56
+ project_id,
57
+ parent_id,
58
+ directory,
59
+ title or f"Session {sid}",
60
+ updated - HOUR,
61
+ updated,
62
+ archived,
63
+ json.dumps({"id": "gpt-x", "providerID": "openai"}),
64
+ ),
65
+ )
66
+ for index, (info, parts) in enumerate(messages or [], start=1):
67
+ mid = f"{sid}_msg_{index:03d}"
68
+ info = {"id": mid, "sessionID": sid, **info}
69
+ info.setdefault("time", {"created": updated - HOUR + index * 1000})
70
+ self.conn.execute(
71
+ "INSERT INTO message (id, session_id, time_created, time_updated, data)"
72
+ " VALUES (?, ?, ?, ?, ?)",
73
+ (mid, sid, info["time"]["created"], updated, json.dumps(info)),
74
+ )
75
+ for part in parts:
76
+ self._part += 1
77
+ raw = part if isinstance(part, str) else json.dumps(part)
78
+ self.conn.execute(
79
+ "INSERT INTO part (id, message_id, session_id, time_created,"
80
+ " time_updated, data) VALUES (?, ?, ?, ?, ?, ?)",
81
+ (f"prt_{self._part:06d}", mid, sid, updated, updated, raw),
82
+ )
83
+
84
+ # -- 2.x --------------------------------------------------------------
85
+
86
+ def session_2x(
87
+ self,
88
+ sid: str,
89
+ directory: str,
90
+ *,
91
+ title: str = "",
92
+ updated: int = NOW,
93
+ project_id: str = "proj1",
94
+ parent_id: str | None = None,
95
+ archived: int | None = None,
96
+ rows: list[tuple[str, Any]] | None = None,
97
+ ) -> None:
98
+ self.conn.execute(
99
+ "INSERT INTO session_v2 (id, project_id, parent_id, directory, title,"
100
+ " agent, model, time_created, time_updated, time_archived)"
101
+ " VALUES (?, ?, ?, ?, ?, 'build', ?, ?, ?, ?)",
102
+ (
103
+ sid,
104
+ project_id,
105
+ parent_id,
106
+ directory,
107
+ title or f"Session {sid}",
108
+ json.dumps({"id": "gpt-x", "providerID": "openai"}),
109
+ updated - HOUR,
110
+ updated,
111
+ archived,
112
+ ),
113
+ )
114
+ for seq, (kind, data) in enumerate(rows or [], start=1):
115
+ raw = data if isinstance(data, str) else json.dumps(data)
116
+ self.conn.execute(
117
+ "INSERT INTO session_message (id, session_id, type, time_created,"
118
+ " time_updated, data, seq) VALUES (?, ?, ?, ?, ?, ?, ?)",
119
+ (
120
+ f"{sid}_sm_{seq:03d}",
121
+ sid,
122
+ kind,
123
+ updated - HOUR + seq,
124
+ updated,
125
+ raw,
126
+ seq,
127
+ ),
128
+ )
129
+
130
+
131
+ def text_of(message: Any) -> str:
132
+ """Concatenated non-synthetic text parts of a parsed message."""
133
+ return "\n\n".join(
134
+ p.text for p in message.parts if p.kind == "text" and not p.synthetic
135
+ ).strip()
136
+
137
+
138
+ def user(text: str, **extra: Any) -> tuple[dict[str, Any], list[dict[str, Any]]]:
139
+ return {"role": "user", **extra}, [{"type": "text", "text": text}]
140
+
141
+
142
+ def assistant(
143
+ *parts: dict[str, Any], **extra: Any
144
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
145
+ info = {
146
+ "role": "assistant",
147
+ "agent": "build",
148
+ "modelID": "gpt-x",
149
+ "providerID": "openai",
150
+ "tokens": {"input": 100, "output": 20},
151
+ "cost": 0.01,
152
+ **extra,
153
+ }
154
+ return info, [{"type": "step-start"}, *parts, {"type": "step-finish"}]
155
+
156
+
157
+ def text(value: str) -> dict[str, Any]:
158
+ return {"type": "text", "text": value}
159
+
160
+
161
+ def tool(
162
+ name: str, args: dict[str, Any], output: str = "ok", status: str = "completed"
163
+ ) -> dict[str, Any]:
164
+ return {
165
+ "type": "tool",
166
+ "tool": name,
167
+ "callID": "c1",
168
+ "state": {"status": status, "input": args, "output": output, "title": name},
169
+ }
170
+
171
+
172
+ @pytest.fixture
173
+ def db_1x(tmp_path: Path) -> Iterator[DbBuilder]:
174
+ builder = DbBuilder(tmp_path / "opencode.db", "schema_1x.sql")
175
+ yield builder
176
+ builder.close()
177
+
178
+
179
+ @pytest.fixture
180
+ def db_2x(tmp_path: Path) -> Iterator[DbBuilder]:
181
+ builder = DbBuilder(tmp_path / "opencode.db", "schema_2x.sql")
182
+ yield builder
183
+ builder.close()
184
+
185
+
186
+ needs_git = pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
187
+
188
+
189
+ def _git(*args: str, cwd: Path) -> None:
190
+ subprocess.run(
191
+ ["git", *args],
192
+ cwd=cwd,
193
+ check=True,
194
+ capture_output=True,
195
+ env={
196
+ "GIT_AUTHOR_NAME": "t",
197
+ "GIT_AUTHOR_EMAIL": "t@example.com",
198
+ "GIT_COMMITTER_NAME": "t",
199
+ "GIT_COMMITTER_EMAIL": "t@example.com",
200
+ "PATH": __import__("os").environ.get("PATH", ""),
201
+ "HOME": str(cwd),
202
+ },
203
+ )
204
+
205
+
206
+ @pytest.fixture
207
+ def git_repo(tmp_path: Path) -> dict[str, Path]:
208
+ """A repo with one extra worktree and a subdirectory, plus a non-git dir."""
209
+ if shutil.which("git") is None:
210
+ pytest.skip("git not installed")
211
+ repo = tmp_path / "repo"
212
+ (repo / "sub" / "deep").mkdir(parents=True)
213
+ _git("init", "-q", "-b", "main", cwd=repo)
214
+ (repo / "README").write_text("x")
215
+ _git("add", ".", cwd=repo)
216
+ _git("commit", "-q", "-m", "init", cwd=repo)
217
+ feature = tmp_path / "repo-feature"
218
+ _git("worktree", "add", "-q", "-b", "feature", str(feature), cwd=repo)
219
+ plain = tmp_path / "plain"
220
+ (plain / "inner").mkdir(parents=True)
221
+ return {
222
+ "repo": repo.resolve(),
223
+ "feature": feature.resolve(),
224
+ "plain": plain.resolve(),
225
+ "other": (tmp_path / "elsewhere").resolve(),
226
+ }
227
+
228
+
229
+ @pytest.fixture
230
+ def fake_opencode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
231
+ """Install a fake ``opencode`` executable on PATH that serves fixture JSON."""
232
+ bindir = tmp_path / "fakebin"
233
+ bindir.mkdir()
234
+ data = tmp_path / "fake-data.json"
235
+ data.write_text(
236
+ json.dumps(
237
+ {
238
+ "version": "1.18.32",
239
+ "sessions": [
240
+ {
241
+ "id": "ses_cli_1",
242
+ "title": "From the CLI",
243
+ "directory": str(tmp_path / "proj"),
244
+ "projectID": "p",
245
+ "time": {"created": NOW - HOUR, "updated": NOW},
246
+ }
247
+ ],
248
+ "exports": {
249
+ "ses_cli_1": {
250
+ "info": {
251
+ "id": "ses_cli_1",
252
+ "title": "From the CLI",
253
+ "directory": str(tmp_path / "proj"),
254
+ "time": {"created": NOW - HOUR, "updated": NOW},
255
+ },
256
+ "messages": [
257
+ {
258
+ "info": {
259
+ "id": "m1",
260
+ "role": "user",
261
+ "time": {"created": NOW - 10},
262
+ },
263
+ "parts": [
264
+ {"type": "text", "text": "hello from export"}
265
+ ],
266
+ },
267
+ {
268
+ "info": {
269
+ "id": "m2",
270
+ "role": "assistant",
271
+ "modelID": "gpt-x",
272
+ "providerID": "openai",
273
+ "time": {"created": NOW - 5},
274
+ },
275
+ "parts": [{"type": "text", "text": "hi back"}],
276
+ },
277
+ ],
278
+ }
279
+ },
280
+ }
281
+ )
282
+ )
283
+ script = bindir / "opencode"
284
+ script.write_text(
285
+ textwrap.dedent(
286
+ f"""\
287
+ #!{sys.executable}
288
+ import json, sys
289
+ data = json.load(open({str(data)!r}))
290
+ args = sys.argv[1:]
291
+ if args == ["--version"]:
292
+ print(data["version"])
293
+ elif args[:2] == ["session", "list"]:
294
+ print("INFO some log line")
295
+ print(json.dumps(data["sessions"]))
296
+ elif args[:1] == ["export"]:
297
+ sid = args[1]
298
+ if sid not in data["exports"]:
299
+ print("Session not found", file=sys.stderr)
300
+ sys.exit(1)
301
+ print("Exporting session: " + sid)
302
+ print(json.dumps(data["exports"][sid]))
303
+ elif args == ["db", "path"]:
304
+ print(data.get("db_path", "/nonexistent/opencode.db"))
305
+ else:
306
+ sys.exit(2)
307
+ """
308
+ )
309
+ )
310
+ script.chmod(0o755)
311
+ monkeypatch.setenv(
312
+ "PATH", f"{bindir}{__import__('os').pathsep}{__import__('os').environ['PATH']}"
313
+ )
314
+ return data
@@ -0,0 +1,76 @@
1
+ -- Schema of the tables read by claude-opencode-sessions, copied from a real
2
+ -- opencode 1.18.32 database (schema only, no data).
3
+
4
+ CREATE TABLE `project` (
5
+ `id` text PRIMARY KEY,
6
+ `worktree` text NOT NULL,
7
+ `vcs` text,
8
+ `name` text,
9
+ `icon_url` text,
10
+ `icon_color` text,
11
+ `time_created` integer NOT NULL,
12
+ `time_updated` integer NOT NULL,
13
+ `time_initialized` integer,
14
+ `sandboxes` text NOT NULL
15
+ , `commands` text, `icon_url_override` text);
16
+
17
+ CREATE TABLE `session` (
18
+ `id` text PRIMARY KEY,
19
+ `project_id` text NOT NULL,
20
+ `parent_id` text,
21
+ `slug` text NOT NULL,
22
+ `directory` text NOT NULL,
23
+ `title` text NOT NULL,
24
+ `version` text NOT NULL,
25
+ `share_url` text,
26
+ `summary_additions` integer,
27
+ `summary_deletions` integer,
28
+ `summary_files` integer,
29
+ `summary_diffs` text,
30
+ `revert` text,
31
+ `permission` text,
32
+ `time_created` integer NOT NULL,
33
+ `time_updated` integer NOT NULL,
34
+ `time_compacting` integer,
35
+ `time_archived` integer, `workspace_id` text, `path` text, `agent` text, `model` text, `cost` real DEFAULT 0 NOT NULL, `tokens_input` integer DEFAULT 0 NOT NULL, `tokens_output` integer DEFAULT 0 NOT NULL, `tokens_reasoning` integer DEFAULT 0 NOT NULL, `tokens_cache_read` integer DEFAULT 0 NOT NULL, `tokens_cache_write` integer DEFAULT 0 NOT NULL, `metadata` text,
36
+ CONSTRAINT `fk_session_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
37
+ );
38
+ CREATE INDEX `session_project_idx` ON `session` (`project_id`);
39
+ CREATE INDEX `session_parent_idx` ON `session` (`parent_id`);
40
+ CREATE INDEX `session_workspace_idx` ON `session` (`workspace_id`);
41
+
42
+ CREATE TABLE `message` (
43
+ `id` text PRIMARY KEY,
44
+ `session_id` text NOT NULL,
45
+ `time_created` integer NOT NULL,
46
+ `time_updated` integer NOT NULL,
47
+ `data` text NOT NULL,
48
+ CONSTRAINT `fk_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
49
+ );
50
+ CREATE INDEX `message_session_time_created_id_idx` ON `message` (`session_id`,`time_created`,`id`);
51
+
52
+ CREATE TABLE `part` (
53
+ `id` text PRIMARY KEY,
54
+ `message_id` text NOT NULL,
55
+ `session_id` text NOT NULL,
56
+ `time_created` integer NOT NULL,
57
+ `time_updated` integer NOT NULL,
58
+ `data` text NOT NULL,
59
+ CONSTRAINT `fk_part_message_id_message_id_fk` FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON DELETE CASCADE
60
+ );
61
+ CREATE INDEX `part_session_idx` ON `part` (`session_id`);
62
+ CREATE INDEX `part_message_id_id_idx` ON `part` (`message_id`,`id`);
63
+
64
+ CREATE TABLE `session_message` (
65
+ `id` text PRIMARY KEY,
66
+ `session_id` text NOT NULL,
67
+ `type` text NOT NULL,
68
+ `time_created` integer NOT NULL,
69
+ `time_updated` integer NOT NULL,
70
+ `data` text NOT NULL, `seq` integer NOT NULL,
71
+ CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
72
+ );
73
+ CREATE INDEX `session_message_time_created_idx` ON `session_message` (`time_created`);
74
+ CREATE INDEX `session_message_session_time_created_id_idx` ON `session_message` (`session_id`,`time_created`,`id`);
75
+ CREATE INDEX `session_message_session_type_seq_idx` ON `session_message` (`session_id`,`type`,`seq`);
76
+ CREATE UNIQUE INDEX `session_message_session_seq_idx` ON `session_message` (`session_id`,`seq`);
@@ -0,0 +1,37 @@
1
+ -- opencode 2.x layout (>= ~2.0.18): sessions in `session_v2`, conversation
2
+ -- events in `session_message`. Reconstructed from the 1.18 tables plus
3
+ -- public reports (no real 2.x database was available); columns this package
4
+ -- does not read are omitted. The frozen 1.x tables may coexist in the file.
5
+
6
+ CREATE TABLE `project` (
7
+ `id` text PRIMARY KEY,
8
+ `worktree` text NOT NULL,
9
+ `vcs` text,
10
+ `name` text,
11
+ `time_created` integer NOT NULL,
12
+ `time_updated` integer NOT NULL
13
+ );
14
+
15
+ CREATE TABLE `session_v2` (
16
+ `id` text PRIMARY KEY,
17
+ `project_id` text NOT NULL,
18
+ `parent_id` text,
19
+ `directory` text NOT NULL,
20
+ `title` text NOT NULL,
21
+ `agent` text,
22
+ `model` text,
23
+ `time_created` integer NOT NULL,
24
+ `time_updated` integer NOT NULL,
25
+ `time_archived` integer
26
+ );
27
+
28
+ CREATE TABLE `session_message` (
29
+ `id` text PRIMARY KEY,
30
+ `session_id` text NOT NULL,
31
+ `type` text NOT NULL,
32
+ `time_created` integer NOT NULL,
33
+ `time_updated` integer NOT NULL,
34
+ `data` text NOT NULL,
35
+ `seq` integer NOT NULL
36
+ );
37
+ CREATE UNIQUE INDEX `session_message_session_seq_idx` ON `session_message` (`session_id`,`seq`);