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.
- agent_sessions/__init__.py +3 -0
- agent_sessions/__main__.py +3 -0
- agent_sessions/adapters/__init__.py +136 -0
- agent_sessions/adapters/base.py +108 -0
- agent_sessions/adapters/claude_code.py +300 -0
- agent_sessions/adapters/codex.py +360 -0
- agent_sessions/adapters/gemini.py +176 -0
- agent_sessions/adapters/opencode.py +231 -0
- agent_sessions/cli.py +858 -0
- agent_sessions/core.py +748 -0
- agent_sessions/model.py +119 -0
- agent_sessions/templates/gitattributes +2 -0
- agent_sessions/templates/sessions-README.md +31 -0
- agent_sessions/templates/summary.md +31 -0
- agent_sessions_cli-0.2.0.dist-info/METADATA +141 -0
- agent_sessions_cli-0.2.0.dist-info/RECORD +19 -0
- agent_sessions_cli-0.2.0.dist-info/WHEEL +4 -0
- agent_sessions_cli-0.2.0.dist-info/entry_points.txt +2 -0
- agent_sessions_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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 []
|