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,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