agent-sessions-cli 0.2.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.
@@ -0,0 +1,231 @@
1
+ """OpenCode: SQLite at ~/.local/share/opencode/opencode.db (tables session, message, part)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import sqlite3
11
+ import tempfile
12
+ from typing import Dict, List, Optional, Tuple
13
+
14
+ from ..core import rel_to_root, under
15
+ from ..model import Event, PUSH_MARKER_RE, Session, ToolCall
16
+ from .base import Adapter, SessionRef, generic_label
17
+
18
+ KIND_BY_TOOL = {
19
+ "bash": "shell", "read": "read", "edit": "edit", "write": "write", "patch": "edit", "multiedit": "edit",
20
+ "glob": "search", "grep": "search", "list": "search", "webfetch": "web", "websearch": "web",
21
+ "task": "agent", "question": "ask",
22
+ }
23
+ SKIP_TOOLS = {"todowrite", "todoread", "skill", "step-start", "step-finish"}
24
+ DEFAULT_TITLE_RE = re.compile(r"^New session - \d{4}-\d{2}-\d{2}T", re.I)
25
+
26
+
27
+ def _ms(value: object) -> Optional[dt.datetime]:
28
+ if isinstance(value, (int, float)) and value > 0:
29
+ return dt.datetime.fromtimestamp(value / 1000.0, tz=dt.timezone.utc)
30
+ return None
31
+
32
+
33
+ class OpenCodeAdapter(Adapter):
34
+ name = "opencode"
35
+ label = "OpenCode"
36
+ tested_version = (1, 18, 23)
37
+
38
+ def db_path(self) -> str:
39
+ if os.environ.get("OPENCODE_DB"):
40
+ return os.path.abspath(os.path.expanduser(os.environ["OPENCODE_DB"]))
41
+ base = os.environ.get("OPENCODE_DATA_DIR") or os.path.join(os.environ.get("XDG_DATA_HOME") or "~/.local/share", "opencode")
42
+ return os.path.abspath(os.path.expanduser(os.path.join(base, "opencode.db")))
43
+
44
+ def data_dir(self) -> str:
45
+ return self.db_path()
46
+
47
+ def short_id(self, session_id: str) -> str:
48
+ # ids look like ses_fbb7bd4bdffekPeUCuVdJWvg6w; the tail is the random part
49
+ return session_id.split("_", 1)[-1][-8:].lower()
50
+
51
+ # -- db access
52
+
53
+ def _connect(self) -> sqlite3.Connection:
54
+ path = self.db_path()
55
+ con = sqlite3.connect(path, timeout=5)
56
+ try:
57
+ con.execute("PRAGMA query_only=1")
58
+ con.execute("SELECT 1 FROM session LIMIT 1")
59
+ return con
60
+ except sqlite3.OperationalError as e:
61
+ con.close()
62
+ if "locked" not in str(e).lower() and "busy" not in str(e).lower():
63
+ raise
64
+ # copy db + wal + shm and read the copy
65
+ tmp = tempfile.mkdtemp(prefix="agent-sessions-oc-")
66
+ for suffix in ("", "-wal", "-shm"):
67
+ src = path + suffix
68
+ if os.path.exists(src):
69
+ shutil.copy2(src, os.path.join(tmp, "opencode.db" + suffix))
70
+ con = sqlite3.connect(os.path.join(tmp, "opencode.db"), timeout=5)
71
+ con.execute("PRAGMA query_only=1")
72
+ return con
73
+
74
+ def _ref_from_row(self, row: Tuple) -> SessionRef:
75
+ sid, directory, title, time_updated, parent_id, version = row
76
+ return SessionRef(self.name, sid, self.short_id(sid), f"sqlite:{self.db_path()}#{sid}", cwd=directory, updated=_ms(time_updated),
77
+ title=None if not title or DEFAULT_TITLE_RE.match(title) else title,
78
+ extra={"parent_id": parent_id, "version": version})
79
+
80
+ def locate_by_id(self, session_id: str) -> Optional[SessionRef]:
81
+ if not self.installed():
82
+ return None
83
+ con = self._connect()
84
+ try:
85
+ row = con.execute("SELECT id, directory, title, time_updated, parent_id, version FROM session WHERE id=?", (session_id,)).fetchone()
86
+ finally:
87
+ con.close()
88
+ return self._ref_from_row(row) if row else None
89
+
90
+ def candidates(self, repo_root: str, limit: int = 5) -> List[SessionRef]:
91
+ if not self.installed():
92
+ return []
93
+ con = self._connect()
94
+ try:
95
+ rows = con.execute(
96
+ "SELECT s.id, s.directory, s.title, s.time_updated, s.parent_id, s.version, p.worktree "
97
+ "FROM session s LEFT JOIN project p ON p.id = s.project_id "
98
+ "WHERE s.parent_id IS NULL ORDER BY s.time_updated DESC LIMIT 200").fetchall()
99
+ finally:
100
+ con.close()
101
+ out: List[SessionRef] = []
102
+ for row in rows:
103
+ directory, worktree = row[1], row[6]
104
+ if under(directory, repo_root) or (worktree and worktree != "/" and under(worktree, repo_root)):
105
+ out.append(self._ref_from_row(row[:6]))
106
+ if len(out) >= limit:
107
+ break
108
+ return out
109
+
110
+ def ref_from_source(self, source: str) -> Optional[SessionRef]:
111
+ if source.startswith("sqlite:") and "#" in source:
112
+ sid = source.rsplit("#", 1)[1]
113
+ return self.locate_by_id(sid)
114
+ return None
115
+
116
+ # -- loading
117
+
118
+ def load(self, ref: SessionRef) -> Session:
119
+ root_hint = ref.extra.get("repo_root") or ref.cwd
120
+ con = self._connect()
121
+ try:
122
+ srow = con.execute(
123
+ "SELECT title, version, time_created, time_updated, tokens_input, tokens_output, tokens_reasoning, "
124
+ "tokens_cache_read, tokens_cache_write, cost, parent_id FROM session WHERE id=?", (ref.session_id,)).fetchone()
125
+ messages = con.execute("SELECT id, data, time_created FROM message WHERE session_id=? ORDER BY time_created, id", (ref.session_id,)).fetchall()
126
+ parts = con.execute("SELECT message_id, data, time_created FROM part WHERE session_id=? ORDER BY time_created, id", (ref.session_id,)).fetchall()
127
+ children = con.execute("SELECT id FROM session WHERE parent_id=?", (ref.session_id,)).fetchall()
128
+ child_parts = []
129
+ for (cid,) in children:
130
+ child_parts += con.execute("SELECT data FROM part WHERE session_id=?", (cid,)).fetchall()
131
+ warnings: List[str] = []
132
+ if not parts:
133
+ try:
134
+ n = con.execute("SELECT COUNT(*) FROM session_message WHERE session_id=?", (ref.session_id,)).fetchone()[0]
135
+ if n:
136
+ warnings.append("OpenCode stored this session in the new session_message table, which this version cannot read yet")
137
+ except sqlite3.OperationalError:
138
+ pass
139
+ finally:
140
+ con.close()
141
+
142
+ by_message: Dict[str, List[dict]] = {}
143
+ for message_id, data, _ in parts:
144
+ try:
145
+ by_message.setdefault(message_id, []).append(json.loads(data))
146
+ except ValueError:
147
+ continue
148
+
149
+ events: List[Event] = []
150
+ stamps: List[dt.datetime] = []
151
+ for mid, data, time_created in messages:
152
+ try:
153
+ m = json.loads(data)
154
+ except ValueError:
155
+ continue
156
+ role = m.get("role") or "assistant"
157
+ ts = _ms((m.get("time") or {}).get("created")) or _ms(time_created)
158
+ if ts:
159
+ stamps.append(ts)
160
+ for part in by_message.get(mid, []):
161
+ events.extend(_events_from_part(part, role, ts, root_hint))
162
+
163
+ hidden: List[Event] = []
164
+ for (data,) in child_parts:
165
+ try:
166
+ part = json.loads(data)
167
+ except ValueError:
168
+ continue
169
+ if part.get("type") == "tool" and KIND_BY_TOOL.get(str(part.get("tool"))) in ("edit", "write"):
170
+ inp = (part.get("state") or {}).get("input") or {}
171
+ p = inp.get("filePath") if isinstance(inp, dict) else None
172
+ if isinstance(p, str) and p:
173
+ hidden.append(Event("tool", tool=ToolCall("edit", str(part.get("tool")), f"{part.get('tool')}: {p} (subagent)", input={},
174
+ paths=[rel_to_root(p, root_hint)]), meta={"hidden": True, "subagent": True}))
175
+
176
+ title, version, t_created, t_updated, tin, tout, treason, tcr, tcw, cost, parent_id = srow if srow else (None,) * 11
177
+ tokens = None
178
+ if any(x for x in (tin, tout, treason, tcr, tcw)):
179
+ tokens = {"input": tin, "output": tout, "reasoning": treason, "cache_read": tcr, "cache_write": tcw, "cost": cost}
180
+ return Session(
181
+ agent=self.name, session_id=ref.session_id, short_id=ref.short_id,
182
+ title=None if not title or DEFAULT_TITLE_RE.match(str(title)) else str(title), cwd=ref.cwd,
183
+ started=min(stamps) if stamps else _ms(t_created), ended=max(stamps) if stamps else _ms(t_updated),
184
+ events=events + hidden, source=ref.source, agent_version=str(version) if version else None,
185
+ parent_id=parent_id if isinstance(parent_id, str) else None, subagent_files=len(children), tokens=tokens,
186
+ tested_version=self.tested_version, warnings=warnings, record_count=len(messages) + len(parts),
187
+ )
188
+
189
+
190
+ def _events_from_part(part: dict, role: str, ts: Optional[dt.datetime], root_hint: Optional[str]) -> List[Event]:
191
+ ptype = part.get("type")
192
+ if ptype == "text":
193
+ text = str(part.get("text") or "").strip()
194
+ if not text:
195
+ return []
196
+ if role == "user":
197
+ if PUSH_MARKER_RE.search(text):
198
+ return [Event("user", text="", ts=ts, is_push_invocation=True)]
199
+ return [Event("user", text=text, ts=ts)]
200
+ return [Event("assistant", text=text, ts=ts)]
201
+ if ptype == "reasoning":
202
+ text = str(part.get("text") or "")
203
+ return [Event("thinking", text=text, ts=ts)] if text.strip() else []
204
+ if ptype == "compaction":
205
+ return [Event("compaction", ts=ts, meta={"auto": part.get("auto")})]
206
+ if ptype == "tool":
207
+ name = str(part.get("tool") or "tool")
208
+ if name in SKIP_TOOLS:
209
+ return []
210
+ state = part.get("state") if isinstance(part.get("state"), dict) else {}
211
+ status = str(state.get("status") or "")
212
+ inp = state.get("input") if isinstance(state.get("input"), dict) else {}
213
+ kind = KIND_BY_TOOL.get(name, "mcp")
214
+ paths: List[str] = []
215
+ if kind in ("edit", "write"):
216
+ p = inp.get("filePath") or inp.get("path")
217
+ if isinstance(p, str) and p:
218
+ paths.append(rel_to_root(p, root_hint))
219
+ output = state.get("output")
220
+ if not isinstance(output, str) or not output:
221
+ meta_out = (state.get("metadata") or {}).get("output") if isinstance(state.get("metadata"), dict) else None
222
+ output = meta_out if isinstance(meta_out, str) else ""
223
+ is_error = status == "error"
224
+ if is_error and not output:
225
+ output = str(state.get("error") or "")
226
+ label = generic_label(name, kind, inp, paths)
227
+ if not label.split(": ", 1)[-1].strip() and isinstance(state.get("title"), str):
228
+ label = f"{name}: {state['title']}"
229
+ return [Event("tool", ts=ts, tool=ToolCall(kind, name, label, input=inp, output=output, is_error=is_error, paths=paths,
230
+ pending=(status == "running")))]
231
+ return []