agent-ps 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_ps/__init__.py +3 -0
- agent_ps/__main__.py +197 -0
- agent_ps/backends/__init__.py +31 -0
- agent_ps/backends/base.py +310 -0
- agent_ps/backends/claude.py +144 -0
- agent_ps/backends/codex.py +84 -0
- agent_ps/backends/copilot.py +211 -0
- agent_ps/backends/hermes.py +129 -0
- agent_ps/backends/opencode.py +191 -0
- agent_ps/backends/pilike.py +108 -0
- agent_ps/backends/sqlite.py +55 -0
- agent_ps/collect.py +101 -0
- agent_ps/jsonl.py +110 -0
- agent_ps/procs.py +188 -0
- agent_ps/resume.py +60 -0
- agent_ps/ui.py +670 -0
- agent_ps/util.py +114 -0
- agent_ps-0.2.0.dist-info/METADATA +397 -0
- agent_ps-0.2.0.dist-info/RECORD +23 -0
- agent_ps-0.2.0.dist-info/WHEEL +5 -0
- agent_ps-0.2.0.dist-info/entry_points.txt +2 -0
- agent_ps-0.2.0.dist-info/licenses/LICENSE +21 -0
- agent_ps-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Codex CLI.
|
|
2
|
+
|
|
3
|
+
Every entry is an envelope: a type, a timestamp, and a `payload` holding the
|
|
4
|
+
actual record. The working directory arrives in `session_meta` at the top of the
|
|
5
|
+
file, the model in the most recent `turn_context`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from .. import jsonl
|
|
11
|
+
from .base import Backend, STATUS_BUSY, STATUS_IDLE, prompt_title
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CodexBackend(Backend):
|
|
15
|
+
name = "codex"
|
|
16
|
+
root = os.path.expanduser("~/.codex")
|
|
17
|
+
session_glob = "sessions/*/*/*/*.jsonl"
|
|
18
|
+
process_patterns = ("codex",)
|
|
19
|
+
resume_binary = "codex"
|
|
20
|
+
resume_flag = "resume"
|
|
21
|
+
extra_dirs = ("shell_snapshots", "thread-writer-locks")
|
|
22
|
+
|
|
23
|
+
def disk_paths(self, session_id, path):
|
|
24
|
+
# the shell snapshot is routinely larger than the transcript it belongs
|
|
25
|
+
# to, and it is named after the session with a timestamp appended
|
|
26
|
+
return [path,
|
|
27
|
+
os.path.join(self.root, "shell_snapshots", f"{session_id}.*.sh"),
|
|
28
|
+
os.path.join(self.root, "thread-writer-locks", f"{session_id}.lock")]
|
|
29
|
+
|
|
30
|
+
def extract(self, reader):
|
|
31
|
+
info = {}
|
|
32
|
+
for entry in reader.head():
|
|
33
|
+
payload = entry.get("payload") or {}
|
|
34
|
+
if entry.get("type") == "session_meta":
|
|
35
|
+
info["cwd"] = payload.get("cwd", "")
|
|
36
|
+
break
|
|
37
|
+
for line in reader.tail():
|
|
38
|
+
if '"turn_context"' not in line:
|
|
39
|
+
continue
|
|
40
|
+
entry = jsonl.parse_line(line)
|
|
41
|
+
payload = (entry or {}).get("payload") or {}
|
|
42
|
+
if payload.get("model"):
|
|
43
|
+
info["model"] = payload["model"]
|
|
44
|
+
break
|
|
45
|
+
info["title"] = self._opening_prompt(reader)
|
|
46
|
+
info["status"] = self._turn(reader)
|
|
47
|
+
return info
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _turn(reader):
|
|
51
|
+
"""Codex marks the start and end of a turn itself, so read the marker."""
|
|
52
|
+
for line in reader.tail():
|
|
53
|
+
if '"task_started"' not in line and '"task_complete"' not in line:
|
|
54
|
+
continue
|
|
55
|
+
entry = jsonl.parse_line(line)
|
|
56
|
+
kind = ((entry or {}).get("payload") or {}).get("type")
|
|
57
|
+
if kind == "task_started":
|
|
58
|
+
return STATUS_BUSY
|
|
59
|
+
if kind == "task_complete":
|
|
60
|
+
return STATUS_IDLE
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _opening_prompt(reader):
|
|
65
|
+
for entry in reader.head():
|
|
66
|
+
payload = entry.get("payload") or {}
|
|
67
|
+
if entry.get("type") != "response_item" or payload.get("role") != "user":
|
|
68
|
+
continue
|
|
69
|
+
content = payload.get("content")
|
|
70
|
+
if isinstance(content, str):
|
|
71
|
+
text = content
|
|
72
|
+
else:
|
|
73
|
+
text = " ".join(c.get("text", "") for c in content or []
|
|
74
|
+
if isinstance(c, dict))
|
|
75
|
+
title = prompt_title(text)
|
|
76
|
+
if title:
|
|
77
|
+
return title
|
|
78
|
+
return ""
|
|
79
|
+
|
|
80
|
+
def session_id(self, path):
|
|
81
|
+
# named rollout-<timestamp>-<uuid>.jsonl
|
|
82
|
+
stem = os.path.basename(path)[:-len(".jsonl")]
|
|
83
|
+
parts = stem.split("-")
|
|
84
|
+
return "-".join(parts[-5:]) if len(parts) >= 5 else stem
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""GitHub Copilot Chat, in VS Code.
|
|
2
|
+
|
|
3
|
+
The odd one out: Copilot runs inside the editor's extension host, so there is no
|
|
4
|
+
process to find and no process to stop. What it does have is the richest
|
|
5
|
+
accounting of the six, including the credits a turn spent, which the free tier
|
|
6
|
+
meters and nothing else surfaces.
|
|
7
|
+
|
|
8
|
+
A chat is alive when its workspace is open in VS Code, which is answered by
|
|
9
|
+
asking the editor which storage directories it currently holds open.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import glob
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
from ..procs import run
|
|
17
|
+
from ..util import directory_size
|
|
18
|
+
from .base import (Backend, KIND_ENDED, KIND_SESSION, STATUS_BUSY,
|
|
19
|
+
STATUS_IDLE)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CopilotBackend(Backend):
|
|
23
|
+
name = "copilot"
|
|
24
|
+
root = os.path.expanduser(
|
|
25
|
+
os.environ.get("VSCODE_USER_DIR",
|
|
26
|
+
"~/Library/Application Support/Code/User"))
|
|
27
|
+
session_glob = "workspaceStorage/*/chatSessions/*.jsonl"
|
|
28
|
+
#: Nothing in the process table belongs to Copilot, so it claims no process.
|
|
29
|
+
process_patterns = ()
|
|
30
|
+
#: A chat lives in the editor's UI, so there is no command that reopens one.
|
|
31
|
+
resume_binary = ""
|
|
32
|
+
|
|
33
|
+
def __init__(self):
|
|
34
|
+
super().__init__()
|
|
35
|
+
self._open = None
|
|
36
|
+
|
|
37
|
+
def owns(self, proc):
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
def open_workspaces(self):
|
|
41
|
+
"""Storage directories VS Code currently has open.
|
|
42
|
+
|
|
43
|
+
One lsof call for every editor process, cached for the pass. A workspace
|
|
44
|
+
that is not open cannot have a live chat, which is the only way to tell
|
|
45
|
+
an abandoned conversation from one still on screen.
|
|
46
|
+
"""
|
|
47
|
+
if self._open is not None:
|
|
48
|
+
return self._open
|
|
49
|
+
self._open = set()
|
|
50
|
+
pids = run(["pgrep", "-f", "Visual Studio Code|Code Helper"]).split()
|
|
51
|
+
if pids:
|
|
52
|
+
out = run(["lsof", "-p", ",".join(pids[:60])], timeout=15)
|
|
53
|
+
for line in out.splitlines():
|
|
54
|
+
marker = line.find("workspaceStorage/")
|
|
55
|
+
if marker < 0:
|
|
56
|
+
continue
|
|
57
|
+
tail = line[marker + len("workspaceStorage/"):]
|
|
58
|
+
self._open.add(tail.split("/")[0])
|
|
59
|
+
return self._open
|
|
60
|
+
|
|
61
|
+
def session_id(self, path):
|
|
62
|
+
return os.path.basename(path)[:-len(".jsonl")]
|
|
63
|
+
|
|
64
|
+
def workspace_of(self, path):
|
|
65
|
+
return os.path.basename(os.path.dirname(os.path.dirname(path)))
|
|
66
|
+
|
|
67
|
+
def fallback_cwd(self, path):
|
|
68
|
+
"""The folder a workspace points at, from the editor's own record."""
|
|
69
|
+
marker = os.path.join(os.path.dirname(os.path.dirname(path)),
|
|
70
|
+
"workspace.json")
|
|
71
|
+
try:
|
|
72
|
+
with open(marker) as handle:
|
|
73
|
+
folder = json.load(handle).get("folder", "")
|
|
74
|
+
except (OSError, ValueError):
|
|
75
|
+
return ""
|
|
76
|
+
return folder[len("file://"):] if folder.startswith("file://") else folder
|
|
77
|
+
|
|
78
|
+
def extract(self, reader):
|
|
79
|
+
state = replay(reader.path)
|
|
80
|
+
requests = [r for r in (state.get("requests") or []) if isinstance(r, dict)]
|
|
81
|
+
if not requests:
|
|
82
|
+
# a chat that was opened and never used, of which there are many
|
|
83
|
+
return {"skip": True}
|
|
84
|
+
last = requests[-1]
|
|
85
|
+
info = {
|
|
86
|
+
"title": state.get("customTitle") or "",
|
|
87
|
+
"model": model_of(state, requests),
|
|
88
|
+
"status": turn_status(last),
|
|
89
|
+
}
|
|
90
|
+
stamp = last.get("timestamp")
|
|
91
|
+
if stamp:
|
|
92
|
+
info["last_active"] = stamp / 1000.0
|
|
93
|
+
return info
|
|
94
|
+
|
|
95
|
+
def describe(self, path):
|
|
96
|
+
row = super().describe(path)
|
|
97
|
+
row["kind"] = (KIND_SESSION if self.workspace_of(path) in self.open_workspaces()
|
|
98
|
+
else KIND_ENDED)
|
|
99
|
+
return row
|
|
100
|
+
|
|
101
|
+
def sessions(self, limit=None):
|
|
102
|
+
self._open = None
|
|
103
|
+
rows = [r for r in super().sessions(limit) if r["title"] or r["model"]]
|
|
104
|
+
return rows
|
|
105
|
+
|
|
106
|
+
def live_sessions(self):
|
|
107
|
+
"""Chats in a window that is currently open."""
|
|
108
|
+
return [r for r in self.sessions(limit=200) if r["kind"] == KIND_SESSION]
|
|
109
|
+
|
|
110
|
+
def details(self, row):
|
|
111
|
+
matches = glob.glob(os.path.join(self.root, "workspaceStorage", "*",
|
|
112
|
+
"chatSessions", f"{row['session_id']}.jsonl"))
|
|
113
|
+
if not matches:
|
|
114
|
+
return []
|
|
115
|
+
requests = [r for r in (replay(matches[0]).get("requests") or [])
|
|
116
|
+
if isinstance(r, dict)]
|
|
117
|
+
if not requests:
|
|
118
|
+
return []
|
|
119
|
+
prompt = sum(r.get("promptTokens") or 0 for r in requests)
|
|
120
|
+
output = sum(r.get("completionTokens") or 0 for r in requests)
|
|
121
|
+
credits = sum(r.get("copilotCredits") or 0 for r in requests)
|
|
122
|
+
return [
|
|
123
|
+
("host", "VS Code, no process of its own"),
|
|
124
|
+
("tokens", f"in {prompt:,} out {output:,}"),
|
|
125
|
+
("turns", f"{len(requests)}"),
|
|
126
|
+
("credits", f"{credits:.2f} used"),
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
def disk_paths(self, session_id, path):
|
|
130
|
+
base = os.path.dirname(os.path.dirname(path))
|
|
131
|
+
return [path, os.path.join(base, "chatEditingSessions", session_id)]
|
|
132
|
+
|
|
133
|
+
def history_bytes(self):
|
|
134
|
+
return sum(directory_size(p) for p in
|
|
135
|
+
glob.glob(os.path.join(self.root, "workspaceStorage", "*",
|
|
136
|
+
"chatSessions")))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def replay(path):
|
|
140
|
+
"""Rebuild a session from its journal of changes.
|
|
141
|
+
|
|
142
|
+
The file is not a log of messages but a list of edits: an initial object,
|
|
143
|
+
then `{kind, k: [path], v}` entries that set a value somewhere inside it.
|
|
144
|
+
Replaying them in order is the only way to read any field.
|
|
145
|
+
"""
|
|
146
|
+
state = {}
|
|
147
|
+
try:
|
|
148
|
+
handle = open(path, errors="ignore")
|
|
149
|
+
except OSError:
|
|
150
|
+
return state
|
|
151
|
+
with handle:
|
|
152
|
+
for line in handle:
|
|
153
|
+
try:
|
|
154
|
+
entry = json.loads(line)
|
|
155
|
+
except ValueError:
|
|
156
|
+
continue
|
|
157
|
+
key, value = entry.get("k"), entry.get("v")
|
|
158
|
+
if not key:
|
|
159
|
+
if isinstance(value, dict):
|
|
160
|
+
state.update(value)
|
|
161
|
+
continue
|
|
162
|
+
node = state
|
|
163
|
+
try:
|
|
164
|
+
for step in key[:-1]:
|
|
165
|
+
node = _descend(node, step)
|
|
166
|
+
_assign(node, key[-1], value)
|
|
167
|
+
except (TypeError, AttributeError, IndexError):
|
|
168
|
+
continue
|
|
169
|
+
return state
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _descend(node, step):
|
|
173
|
+
if isinstance(step, int):
|
|
174
|
+
while len(node) <= step:
|
|
175
|
+
node.append({})
|
|
176
|
+
return node[step]
|
|
177
|
+
return node.setdefault(step, {})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _assign(node, key, value):
|
|
181
|
+
if isinstance(key, int):
|
|
182
|
+
while len(node) <= key:
|
|
183
|
+
node.append({})
|
|
184
|
+
node[key] = value
|
|
185
|
+
else:
|
|
186
|
+
node[key] = value
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def model_of(state, requests):
|
|
190
|
+
"""What actually answered, not the picker that chose it.
|
|
191
|
+
|
|
192
|
+
A request records `modelId`, but under the default setting that is the
|
|
193
|
+
alias `copilot/auto`, which says nothing. The family behind the alias is
|
|
194
|
+
kept with the model the user selected.
|
|
195
|
+
"""
|
|
196
|
+
selected = ((state.get("inputState") or {}).get("selectedModel") or {})
|
|
197
|
+
family = (selected.get("metadata") or {}).get("family")
|
|
198
|
+
if family:
|
|
199
|
+
return family
|
|
200
|
+
for request in reversed(requests):
|
|
201
|
+
raw = request.get("modelId")
|
|
202
|
+
if raw and raw != "copilot/auto":
|
|
203
|
+
return raw
|
|
204
|
+
return ""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def turn_status(request):
|
|
208
|
+
state = request.get("modelState")
|
|
209
|
+
if isinstance(state, dict) and not state.get("completedAt"):
|
|
210
|
+
return STATUS_BUSY
|
|
211
|
+
return STATUS_IDLE if request.get("response") is not None else STATUS_BUSY
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Hermes.
|
|
2
|
+
|
|
3
|
+
Keeps its state in one SQLite database, and keeps more of it than the others:
|
|
4
|
+
a session row already holds the working directory, the title, the model, and
|
|
5
|
+
when it was last active, so a row costs a single query.
|
|
6
|
+
|
|
7
|
+
Hermes also runs a gateway process that outlives the sessions it serves, which
|
|
8
|
+
is the sort of thing worth noticing before it sits there for a week.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import glob
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from ..util import directory_size
|
|
15
|
+
from .base import KIND_GATEWAY, KIND_SESSION, STATUS_BUSY, STATUS_IDLE
|
|
16
|
+
from .sqlite import SqliteBackend
|
|
17
|
+
|
|
18
|
+
KIND_SUPERVISOR = "supervisor"
|
|
19
|
+
BACKGROUND = {KIND_GATEWAY, KIND_SUPERVISOR}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HermesBackend(SqliteBackend):
|
|
23
|
+
name = "hermes"
|
|
24
|
+
root = os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))
|
|
25
|
+
process_patterns = ("hermes", "hermes_cli.main", "hermes_cli.stderr_timestamp")
|
|
26
|
+
resume_binary = "hermes"
|
|
27
|
+
# the --resume flag reopens a session; `hermes resume` is a different
|
|
28
|
+
# command that lifts an emergency stop, so do not "simplify" this
|
|
29
|
+
resume_flag = "--resume"
|
|
30
|
+
|
|
31
|
+
db_filename = "state.db"
|
|
32
|
+
|
|
33
|
+
def available(self):
|
|
34
|
+
return bool(self.database)
|
|
35
|
+
|
|
36
|
+
def classify(self, proc):
|
|
37
|
+
"""Tell the gateway apart from the thing that watches it.
|
|
38
|
+
|
|
39
|
+
Hermes starts a messaging gateway on its own and detaches it, so it
|
|
40
|
+
outlives the session that caused it. The gateway runs under a wrapper
|
|
41
|
+
that timestamps its stderr, and since the wrapper carries the gateway
|
|
42
|
+
command after a `--`, both would otherwise read as the gateway.
|
|
43
|
+
"""
|
|
44
|
+
cmd = proc["cmd"]
|
|
45
|
+
if "stderr_timestamp" in cmd:
|
|
46
|
+
return KIND_SUPERVISOR
|
|
47
|
+
if "gateway run" in cmd:
|
|
48
|
+
return KIND_GATEWAY
|
|
49
|
+
return KIND_SESSION
|
|
50
|
+
|
|
51
|
+
def is_background(self, kind):
|
|
52
|
+
return kind in BACKGROUND
|
|
53
|
+
|
|
54
|
+
def sessions(self, limit=None):
|
|
55
|
+
return self.rows_from(
|
|
56
|
+
"SELECT id, cwd, title, model, last_activity_at, ended_at "
|
|
57
|
+
"FROM sessions ORDER BY last_activity_at DESC",
|
|
58
|
+
limit,
|
|
59
|
+
lambda r: {
|
|
60
|
+
"session_id": r["id"],
|
|
61
|
+
"title": r["title"] or "",
|
|
62
|
+
"model": r["model"] or "",
|
|
63
|
+
"cwd": r["cwd"] or "",
|
|
64
|
+
"last_active": r["last_activity_at"] or 0,
|
|
65
|
+
"status": "" if r["ended_at"] else self._turn(r["id"]),
|
|
66
|
+
"disk": self._session_bytes(r["id"]),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
def _turn(self, session_id):
|
|
70
|
+
"""Whose turn it is, from the newest message.
|
|
71
|
+
|
|
72
|
+
Hermes records a finish reason when a reply completes, so an assistant
|
|
73
|
+
message without one is still being written.
|
|
74
|
+
"""
|
|
75
|
+
found = self.query("SELECT role, finish_reason FROM messages "
|
|
76
|
+
"WHERE session_id = ? ORDER BY timestamp DESC LIMIT 1",
|
|
77
|
+
(session_id,))
|
|
78
|
+
if not found:
|
|
79
|
+
return ""
|
|
80
|
+
role = found[0]["role"]
|
|
81
|
+
if role == "user":
|
|
82
|
+
return STATUS_BUSY
|
|
83
|
+
if role == "assistant":
|
|
84
|
+
return STATUS_IDLE if found[0]["finish_reason"] else STATUS_BUSY
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
def _session_bytes(self, session_id):
|
|
88
|
+
"""The conversation in the database, plus what it dumped beside it.
|
|
89
|
+
|
|
90
|
+
Hermes writes a full request dump per turn, and those are two orders of
|
|
91
|
+
magnitude larger than the messages they record.
|
|
92
|
+
"""
|
|
93
|
+
found = self.query("SELECT total(length(content)) + total(length(tool_calls)) "
|
|
94
|
+
"FROM messages WHERE session_id = ?", (session_id,))
|
|
95
|
+
total = int(found[0][0] or 0) if found else 0
|
|
96
|
+
dumps = os.path.join(self.root, "sessions", f"request_dump_{session_id}_*.json")
|
|
97
|
+
return total + sum(directory_size(p) for p in glob.glob(dumps))
|
|
98
|
+
|
|
99
|
+
def details(self, row):
|
|
100
|
+
found = self.query(
|
|
101
|
+
"SELECT input_tokens, output_tokens, reasoning_tokens, api_call_count, "
|
|
102
|
+
"estimated_cost_usd, billing_provider, message_count, tool_call_count "
|
|
103
|
+
"FROM sessions WHERE id = ?", (row["session_id"],))
|
|
104
|
+
if not found:
|
|
105
|
+
return []
|
|
106
|
+
r = found[0]
|
|
107
|
+
return [
|
|
108
|
+
("provider", r["billing_provider"] or "-"),
|
|
109
|
+
("tokens", f"in {r['input_tokens'] or 0:,} out {r['output_tokens'] or 0:,}"
|
|
110
|
+
f" reasoning {r['reasoning_tokens'] or 0:,}"),
|
|
111
|
+
("calls", f"{r['api_call_count'] or 0} api, {r['message_count'] or 0} messages,"
|
|
112
|
+
f" {r['tool_call_count'] or 0} tool calls"),
|
|
113
|
+
("cost", f"${r['estimated_cost_usd'] or 0:.4f} estimated"),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
def history_counts(self, since):
|
|
117
|
+
found = self.query("SELECT count(*), "
|
|
118
|
+
"sum(CASE WHEN last_activity_at > ? THEN 1 ELSE 0 END) "
|
|
119
|
+
"FROM sessions", (since,))
|
|
120
|
+
if not found:
|
|
121
|
+
return 0, 0
|
|
122
|
+
return int(found[0][0] or 0), int(found[0][1] or 0)
|
|
123
|
+
|
|
124
|
+
def history_bytes(self):
|
|
125
|
+
# the request dumps under sessions/ are debugging artefacts, and they are
|
|
126
|
+
# larger than the database that holds the actual conversations
|
|
127
|
+
return (directory_size(self.database)
|
|
128
|
+
+ directory_size(os.path.join(self.root, "sessions"))
|
|
129
|
+
+ directory_size(os.path.join(self.root, "logs")))
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""OpenCode.
|
|
2
|
+
|
|
3
|
+
Newer versions keep everything in one SQLite database, where a session row
|
|
4
|
+
already carries its directory, title, and model. Nothing has to be parsed to
|
|
5
|
+
build a row, which makes this the cheapest backend of the set.
|
|
6
|
+
|
|
7
|
+
Older versions wrote a tree of JSON files under `storage/`. Installs that never
|
|
8
|
+
migrated still have only that, so it is read when the database is absent.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import glob
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from .. import jsonl
|
|
16
|
+
from ..util import directory_size
|
|
17
|
+
from .base import Backend, KIND_SESSION, STATUS_BUSY, STATUS_IDLE
|
|
18
|
+
from .sqlite import SqliteBackend
|
|
19
|
+
|
|
20
|
+
KIND_SERVE = "serve"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OpenCodeBackend(SqliteBackend):
|
|
24
|
+
name = "opencode"
|
|
25
|
+
root = os.path.expanduser(
|
|
26
|
+
os.environ.get("OPENCODE_DATA", "~/.local/share/opencode"))
|
|
27
|
+
session_glob = "storage/session/*/*.json"
|
|
28
|
+
process_patterns = ("opencode",)
|
|
29
|
+
resume_binary = "opencode"
|
|
30
|
+
resume_flag = "--session"
|
|
31
|
+
|
|
32
|
+
db_filename = "opencode.db"
|
|
33
|
+
|
|
34
|
+
def classify(self, proc):
|
|
35
|
+
# `opencode serve` hosts sessions for editors and keeps running after
|
|
36
|
+
# every one of them is closed
|
|
37
|
+
return KIND_SERVE if " serve" in proc["cmd"] else KIND_SESSION
|
|
38
|
+
|
|
39
|
+
def is_background(self, kind):
|
|
40
|
+
return kind == KIND_SERVE
|
|
41
|
+
|
|
42
|
+
# SQLite ---------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def sessions(self, limit=None):
|
|
45
|
+
if not self.database:
|
|
46
|
+
return super(SqliteBackend, self).sessions(limit)
|
|
47
|
+
return self.rows_from(
|
|
48
|
+
"SELECT id, directory, title, model, time_updated "
|
|
49
|
+
"FROM session ORDER BY time_updated DESC",
|
|
50
|
+
limit,
|
|
51
|
+
lambda r: {
|
|
52
|
+
"session_id": r["id"],
|
|
53
|
+
"title": r["title"] or "",
|
|
54
|
+
"model": _model_name(r["model"]),
|
|
55
|
+
"cwd": r["directory"] or "",
|
|
56
|
+
"last_active": (r["time_updated"] or 0) / 1000.0,
|
|
57
|
+
"status": self._turn(r["id"]),
|
|
58
|
+
"disk": self._session_bytes(r["id"]),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
def _turn(self, session_id):
|
|
62
|
+
"""Whose turn it is, from the newest message.
|
|
63
|
+
|
|
64
|
+
An assistant message carries a `finish` reason once its turn is over, so
|
|
65
|
+
one without it is still being written.
|
|
66
|
+
"""
|
|
67
|
+
found = self.query("SELECT data FROM message WHERE session_id = ? "
|
|
68
|
+
"ORDER BY time_created DESC LIMIT 1", (session_id,))
|
|
69
|
+
if not found:
|
|
70
|
+
return ""
|
|
71
|
+
try:
|
|
72
|
+
data = json.loads(found[0]["data"])
|
|
73
|
+
except ValueError:
|
|
74
|
+
return ""
|
|
75
|
+
if data.get("role") == "user":
|
|
76
|
+
return STATUS_BUSY
|
|
77
|
+
if data.get("role") == "assistant":
|
|
78
|
+
return STATUS_IDLE if data.get("finish") else STATUS_BUSY
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
def _session_bytes(self, session_id):
|
|
82
|
+
"""What one session holds in the database, rather than on its own."""
|
|
83
|
+
found = self.query(
|
|
84
|
+
"SELECT (SELECT total(length(data)) FROM message WHERE session_id = ?)"
|
|
85
|
+
" + (SELECT total(length(data)) FROM part WHERE session_id = ?)",
|
|
86
|
+
(session_id, session_id))
|
|
87
|
+
return int(found[0][0] or 0) if found else 0
|
|
88
|
+
|
|
89
|
+
def details(self, row):
|
|
90
|
+
if not self.database:
|
|
91
|
+
return []
|
|
92
|
+
found = self.query(
|
|
93
|
+
"SELECT cost, tokens_input, tokens_output, tokens_reasoning, "
|
|
94
|
+
"tokens_cache_read, tokens_cache_write, agent "
|
|
95
|
+
"FROM session WHERE id = ?", (row["session_id"],))
|
|
96
|
+
if not found:
|
|
97
|
+
return []
|
|
98
|
+
r = found[0]
|
|
99
|
+
return [
|
|
100
|
+
("agent mode", r["agent"] or "-"),
|
|
101
|
+
("tokens", f"in {r['tokens_input'] or 0:,} out {r['tokens_output'] or 0:,}"
|
|
102
|
+
f" reasoning {r['tokens_reasoning'] or 0:,}"),
|
|
103
|
+
("cache", f"read {r['tokens_cache_read'] or 0:,}"
|
|
104
|
+
f" write {r['tokens_cache_write'] or 0:,}"),
|
|
105
|
+
("cost", f"${r['cost'] or 0:.4f}"),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
def history_counts(self, since):
|
|
109
|
+
if not self.database:
|
|
110
|
+
return super().history_counts(since)
|
|
111
|
+
found = self.query("SELECT count(*), "
|
|
112
|
+
"sum(CASE WHEN time_updated > ? THEN 1 ELSE 0 END) "
|
|
113
|
+
"FROM session", (since * 1000,))
|
|
114
|
+
if not found:
|
|
115
|
+
return 0, 0
|
|
116
|
+
return int(found[0][0] or 0), int(found[0][1] or 0)
|
|
117
|
+
|
|
118
|
+
def history_bytes(self):
|
|
119
|
+
# a migrated install keeps the old tree around, and those are still
|
|
120
|
+
# bytes sitting on the disk
|
|
121
|
+
return (directory_size(self.database) if self.database else 0) \
|
|
122
|
+
+ directory_size(os.path.join(self.root, "storage"))
|
|
123
|
+
|
|
124
|
+
# The JSON store older versions wrote ----------------------------------
|
|
125
|
+
|
|
126
|
+
def extract(self, reader):
|
|
127
|
+
data = jsonl.load(reader.path)
|
|
128
|
+
model, status = self._latest(data.get("id", ""))
|
|
129
|
+
info = {
|
|
130
|
+
"cwd": data.get("directory", ""),
|
|
131
|
+
"title": data.get("title", ""),
|
|
132
|
+
"model": model,
|
|
133
|
+
"status": status,
|
|
134
|
+
}
|
|
135
|
+
updated = (data.get("time") or {}).get("updated")
|
|
136
|
+
if updated:
|
|
137
|
+
info["last_active"] = updated / 1000.0
|
|
138
|
+
return info
|
|
139
|
+
|
|
140
|
+
def _latest(self, session_id):
|
|
141
|
+
"""Model and turn state from the newest message files, newest first."""
|
|
142
|
+
if not session_id:
|
|
143
|
+
return "", ""
|
|
144
|
+
paths = glob.glob(os.path.join(self.root, "storage", "message",
|
|
145
|
+
session_id, "*.json"))
|
|
146
|
+
model = status = ""
|
|
147
|
+
for path in sorted(paths, key=_mtime, reverse=True):
|
|
148
|
+
data = jsonl.load(path)
|
|
149
|
+
if not status:
|
|
150
|
+
role = data.get("role")
|
|
151
|
+
if role == "user":
|
|
152
|
+
status = STATUS_BUSY
|
|
153
|
+
elif role == "assistant":
|
|
154
|
+
status = STATUS_IDLE
|
|
155
|
+
if not model and data.get("modelID"):
|
|
156
|
+
provider = data.get("providerID", "")
|
|
157
|
+
model = f"{provider}/{data['modelID']}" if provider else data["modelID"]
|
|
158
|
+
if model and status:
|
|
159
|
+
break
|
|
160
|
+
return model, status
|
|
161
|
+
|
|
162
|
+
def session_id(self, path):
|
|
163
|
+
return os.path.basename(path)[:-len(".json")]
|
|
164
|
+
|
|
165
|
+
def disk_paths(self, session_id, path):
|
|
166
|
+
return [path,
|
|
167
|
+
os.path.join(self.root, "storage", "message", session_id),
|
|
168
|
+
os.path.join(self.root, "storage", "session_diff",
|
|
169
|
+
f"{session_id}.json")]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _model_name(value):
|
|
173
|
+
"""`{"id": "mimo-claude", "providerID": "9router"}` as `9router/mimo-claude`."""
|
|
174
|
+
if not value:
|
|
175
|
+
return ""
|
|
176
|
+
try:
|
|
177
|
+
data = json.loads(value)
|
|
178
|
+
except ValueError:
|
|
179
|
+
return str(value)
|
|
180
|
+
if not isinstance(data, dict):
|
|
181
|
+
return str(value)
|
|
182
|
+
model = data.get("id", "")
|
|
183
|
+
provider = data.get("providerID", "")
|
|
184
|
+
return f"{provider}/{model}" if provider and model else model
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _mtime(path):
|
|
188
|
+
try:
|
|
189
|
+
return os.path.getmtime(path)
|
|
190
|
+
except OSError:
|
|
191
|
+
return 0
|