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,224 @@
1
+ """Parsers turning opencode's stored JSON into :mod:`models` objects.
2
+
3
+ Two shapes exist:
4
+
5
+ * opencode 1.x (SQLite era, >= 1.2) and ``opencode export``: a message
6
+ ``info`` object plus a list of ``part`` objects.
7
+ * opencode 2.x: one ``session_message`` row per event, with the role in the
8
+ ``type`` column and a JSON ``data`` payload.
9
+
10
+ Every accessor is defensive: unknown shapes degrade to generic parts
11
+ instead of raising.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import Any
18
+
19
+ from .models import Message, Part, format_model
20
+
21
+ __all__ = [
22
+ "as_dict",
23
+ "as_list",
24
+ "loads",
25
+ "parse_v1_message",
26
+ "parse_v1_part",
27
+ "parse_v2_row",
28
+ ]
29
+
30
+ _SKIP_V1_PARTS = {"step-start", "step-finish", "snapshot"}
31
+
32
+
33
+ def as_dict(value: Any) -> dict[str, Any]:
34
+ """``value`` if it is a dict, else an empty dict."""
35
+ return value if isinstance(value, dict) else {}
36
+
37
+
38
+ def as_list(value: Any) -> list[Any]:
39
+ """``value`` if it is a list, else an empty list."""
40
+ return value if isinstance(value, list) else []
41
+
42
+
43
+ def loads(raw: Any) -> Any:
44
+ """``json.loads`` that returns ``None`` instead of raising."""
45
+ if raw is None:
46
+ return None
47
+ if isinstance(raw, (dict, list)):
48
+ return raw
49
+ try:
50
+ return json.loads(raw)
51
+ except (TypeError, ValueError):
52
+ return None
53
+
54
+
55
+ def _str(value: Any) -> str:
56
+ if value is None:
57
+ return ""
58
+ if isinstance(value, str):
59
+ return value
60
+ try:
61
+ return json.dumps(value, ensure_ascii=False)
62
+ except (TypeError, ValueError):
63
+ return str(value)
64
+
65
+
66
+ def _error_text(error: Any) -> str | None:
67
+ if not error:
68
+ return None
69
+ if isinstance(error, dict):
70
+ name = error.get("name") or "Error"
71
+ data = error.get("data")
72
+ message = data.get("message") if isinstance(data, dict) else None
73
+ message = message or error.get("message")
74
+ return f"{name}: {message}" if message else str(name)
75
+ return _str(error)
76
+
77
+
78
+ def parse_v1_part(part: Any) -> Part | None:
79
+ """Parse one 1.x ``part`` object. Returns ``None`` for bookkeeping parts."""
80
+ if not isinstance(part, dict):
81
+ return Part(kind="other", text=_str(part))
82
+ kind = part.get("type") or "other"
83
+ if kind in _SKIP_V1_PARTS:
84
+ return None
85
+ if kind in ("text", "reasoning"):
86
+ return Part(
87
+ kind=kind,
88
+ text=_str(part.get("text")),
89
+ synthetic=bool(part.get("synthetic") or part.get("ignored")),
90
+ )
91
+ if kind == "tool":
92
+ state = as_dict(part.get("state"))
93
+ output = state.get("output")
94
+ if output is None and state.get("error") is not None:
95
+ output = state.get("error")
96
+ return Part(
97
+ kind="tool",
98
+ tool=_str(part.get("tool")) or "tool",
99
+ tool_input=state.get("input"),
100
+ tool_output=None if output is None else _str(output),
101
+ status=state.get("status"),
102
+ title=state.get("title"),
103
+ )
104
+ if kind == "file":
105
+ name = part.get("filename") or part.get("url") or ""
106
+ return Part(kind="file", text=_str(name), files=[_str(name)] if name else [])
107
+ if kind == "patch":
108
+ files = as_list(part.get("files"))
109
+ return Part(kind="patch", files=[_str(f) for f in files])
110
+ if kind == "compaction":
111
+ return Part(kind="compaction")
112
+ if kind in ("agent", "subtask"):
113
+ text = part.get("prompt") or part.get("description") or part.get("name")
114
+ return Part(kind=kind, text=_str(text))
115
+ return Part(kind=_str(kind), text=_str(part.get("text")))
116
+
117
+
118
+ def parse_v1_message(info: Any, parts: list[Any], seq: int | None = None) -> Message:
119
+ """Parse a 1.x message ``info`` object together with its parts."""
120
+ info = info if isinstance(info, dict) else {}
121
+ model = format_model(info.get("model"))
122
+ if model is None and info.get("modelID"):
123
+ model = format_model(
124
+ {"id": info.get("modelID"), "providerID": info.get("providerID")}
125
+ )
126
+ time = as_dict(info.get("time"))
127
+ tokens = as_dict(info.get("tokens")) or None
128
+ cost = info.get("cost")
129
+ parsed = [p for p in (parse_v1_part(raw) for raw in parts) if p is not None]
130
+ return Message(
131
+ id=_str(info.get("id")),
132
+ role=_str(info.get("role")) or "unknown",
133
+ time_created=time.get("created"),
134
+ parts=parsed,
135
+ seq=seq,
136
+ model=model,
137
+ agent=info.get("agent") or info.get("mode"),
138
+ error=_error_text(info.get("error")),
139
+ tokens=tokens,
140
+ cost=cost if isinstance(cost, (int, float)) else None,
141
+ )
142
+
143
+
144
+ def _v2_content_part(item: Any) -> Part | None:
145
+ if isinstance(item, str):
146
+ return Part(kind="text", text=item)
147
+ if not isinstance(item, dict):
148
+ return None
149
+ kind = item.get("type")
150
+ if kind in ("text", "reasoning") or (kind is None and "text" in item):
151
+ return Part(
152
+ kind=kind or "text",
153
+ text=_str(item.get("text")),
154
+ synthetic=bool(item.get("synthetic")),
155
+ )
156
+ is_tool = "tool" in item or (isinstance(kind, str) and kind.startswith("tool"))
157
+ if is_tool:
158
+ state = as_dict(item.get("state")) or item
159
+ output = state.get("output", state.get("result"))
160
+ if output is None and state.get("error") is not None:
161
+ output = state.get("error")
162
+ return Part(
163
+ kind="tool",
164
+ tool=_str(item.get("tool") or item.get("name")) or "tool",
165
+ tool_input=state.get("input", state.get("args")),
166
+ tool_output=None if output is None else _str(output),
167
+ status=state.get("status"),
168
+ title=state.get("title"),
169
+ )
170
+ return parse_v1_part(item)
171
+
172
+
173
+ def parse_v2_row(
174
+ row_id: str,
175
+ row_type: str,
176
+ data_raw: Any,
177
+ seq: int | None,
178
+ time_created: int | None,
179
+ ) -> tuple[Message, bool]:
180
+ """Parse one 2.x ``session_message`` row.
181
+
182
+ Returns the message and whether its JSON payload parsed cleanly.
183
+ """
184
+ data = loads(data_raw)
185
+ ok = isinstance(data, dict)
186
+ data = data if isinstance(data, dict) else {}
187
+
188
+ parts: list[Part] = []
189
+ text = data.get("text")
190
+ if isinstance(text, str) and text:
191
+ parts.append(Part(kind="text", text=text, synthetic=row_type == "synthetic"))
192
+ for key in ("content", "parts"):
193
+ value = data.get(key)
194
+ if isinstance(value, str) and value:
195
+ parts.append(Part(kind="text", text=value))
196
+ elif isinstance(value, list):
197
+ parts.extend(p for p in map(_v2_content_part, value) if p is not None)
198
+ if "tool" in data and not parts:
199
+ tool_part = _v2_content_part(data)
200
+ if tool_part is not None:
201
+ parts.append(tool_part)
202
+ if not ok:
203
+ parts.append(Part(kind="other", text="[unparseable message payload]"))
204
+
205
+ model = format_model(data.get("model"))
206
+ if model is None and data.get("modelID"):
207
+ model = format_model(
208
+ {"id": data.get("modelID"), "providerID": data.get("providerID")}
209
+ )
210
+ tokens = as_dict(data.get("tokens")) or None
211
+ cost = data.get("cost")
212
+ message = Message(
213
+ id=row_id,
214
+ role=row_type or "unknown",
215
+ time_created=time_created,
216
+ parts=parts,
217
+ seq=seq,
218
+ model=model,
219
+ agent=data.get("agent"),
220
+ error=_error_text(data.get("error")),
221
+ tokens=tokens,
222
+ cost=cost if isinstance(cost, (int, float)) else None,
223
+ )
224
+ return message, ok
File without changes
@@ -0,0 +1,169 @@
1
+ """Plain-text / Markdown rendering helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from datetime import datetime
9
+
10
+ from .models import Part, Session
11
+ from .scope import Scope, normalize
12
+
13
+ __all__ = [
14
+ "absolute_time",
15
+ "cell",
16
+ "display_dir",
17
+ "relative_time",
18
+ "render_list",
19
+ "tilde",
20
+ "tool_summary",
21
+ "truncate",
22
+ ]
23
+
24
+
25
+ def _now_ms() -> int:
26
+ return int(time.time() * 1000)
27
+
28
+
29
+ def relative_time(ms: int | None, now_ms: int | None = None) -> str:
30
+ if not ms:
31
+ return "?"
32
+ now_ms = now_ms if now_ms is not None else _now_ms()
33
+ delta = max(0, (now_ms - ms) // 1000)
34
+ if delta < 60:
35
+ return "just now"
36
+ for size, unit in ((86400, "d"), (3600, "h"), (60, "m")):
37
+ if delta >= size:
38
+ if unit == "d" and delta >= 30 * 86400:
39
+ return absolute_time(ms, date_only=True)
40
+ return f"{delta // size}{unit} ago"
41
+ return "just now" # pragma: no cover
42
+
43
+
44
+ def absolute_time(ms: int | None, date_only: bool = False) -> str:
45
+ if not ms:
46
+ return "?"
47
+ moment = datetime.fromtimestamp(ms / 1000)
48
+ return moment.strftime("%Y-%m-%d" if date_only else "%Y-%m-%d %H:%M")
49
+
50
+
51
+ def truncate(text: str, limit: int) -> str:
52
+ if limit <= 0 or len(text) <= limit:
53
+ return text
54
+ return text[:limit].rstrip() + f"\n… [{len(text) - limit} more chars]"
55
+
56
+
57
+ def cell(text: str, limit: int = 70) -> str:
58
+ text = " ".join(str(text).split())
59
+ if len(text) > limit:
60
+ text = text[: limit - 1] + "…"
61
+ return text.replace("|", "\\|")
62
+
63
+
64
+ def tilde(path: str) -> str:
65
+ home = os.path.expanduser("~")
66
+ if path == home or path.startswith(home + os.sep):
67
+ return "~" + path[len(home) :]
68
+ return path
69
+
70
+
71
+ def display_dir(directory: str, scope: Scope) -> str:
72
+ if not directory:
73
+ return "?"
74
+ path = normalize(directory)
75
+ if scope.mode != "all":
76
+ for root in scope.roots:
77
+ if path == root:
78
+ return "." if root == scope.root else tilde(root)
79
+ if path.startswith(root + os.sep):
80
+ rel = os.path.relpath(path, root)
81
+ return rel if root == scope.root else tilde(path)
82
+ return tilde(path)
83
+
84
+
85
+ _INPUT_KEYS = (
86
+ "filePath",
87
+ "file_path",
88
+ "path",
89
+ "command",
90
+ "pattern",
91
+ "url",
92
+ "query",
93
+ "description",
94
+ "prompt",
95
+ )
96
+
97
+
98
+ def tool_summary(part: Part) -> str:
99
+ """One-line description of a tool call."""
100
+ name = part.tool or "tool"
101
+ args = part.tool_input
102
+ detail = ""
103
+ if isinstance(args, dict):
104
+ for key in _INPUT_KEYS:
105
+ value = args.get(key)
106
+ if isinstance(value, str) and value.strip():
107
+ lines = value.strip().splitlines()
108
+ detail = lines[0] + (" …" if len(lines) > 1 else "")
109
+ break
110
+ if not detail and args:
111
+ detail = json.dumps(args, ensure_ascii=False)
112
+ elif isinstance(args, str):
113
+ detail = args.strip().splitlines()[0] if args.strip() else ""
114
+ if not detail and part.title:
115
+ detail = part.title
116
+ detail = cell(detail, 120).replace("\\|", "|")
117
+ status = f" ({part.status})" if part.status and part.status != "completed" else ""
118
+ return f"→ {name}: `{detail}`{status}" if detail else f"→ {name}{status}"
119
+
120
+
121
+ def render_list(
122
+ sessions: list[Session],
123
+ scope: Scope,
124
+ *,
125
+ hidden_archived: int = 0,
126
+ hidden_subagents: int = 0,
127
+ total: int | None = None,
128
+ backend: str = "",
129
+ notes: list[str] | None = None,
130
+ now_ms: int | None = None,
131
+ ) -> str:
132
+ lines = [f"opencode sessions — {scope.describe()}", ""]
133
+ if not sessions:
134
+ lines.append("No opencode sessions found in this scope.")
135
+ if scope.mode == "worktree":
136
+ lines.append("Try `--worktrees` (all worktrees) or `--all` (everything).")
137
+ else:
138
+ lines.append("| # | Title | Updated | Msgs | Agent · Model | Dir | ID |")
139
+ lines.append("|---|---|---|---|---|---|---|")
140
+ for index, s in enumerate(sessions, start=1):
141
+ flags = []
142
+ if s.is_archived:
143
+ flags.append("archived")
144
+ if s.is_subagent:
145
+ flags.append("sub-agent")
146
+ title = cell(s.title) + (f" _({', '.join(flags)})_" if flags else "")
147
+ agent = " · ".join(x for x in (s.agent, s.model) if x) or "?"
148
+ msgs = "?" if s.message_count is None else str(s.message_count)
149
+ where = cell(display_dir(s.directory, scope), 50)
150
+ lines.append(
151
+ f"| {index} | {title} | {relative_time(s.time_updated, now_ms)} | "
152
+ f"{msgs} | {cell(agent, 50)} | {where} | `{s.id}` |"
153
+ )
154
+ footer = []
155
+ if total is not None and total > len(sessions):
156
+ footer.append(f"showing {len(sessions)} of {total} (use -n to show more)")
157
+ hidden = []
158
+ if hidden_archived:
159
+ hidden.append(f"{hidden_archived} archived (--include-archived)")
160
+ if hidden_subagents:
161
+ hidden.append(f"{hidden_subagents} sub-agent (--include-subagents)")
162
+ if hidden:
163
+ footer.append("hidden: " + ", ".join(hidden))
164
+ if backend:
165
+ footer.append(f"backend: {backend}")
166
+ lines.append("")
167
+ lines.extend(f"_{line}_" for line in footer)
168
+ lines.extend(f"_note: {note}_" for note in notes or [])
169
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,160 @@
1
+ """Decide which sessions belong to "this repository".
2
+
3
+ Mirrors Claude Code's ``/resume`` picker:
4
+
5
+ * ``worktree`` (default): the current git worktree, i.e. its top-level
6
+ directory and everything below it. Outside git: the directory and below.
7
+ * ``repo``: every worktree of the current repository (``Ctrl+W`` in
8
+ ``/resume``), plus sessions of the same opencode project whose worktree
9
+ directory no longer exists.
10
+ * ``all``: every session on this machine (``Ctrl+A`` in ``/resume``).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from collections.abc import Iterable
20
+ from dataclasses import dataclass, field
21
+
22
+ from .models import Session
23
+
24
+ __all__ = [
25
+ "MODES",
26
+ "Scope",
27
+ "git_toplevel",
28
+ "git_worktrees",
29
+ "is_within",
30
+ "normalize",
31
+ "path_key",
32
+ "resolve_scope",
33
+ ]
34
+
35
+ MODES = ("worktree", "repo", "all")
36
+ _CASE_INSENSITIVE = sys.platform in ("darwin", "win32")
37
+
38
+
39
+ def normalize(path: str) -> str:
40
+ """Absolute, symlink-resolved path without a trailing separator.
41
+
42
+ Also repairs directories stored without their leading ``/`` (a known
43
+ opencode migration bug).
44
+ """
45
+ if not path:
46
+ return ""
47
+ path = os.path.expanduser(path)
48
+ if not os.path.isabs(path) and os.sep == "/" and not path.startswith("."):
49
+ path = "/" + path
50
+ resolved = os.path.realpath(path)
51
+ if len(resolved) > 1:
52
+ resolved = resolved.rstrip(os.sep)
53
+ return resolved
54
+
55
+
56
+ def path_key(path: str) -> str:
57
+ """Comparison key for a normalised path (case-folded on macOS/Windows)."""
58
+ return path.casefold() if _CASE_INSENSITIVE else path
59
+
60
+
61
+ def is_within(child: str, parent: str) -> bool:
62
+ """True when ``child`` equals ``parent`` or lives below it (keys)."""
63
+ if child == parent:
64
+ return True
65
+ prefix = parent if parent.endswith(os.sep) else parent + os.sep
66
+ return child.startswith(prefix)
67
+
68
+
69
+ def _git(args: list[str], cwd: str) -> str | None:
70
+ git = shutil.which("git")
71
+ if not git or not os.path.isdir(cwd):
72
+ return None
73
+ try:
74
+ result = subprocess.run(
75
+ [git, "-C", cwd, *args],
76
+ capture_output=True,
77
+ text=True,
78
+ timeout=10,
79
+ check=False,
80
+ )
81
+ except (OSError, subprocess.SubprocessError):
82
+ return None
83
+ return result.stdout if result.returncode == 0 else None
84
+
85
+
86
+ def git_toplevel(path: str) -> str | None:
87
+ out = _git(["rev-parse", "--show-toplevel"], path)
88
+ return normalize(out.strip()) if out and out.strip() else None
89
+
90
+
91
+ def git_worktrees(path: str) -> list[str]:
92
+ out = _git(["worktree", "list", "--porcelain"], path)
93
+ if not out:
94
+ return []
95
+ return [
96
+ normalize(line[len("worktree ") :])
97
+ for line in out.splitlines()
98
+ if line.startswith("worktree ")
99
+ ]
100
+
101
+
102
+ @dataclass
103
+ class Scope:
104
+ mode: str
105
+ cwd: str
106
+ roots: list[str] = field(default_factory=list)
107
+ in_git: bool = False
108
+
109
+ @property
110
+ def root(self) -> str:
111
+ return self.roots[0] if self.roots else self.cwd
112
+
113
+ def describe(self) -> str:
114
+ if self.mode == "all":
115
+ return "all sessions on this machine"
116
+ if self.mode == "repo":
117
+ count = len(self.roots)
118
+ return f"all worktrees of {self.root} ({count} worktree(s))"
119
+ kind = "git worktree" if self.in_git else "directory"
120
+ return f"{kind} {self.root}"
121
+
122
+ def matches(self, directory: str) -> bool:
123
+ if self.mode == "all":
124
+ return True
125
+ key = path_key(normalize(directory))
126
+ return any(is_within(key, path_key(root)) for root in self.roots)
127
+
128
+ def filter(self, sessions: Iterable[Session]) -> list[Session]:
129
+ sessions = list(sessions)
130
+ if self.mode == "all":
131
+ return sessions
132
+ selected = [s for s in sessions if self.matches(s.directory)]
133
+ if self.mode == "repo":
134
+ projects = {s.project_id for s in selected if s.project_id} - {"global"}
135
+ chosen = {s.id for s in selected}
136
+ selected.extend(
137
+ s
138
+ for s in sessions
139
+ if s.id not in chosen
140
+ and s.project_id in projects
141
+ and not os.path.isdir(normalize(s.directory))
142
+ )
143
+ return selected
144
+
145
+
146
+ def resolve_scope(path: str | None = None, mode: str = "worktree") -> Scope:
147
+ if mode not in MODES:
148
+ raise ValueError(f"unknown scope {mode!r}; expected one of {MODES}")
149
+ cwd = normalize(path or os.getcwd())
150
+ top = git_toplevel(cwd)
151
+ if mode == "all":
152
+ return Scope(mode, cwd, [top or cwd], in_git=bool(top))
153
+ if top is None:
154
+ return Scope(mode, cwd, [cwd], in_git=False)
155
+ if mode == "repo":
156
+ roots = git_worktrees(top) or [top]
157
+ if top in roots:
158
+ roots.remove(top)
159
+ return Scope(mode, cwd, [top, *roots], in_git=True)
160
+ return Scope(mode, cwd, [top], in_git=True)
@@ -0,0 +1,149 @@
1
+ """Glue between backends, scoping and the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from .cli_backend import CliBackend
10
+ from .errors import (
11
+ BackendError,
12
+ OpencodeSessionsError,
13
+ )
14
+ from .locate import find_db
15
+ from .models import Message, Session
16
+ from .scope import Scope
17
+ from .sqlite_backend import SqliteBackend
18
+
19
+ __all__ = [
20
+ "BACKENDS",
21
+ "PARSE_ERROR_THRESHOLD",
22
+ "Context",
23
+ "Listing",
24
+ "open_context",
25
+ ]
26
+
27
+ BACKENDS = ("auto", "sqlite", "cli")
28
+ PARSE_ERROR_THRESHOLD = 0.2
29
+
30
+
31
+ @dataclass
32
+ class Listing:
33
+ shown: list[Session]
34
+ hidden_archived: int = 0
35
+ hidden_subagents: int = 0
36
+
37
+
38
+ @dataclass
39
+ class Context:
40
+ """An opened data source plus notes about how it was chosen."""
41
+
42
+ mode: str
43
+ sqlite: SqliteBackend | None = None
44
+ cli: CliBackend | None = None
45
+ notes: list[str] = field(default_factory=list)
46
+ _cli_tried: bool = False
47
+
48
+ @property
49
+ def backend_name(self) -> str:
50
+ return "sqlite" if self.sqlite else "cli"
51
+
52
+ def describe(self) -> str:
53
+ if self.sqlite:
54
+ return f"sqlite ({self.sqlite.describe()}) · db: {_tilde(self.sqlite.path)}"
55
+ assert self.cli is not None
56
+ return f"cli ({self.cli.describe()})"
57
+
58
+ def get_cli(self) -> CliBackend | None:
59
+ if self.cli is None and not self._cli_tried and self.mode != "sqlite":
60
+ self._cli_tried = True
61
+ try:
62
+ self.cli = CliBackend()
63
+ except OpencodeSessionsError as exc:
64
+ self.notes.append(f"cli unavailable: {exc}")
65
+ return self.cli
66
+
67
+ # -- sessions ---------------------------------------------------------
68
+
69
+ def all_sessions(self, cwd: str | None = None) -> list[Session]:
70
+ if self.sqlite:
71
+ sessions = self.sqlite.list_sessions()
72
+ else:
73
+ assert self.cli is not None
74
+ sessions = self.cli.list_sessions(cwd=cwd)
75
+ sessions.sort(key=lambda s: s.time_updated or 0, reverse=True)
76
+ return sessions
77
+
78
+ def listing(
79
+ self,
80
+ scope: Scope,
81
+ include_archived: bool = False,
82
+ include_subagents: bool = False,
83
+ ) -> Listing:
84
+ scoped = scope.filter(self.all_sessions(cwd=scope.cwd))
85
+ result = Listing(shown=[])
86
+ for session in scoped:
87
+ if session.is_archived and not include_archived:
88
+ result.hidden_archived += 1
89
+ elif session.is_subagent and not include_subagents:
90
+ result.hidden_subagents += 1
91
+ else:
92
+ result.shown.append(session)
93
+ return result
94
+
95
+ # -- messages ---------------------------------------------------------
96
+
97
+ def messages(self, session: Session) -> list[Message]:
98
+ if self.sqlite:
99
+ try:
100
+ messages, errors = self.sqlite.load_messages(session.id)
101
+ except sqlite3.Error as exc:
102
+ messages, errors = [], -1
103
+ self.notes.append(f"sqlite read failed: {exc}")
104
+ broken = errors < 0 or (
105
+ messages and errors / max(len(messages), 1) > PARSE_ERROR_THRESHOLD
106
+ )
107
+ if not broken:
108
+ return messages
109
+ cli = self.get_cli()
110
+ if cli is None:
111
+ if errors < 0:
112
+ raise BackendError("could not read messages; no fallback")
113
+ self.notes.append(f"{errors} message(s) could not be parsed")
114
+ return messages
115
+ self.notes.append("fell back to `opencode export` for this session")
116
+ return cli.load_messages(session.id)[0]
117
+ assert self.cli is not None
118
+ return self.cli.load_messages(session.id)[0]
119
+
120
+
121
+ def _tilde(path: Path) -> str:
122
+ try:
123
+ return "~/" + str(path.resolve().relative_to(Path.home().resolve()))
124
+ except ValueError:
125
+ return str(path)
126
+
127
+
128
+ def open_context(backend: str = "auto", db: str | None = None) -> Context:
129
+ """Open the requested backend (``auto`` = sqlite, falling back to cli)."""
130
+ if backend not in BACKENDS:
131
+ raise ValueError(f"unknown backend {backend!r}")
132
+ ctx = Context(mode=backend)
133
+ if backend in ("auto", "sqlite"):
134
+ try:
135
+ ctx.sqlite = SqliteBackend(find_db(db))
136
+ return ctx
137
+ except (OpencodeSessionsError, sqlite3.Error) as exc:
138
+ if backend == "sqlite":
139
+ raise
140
+ ctx.notes.append(f"sqlite unavailable: {exc}")
141
+ try:
142
+ ctx.cli = CliBackend()
143
+ except OpencodeSessionsError as exc:
144
+ ctx.notes.append(f"cli unavailable: {exc}")
145
+ raise BackendError("; ".join(ctx.notes)) from exc
146
+ ctx._cli_tried = True
147
+ if backend == "auto":
148
+ ctx.notes.append("using the opencode CLI fallback")
149
+ return ctx