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.
- claude_opencode_sessions/__init__.py +10 -0
- claude_opencode_sessions/__main__.py +3 -0
- claude_opencode_sessions/cli.py +196 -0
- claude_opencode_sessions/cli_backend.py +140 -0
- claude_opencode_sessions/errors.py +39 -0
- claude_opencode_sessions/import_command.py +216 -0
- claude_opencode_sessions/importer.py +414 -0
- claude_opencode_sessions/locate.py +90 -0
- claude_opencode_sessions/models.py +96 -0
- claude_opencode_sessions/parse.py +224 -0
- claude_opencode_sessions/py.typed +0 -0
- claude_opencode_sessions/render.py +169 -0
- claude_opencode_sessions/scope.py +160 -0
- claude_opencode_sessions/service.py +149 -0
- claude_opencode_sessions/sqlite_backend.py +249 -0
- claude_opencode_sessions/tests/__init__.py +0 -0
- claude_opencode_sessions/tests/conftest.py +314 -0
- claude_opencode_sessions/tests/fixtures/schema_1x.sql +76 -0
- claude_opencode_sessions/tests/fixtures/schema_2x.sql +37 -0
- claude_opencode_sessions/tests/test_cli.py +140 -0
- claude_opencode_sessions/tests/test_cli_backend.py +116 -0
- claude_opencode_sessions/tests/test_importer.py +504 -0
- claude_opencode_sessions/tests/test_launcher.py +47 -0
- claude_opencode_sessions/tests/test_locate.py +62 -0
- claude_opencode_sessions/tests/test_parse.py +120 -0
- claude_opencode_sessions/tests/test_render.py +107 -0
- claude_opencode_sessions/tests/test_scope.py +121 -0
- claude_opencode_sessions/tests/test_sqlite_backend.py +157 -0
- claude_opencode_sessions-0.1.0.dist-info/METADATA +287 -0
- claude_opencode_sessions-0.1.0.dist-info/RECORD +33 -0
- claude_opencode_sessions-0.1.0.dist-info/WHEEL +4 -0
- claude_opencode_sessions-0.1.0.dist-info/entry_points.txt +2 -0
- claude_opencode_sessions-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Import opencode sessions as Claude Code conversations.
|
|
2
|
+
|
|
3
|
+
Each imported session becomes a Claude Code transcript
|
|
4
|
+
(``<claude config>/projects/<project>/<uuid>.jsonl``), so it shows up in
|
|
5
|
+
``/resume`` / ``claude --resume`` and can be continued like any Claude
|
|
6
|
+
session. Nothing is ever overwritten:
|
|
7
|
+
|
|
8
|
+
* The Claude session id is derived from the opencode session id **and** a
|
|
9
|
+
hash of its content (``uuid5``). Importing an unchanged session again
|
|
10
|
+
maps to the same file, which already exists, so it is skipped.
|
|
11
|
+
* A session whose content changed since the last import gets a new file,
|
|
12
|
+
titled with the next free suffix: ``Title``, ``Title (1)``, ``Title (2)``…
|
|
13
|
+
* Files are written to a temporary name and then hard-linked into place,
|
|
14
|
+
which fails instead of replacing an existing file.
|
|
15
|
+
|
|
16
|
+
Claude Code's transcript format is internal; this module writes the minimal
|
|
17
|
+
subset it needs (a ``custom-title`` entry plus a chain of ``user`` /
|
|
18
|
+
``assistant`` messages). Tool calls are summarised as text.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import datetime as dt
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import subprocess
|
|
29
|
+
import uuid
|
|
30
|
+
from collections.abc import Iterable
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from .errors import OpencodeSessionsError
|
|
36
|
+
from .models import Message, Session
|
|
37
|
+
from .render import tool_summary, truncate
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"MARKER_KEY",
|
|
41
|
+
"TITLE_PREFIX",
|
|
42
|
+
"ImportResult",
|
|
43
|
+
"build_conversation",
|
|
44
|
+
"claude_project_dir",
|
|
45
|
+
"content_hash",
|
|
46
|
+
"existing_imports",
|
|
47
|
+
"import_session",
|
|
48
|
+
"project_dir_name",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
MARKER_KEY = "opencodeImport"
|
|
52
|
+
TITLE_PREFIX = "[opencode] "
|
|
53
|
+
FORMAT_VERSION = 1
|
|
54
|
+
_NAMESPACE = uuid.UUID("0c7e4a52-3d1b-5b8e-9a6f-6f70656e636f")
|
|
55
|
+
_SYNTHETIC_MODEL = "<synthetic>"
|
|
56
|
+
_MAX_DIR_NAME = 200
|
|
57
|
+
_TOOL_OUTPUT_LIMIT = 2000
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ImportFailedError(OpencodeSessionsError):
|
|
61
|
+
"""Import-specific failure (e.g. the Claude project dir can't be found)."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# -- locating Claude's project directory ------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def claude_config_dir() -> Path:
|
|
68
|
+
env = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
69
|
+
return Path(env).expanduser() if env else Path.home() / ".claude"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def project_dir_name(project_root: str) -> str:
|
|
73
|
+
"""Claude Code's directory name for a project path (non-alnum → ``-``)."""
|
|
74
|
+
return re.sub(r"[^a-zA-Z0-9]", "-", project_root)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def claude_project_dir(
|
|
78
|
+
project_root: str,
|
|
79
|
+
*,
|
|
80
|
+
transcript_path: str | None = None,
|
|
81
|
+
explicit: str | None = None,
|
|
82
|
+
) -> Path:
|
|
83
|
+
"""Where Claude Code keeps transcripts for ``project_root``.
|
|
84
|
+
|
|
85
|
+
Preference: explicit path; the directory of the running session's
|
|
86
|
+
transcript (exact, handles every Claude Code setting); the derived name.
|
|
87
|
+
"""
|
|
88
|
+
if explicit:
|
|
89
|
+
return Path(explicit).expanduser()
|
|
90
|
+
if transcript_path:
|
|
91
|
+
return Path(transcript_path).expanduser().parent
|
|
92
|
+
projects = claude_config_dir() / "projects"
|
|
93
|
+
name = project_dir_name(project_root)
|
|
94
|
+
if len(name) <= _MAX_DIR_NAME:
|
|
95
|
+
return projects / name
|
|
96
|
+
# Long paths get a hash suffix we can't reproduce; reuse an existing dir.
|
|
97
|
+
candidates = sorted(projects.glob(name[:_MAX_DIR_NAME] + "-*"))
|
|
98
|
+
if len(candidates) == 1:
|
|
99
|
+
return candidates[0]
|
|
100
|
+
raise ImportFailedError(
|
|
101
|
+
f"can't determine Claude's project directory for {project_root} "
|
|
102
|
+
"(path too long); pass --claude-project-dir"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# -- conversation ------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Turn:
|
|
111
|
+
role: str
|
|
112
|
+
text: str
|
|
113
|
+
time_ms: int | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _user_text(message: Message) -> str:
|
|
117
|
+
chunks = []
|
|
118
|
+
for part in message.parts:
|
|
119
|
+
if part.kind == "text" and not part.synthetic and part.text.strip():
|
|
120
|
+
chunks.append(part.text.strip())
|
|
121
|
+
elif part.kind == "file" and part.text:
|
|
122
|
+
chunks.append(f"[attached: {part.text}]")
|
|
123
|
+
return "\n\n".join(chunks)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _assistant_text(message: Message, with_tool_output: bool) -> str:
|
|
127
|
+
chunks: list[str] = []
|
|
128
|
+
for part in message.parts:
|
|
129
|
+
if part.kind == "text" and part.text.strip():
|
|
130
|
+
chunks.append(part.text.strip())
|
|
131
|
+
elif part.kind == "tool":
|
|
132
|
+
line = tool_summary(part)
|
|
133
|
+
if with_tool_output and part.tool_output:
|
|
134
|
+
output = truncate(part.tool_output.strip(), _TOOL_OUTPUT_LIMIT)
|
|
135
|
+
line += "\n" + "\n".join(" " + x for x in output.splitlines())
|
|
136
|
+
chunks.append(line)
|
|
137
|
+
elif part.kind == "compaction":
|
|
138
|
+
chunks.append("[context compacted]")
|
|
139
|
+
if message.error:
|
|
140
|
+
chunks.append(f"[error: {message.error}]")
|
|
141
|
+
return "\n\n".join(chunks)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_conversation(
|
|
145
|
+
messages: Iterable[Message], *, with_tool_output: bool = False
|
|
146
|
+
) -> list[Turn]:
|
|
147
|
+
"""Alternating user/assistant turns with plain-text content."""
|
|
148
|
+
turns: list[Turn] = []
|
|
149
|
+
for message in messages:
|
|
150
|
+
if message.role == "user":
|
|
151
|
+
role, text = "user", _user_text(message)
|
|
152
|
+
elif message.role == "assistant":
|
|
153
|
+
role, text = "assistant", _assistant_text(message, with_tool_output)
|
|
154
|
+
else:
|
|
155
|
+
continue
|
|
156
|
+
if not text:
|
|
157
|
+
continue
|
|
158
|
+
if turns and turns[-1].role == role:
|
|
159
|
+
turns[-1].text += "\n\n" + text
|
|
160
|
+
else:
|
|
161
|
+
turns.append(Turn(role, text, message.time_created))
|
|
162
|
+
return turns
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def content_hash(turns: list[Turn]) -> str:
|
|
166
|
+
payload = json.dumps(
|
|
167
|
+
[[t.role, t.text] for t in turns], ensure_ascii=False, separators=(",", ":")
|
|
168
|
+
)
|
|
169
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def claude_session_id(opencode_id: str, digest: str) -> str:
|
|
173
|
+
return str(uuid.uuid5(_NAMESPACE, f"{opencode_id}:{digest}"))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# -- existing imports -----------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class ExistingImport:
|
|
181
|
+
path: Path
|
|
182
|
+
opencode_id: str
|
|
183
|
+
content_hash: str
|
|
184
|
+
suffix: int
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _read_marker(path: Path) -> dict[str, Any] | None:
|
|
188
|
+
try:
|
|
189
|
+
with path.open(encoding="utf-8") as handle:
|
|
190
|
+
first = handle.readline()
|
|
191
|
+
except OSError:
|
|
192
|
+
return None
|
|
193
|
+
if MARKER_KEY not in first:
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
entry = json.loads(first)
|
|
197
|
+
except ValueError:
|
|
198
|
+
return None
|
|
199
|
+
marker = entry.get(MARKER_KEY) if isinstance(entry, dict) else None
|
|
200
|
+
return marker if isinstance(marker, dict) else None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def existing_imports(project_dir: Path) -> dict[str, list[ExistingImport]]:
|
|
204
|
+
"""Map opencode session id → imports already present in ``project_dir``."""
|
|
205
|
+
found: dict[str, list[ExistingImport]] = {}
|
|
206
|
+
if not project_dir.is_dir():
|
|
207
|
+
return found
|
|
208
|
+
for path in project_dir.glob("*.jsonl"):
|
|
209
|
+
marker = _read_marker(path)
|
|
210
|
+
if not marker or not marker.get("sessionId"):
|
|
211
|
+
continue
|
|
212
|
+
item = ExistingImport(
|
|
213
|
+
path=path,
|
|
214
|
+
opencode_id=str(marker["sessionId"]),
|
|
215
|
+
content_hash=str(marker.get("contentHash", "")),
|
|
216
|
+
suffix=int(marker.get("suffix", 0) or 0),
|
|
217
|
+
)
|
|
218
|
+
found.setdefault(item.opencode_id, []).append(item)
|
|
219
|
+
return found
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# -- writing -----------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _iso(ms: int | None) -> str:
|
|
226
|
+
moment = (
|
|
227
|
+
dt.datetime.fromtimestamp(ms / 1000, tz=dt.timezone.utc)
|
|
228
|
+
if ms
|
|
229
|
+
else dt.datetime.now(tz=dt.timezone.utc)
|
|
230
|
+
)
|
|
231
|
+
return moment.strftime("%Y-%m-%dT%H:%M:%S.") + f"{moment.microsecond // 1000:03d}Z"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _git_branch(path: str) -> str:
|
|
235
|
+
try:
|
|
236
|
+
result = subprocess.run(
|
|
237
|
+
["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
238
|
+
capture_output=True,
|
|
239
|
+
text=True,
|
|
240
|
+
timeout=5,
|
|
241
|
+
check=False,
|
|
242
|
+
)
|
|
243
|
+
except (OSError, subprocess.SubprocessError):
|
|
244
|
+
return ""
|
|
245
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _header(session: Session) -> str:
|
|
249
|
+
when = _iso(session.time_updated)[:16].replace("T", " ")
|
|
250
|
+
return (
|
|
251
|
+
f"[Imported from opencode session {session.id} — “{session.title}”, "
|
|
252
|
+
f"{session.directory}, last updated {when} UTC. Tool calls are summarised "
|
|
253
|
+
"as “→ tool: args” lines; their output is not included.]"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def transcript_entries(
|
|
258
|
+
session: Session,
|
|
259
|
+
turns: list[Turn],
|
|
260
|
+
*,
|
|
261
|
+
claude_id: str,
|
|
262
|
+
title: str,
|
|
263
|
+
digest: str,
|
|
264
|
+
suffix: int,
|
|
265
|
+
cwd: str,
|
|
266
|
+
git_branch: str = "",
|
|
267
|
+
) -> list[dict[str, Any]]:
|
|
268
|
+
marker = {
|
|
269
|
+
"sessionId": session.id,
|
|
270
|
+
"contentHash": digest,
|
|
271
|
+
"suffix": suffix,
|
|
272
|
+
"directory": session.directory,
|
|
273
|
+
"importedAt": _iso(None),
|
|
274
|
+
"formatVersion": FORMAT_VERSION,
|
|
275
|
+
}
|
|
276
|
+
entries: list[dict[str, Any]] = [
|
|
277
|
+
{
|
|
278
|
+
"type": "custom-title",
|
|
279
|
+
"customTitle": title,
|
|
280
|
+
"sessionId": claude_id,
|
|
281
|
+
MARKER_KEY: marker,
|
|
282
|
+
}
|
|
283
|
+
]
|
|
284
|
+
turns = list(turns)
|
|
285
|
+
if turns and turns[0].role != "user":
|
|
286
|
+
turns.insert(0, Turn("user", "(conversation started by the assistant)"))
|
|
287
|
+
if turns and turns[-1].role == "user":
|
|
288
|
+
turns.append(Turn("assistant", "[The opencode session ended here.]"))
|
|
289
|
+
parent: str | None = None
|
|
290
|
+
base = uuid.UUID(claude_id)
|
|
291
|
+
for index, turn in enumerate(turns):
|
|
292
|
+
entry_uuid = str(uuid.uuid5(base, str(index)))
|
|
293
|
+
text = turn.text
|
|
294
|
+
if index == 0:
|
|
295
|
+
text = _header(session) + "\n\n" + text
|
|
296
|
+
entry: dict[str, Any] = {
|
|
297
|
+
"parentUuid": parent,
|
|
298
|
+
"isSidechain": False,
|
|
299
|
+
"userType": "external",
|
|
300
|
+
"cwd": cwd,
|
|
301
|
+
"sessionId": claude_id,
|
|
302
|
+
"gitBranch": git_branch,
|
|
303
|
+
"type": turn.role,
|
|
304
|
+
"uuid": entry_uuid,
|
|
305
|
+
"timestamp": _iso(turn.time_ms),
|
|
306
|
+
}
|
|
307
|
+
if turn.role == "user":
|
|
308
|
+
entry["message"] = {"role": "user", "content": text}
|
|
309
|
+
else:
|
|
310
|
+
entry["message"] = {
|
|
311
|
+
"id": f"msg_opencode_{entry_uuid.replace('-', '')}",
|
|
312
|
+
"type": "message",
|
|
313
|
+
"role": "assistant",
|
|
314
|
+
"model": _SYNTHETIC_MODEL,
|
|
315
|
+
"content": [{"type": "text", "text": text}],
|
|
316
|
+
"stop_reason": "end_turn",
|
|
317
|
+
"stop_sequence": None,
|
|
318
|
+
"usage": {
|
|
319
|
+
"input_tokens": 0,
|
|
320
|
+
"output_tokens": 0,
|
|
321
|
+
"cache_creation_input_tokens": 0,
|
|
322
|
+
"cache_read_input_tokens": 0,
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
entries.append(entry)
|
|
326
|
+
parent = entry_uuid
|
|
327
|
+
return entries
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _write_new(path: Path, entries: list[dict[str, Any]]) -> bool:
|
|
331
|
+
"""Write ``path`` atomically; return False if it already exists."""
|
|
332
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
334
|
+
data = "".join(json.dumps(e, ensure_ascii=False) + "\n" for e in entries)
|
|
335
|
+
with tmp.open("x", encoding="utf-8") as handle:
|
|
336
|
+
handle.write(data)
|
|
337
|
+
handle.flush()
|
|
338
|
+
os.fsync(handle.fileno())
|
|
339
|
+
try:
|
|
340
|
+
os.link(tmp, path)
|
|
341
|
+
except FileExistsError:
|
|
342
|
+
return False
|
|
343
|
+
finally:
|
|
344
|
+
tmp.unlink()
|
|
345
|
+
return True
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# -- orchestration -------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@dataclass
|
|
352
|
+
class ImportResult:
|
|
353
|
+
session: Session
|
|
354
|
+
status: str # imported | unchanged | empty | would-import
|
|
355
|
+
title: str = ""
|
|
356
|
+
claude_id: str | None = None
|
|
357
|
+
path: Path | None = None
|
|
358
|
+
turns: int = 0
|
|
359
|
+
notes: list[str] = field(default_factory=list)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _title(session: Session, suffix: int) -> str:
|
|
363
|
+
title = TITLE_PREFIX + (session.title or session.id)
|
|
364
|
+
return f"{title} ({suffix})" if suffix else title
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def import_session(
|
|
368
|
+
session: Session,
|
|
369
|
+
messages: list[Message],
|
|
370
|
+
*,
|
|
371
|
+
project_dir: Path,
|
|
372
|
+
cwd: str,
|
|
373
|
+
existing: dict[str, list[ExistingImport]],
|
|
374
|
+
with_tool_output: bool = False,
|
|
375
|
+
dry_run: bool = False,
|
|
376
|
+
git_branch: str | None = None,
|
|
377
|
+
) -> ImportResult:
|
|
378
|
+
turns = build_conversation(messages, with_tool_output=with_tool_output)
|
|
379
|
+
if not turns:
|
|
380
|
+
return ImportResult(session, "empty")
|
|
381
|
+
digest = content_hash(turns)
|
|
382
|
+
claude_id = claude_session_id(session.id, digest)
|
|
383
|
+
path = project_dir / f"{claude_id}.jsonl"
|
|
384
|
+
previous = existing.get(session.id, [])
|
|
385
|
+
same = [p for p in previous if p.content_hash == digest]
|
|
386
|
+
if path.exists() or same:
|
|
387
|
+
found = path if path.exists() else same[0].path
|
|
388
|
+
return ImportResult(
|
|
389
|
+
session, "unchanged", claude_id=found.stem, path=found, turns=len(turns)
|
|
390
|
+
)
|
|
391
|
+
suffix = max((p.suffix for p in previous), default=-1) + 1
|
|
392
|
+
title = _title(session, suffix)
|
|
393
|
+
if dry_run:
|
|
394
|
+
return ImportResult(
|
|
395
|
+
session, "would-import", title, claude_id, path, turns=len(turns)
|
|
396
|
+
)
|
|
397
|
+
branch = _git_branch(cwd) if git_branch is None else git_branch
|
|
398
|
+
entries = transcript_entries(
|
|
399
|
+
session,
|
|
400
|
+
turns,
|
|
401
|
+
claude_id=claude_id,
|
|
402
|
+
title=title,
|
|
403
|
+
digest=digest,
|
|
404
|
+
suffix=suffix,
|
|
405
|
+
cwd=cwd,
|
|
406
|
+
git_branch=branch,
|
|
407
|
+
)
|
|
408
|
+
if not _write_new(path, entries):
|
|
409
|
+
return ImportResult(
|
|
410
|
+
session, "unchanged", claude_id=claude_id, path=path, turns=len(turns)
|
|
411
|
+
)
|
|
412
|
+
record = ExistingImport(path, session.id, digest, suffix)
|
|
413
|
+
existing.setdefault(session.id, []).append(record)
|
|
414
|
+
return ImportResult(session, "imported", title, claude_id, path, len(turns))
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Locate opencode's SQLite database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .errors import DatabaseNotFoundError
|
|
11
|
+
|
|
12
|
+
__all__ = ["candidate_paths", "find_db", "opencode_db_path"]
|
|
13
|
+
|
|
14
|
+
ENV_VAR = "OPENCODE_SESSIONS_DB"
|
|
15
|
+
DB_NAME = "opencode.db"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def candidate_paths() -> list[Path]:
|
|
19
|
+
"""Default locations, most specific first (opencode uses XDG dirs)."""
|
|
20
|
+
paths: list[Path] = []
|
|
21
|
+
xdg = os.environ.get("XDG_DATA_HOME")
|
|
22
|
+
if xdg:
|
|
23
|
+
paths.append(Path(xdg) / "opencode" / DB_NAME)
|
|
24
|
+
paths.append(Path.home() / ".local" / "share" / "opencode" / DB_NAME)
|
|
25
|
+
for var in ("LOCALAPPDATA", "APPDATA"):
|
|
26
|
+
base = os.environ.get(var)
|
|
27
|
+
if base:
|
|
28
|
+
paths.append(Path(base) / "opencode" / DB_NAME)
|
|
29
|
+
unique: list[Path] = []
|
|
30
|
+
for path in paths:
|
|
31
|
+
if path not in unique:
|
|
32
|
+
unique.append(path)
|
|
33
|
+
return unique
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def opencode_db_path(executable: str = "opencode", timeout: float = 15) -> Path | None:
|
|
37
|
+
"""Ask ``opencode db path`` (available in recent opencode versions)."""
|
|
38
|
+
exe = shutil.which(executable)
|
|
39
|
+
if not exe:
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
[exe, "db", "path"],
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
timeout=timeout,
|
|
47
|
+
check=False,
|
|
48
|
+
)
|
|
49
|
+
except (OSError, subprocess.SubprocessError):
|
|
50
|
+
return None
|
|
51
|
+
if result.returncode != 0:
|
|
52
|
+
return None
|
|
53
|
+
for line in reversed(result.stdout.strip().splitlines()):
|
|
54
|
+
line = line.strip()
|
|
55
|
+
if line.endswith(".db") and Path(line).is_file():
|
|
56
|
+
return Path(line)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def find_db(
|
|
61
|
+
explicit: str | os.PathLike[str] | None = None, ask_cli: bool = True
|
|
62
|
+
) -> Path:
|
|
63
|
+
"""Return the database path or raise :class:`DatabaseNotFoundError`.
|
|
64
|
+
|
|
65
|
+
Order: explicit argument, ``$OPENCODE_SESSIONS_DB``, XDG/default paths,
|
|
66
|
+
then ``opencode db path``.
|
|
67
|
+
"""
|
|
68
|
+
if explicit:
|
|
69
|
+
path = Path(explicit).expanduser()
|
|
70
|
+
if path.is_file():
|
|
71
|
+
return path
|
|
72
|
+
raise DatabaseNotFoundError(f"opencode database not found: {path}")
|
|
73
|
+
env = os.environ.get(ENV_VAR)
|
|
74
|
+
if env:
|
|
75
|
+
path = Path(env).expanduser()
|
|
76
|
+
if path.is_file():
|
|
77
|
+
return path
|
|
78
|
+
raise DatabaseNotFoundError(f"{ENV_VAR} points to a missing file: {path}")
|
|
79
|
+
for path in candidate_paths():
|
|
80
|
+
if path.is_file():
|
|
81
|
+
return path
|
|
82
|
+
if ask_cli:
|
|
83
|
+
path_from_cli = opencode_db_path()
|
|
84
|
+
if path_from_cli:
|
|
85
|
+
return path_from_cli
|
|
86
|
+
searched = ", ".join(str(p) for p in candidate_paths())
|
|
87
|
+
raise DatabaseNotFoundError(
|
|
88
|
+
f"opencode database not found (looked in: {searched}). "
|
|
89
|
+
"Is opencode >= 1.2 installed and has it been run at least once?"
|
|
90
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Backend-independent data model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
__all__ = ["Message", "Part", "Session", "format_model"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_model(value: Any) -> str | None:
|
|
13
|
+
"""Render a model reference as ``provider/model``.
|
|
14
|
+
|
|
15
|
+
opencode stores models either as a JSON string/object
|
|
16
|
+
(``{"id": ..., "providerID": ...}``) or as separate fields.
|
|
17
|
+
"""
|
|
18
|
+
if value is None or value == "":
|
|
19
|
+
return None
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
stripped = value.strip()
|
|
22
|
+
if not stripped.startswith("{"):
|
|
23
|
+
return stripped
|
|
24
|
+
try:
|
|
25
|
+
value = json.loads(stripped)
|
|
26
|
+
except ValueError:
|
|
27
|
+
return stripped
|
|
28
|
+
if isinstance(value, dict):
|
|
29
|
+
model = value.get("id") or value.get("modelID")
|
|
30
|
+
provider = value.get("providerID")
|
|
31
|
+
if model and provider:
|
|
32
|
+
return f"{provider}/{model}"
|
|
33
|
+
if model:
|
|
34
|
+
return str(model)
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Session:
|
|
40
|
+
id: str
|
|
41
|
+
title: str
|
|
42
|
+
directory: str
|
|
43
|
+
project_id: str | None = None
|
|
44
|
+
parent_id: str | None = None
|
|
45
|
+
time_created: int | None = None
|
|
46
|
+
time_updated: int | None = None
|
|
47
|
+
time_archived: int | None = None
|
|
48
|
+
agent: str | None = None
|
|
49
|
+
model: str | None = None
|
|
50
|
+
version: str | None = None
|
|
51
|
+
message_count: int | None = None
|
|
52
|
+
cost: float | None = None
|
|
53
|
+
tokens_input: int | None = None
|
|
54
|
+
tokens_output: int | None = None
|
|
55
|
+
origin: str = "sqlite"
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def is_subagent(self) -> bool:
|
|
59
|
+
return bool(self.parent_id)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_archived(self) -> bool:
|
|
63
|
+
return bool(self.time_archived)
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict[str, Any]:
|
|
66
|
+
data = asdict(self)
|
|
67
|
+
data["is_subagent"] = self.is_subagent
|
|
68
|
+
data["is_archived"] = self.is_archived
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Part:
|
|
74
|
+
kind: str
|
|
75
|
+
text: str = ""
|
|
76
|
+
tool: str | None = None
|
|
77
|
+
tool_input: Any = None
|
|
78
|
+
tool_output: str | None = None
|
|
79
|
+
status: str | None = None
|
|
80
|
+
title: str | None = None
|
|
81
|
+
files: list[str] = field(default_factory=list)
|
|
82
|
+
synthetic: bool = False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Message:
|
|
87
|
+
id: str
|
|
88
|
+
role: str
|
|
89
|
+
time_created: int | None = None
|
|
90
|
+
parts: list[Part] = field(default_factory=list)
|
|
91
|
+
seq: int | None = None
|
|
92
|
+
model: str | None = None
|
|
93
|
+
agent: str | None = None
|
|
94
|
+
error: str | None = None
|
|
95
|
+
tokens: dict[str, Any] | None = None
|
|
96
|
+
cost: float | None = None
|