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