agentledger-cli 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- agentledger_cli-0.1.0/README.md → agentledger_cli-0.2.0/PKG-INFO +14 -1
- agentledger_cli-0.1.0/PKG-INFO → agentledger_cli-0.2.0/README.md +6 -9
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/pyproject.toml +1 -1
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/__init__.py +1 -1
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/agents/__init__.py +5 -3
- agentledger_cli-0.2.0/src/agentledger/agents/opencode.py +246 -0
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/cli.py +6 -5
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/.gitignore +0 -0
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/__main__.py +0 -0
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/agents/claude.py +0 -0
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/agents/cortex.py +0 -0
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/agents/cursor.py +0 -0
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/agents/gemini.py +0 -0
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/app.py +0 -0
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/config.py +0 -0
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/git_context.py +0 -0
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/hooks.py +0 -0
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/repo.py +0 -0
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/runner.py +0 -0
- {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/trace.py +0 -0
|
@@ -1,6 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agentledger-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Report Claude Code, Cursor, Gemini CLI, OpenCode and Cortex Code sessions to the AgentLedger Snowflake Native App.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: snowflake-connector-python>=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
1
9
|
# agentledger
|
|
2
10
|
|
|
3
|
-
Reports your Claude Code, Cursor CLI, Gemini CLI and Cortex Code sessions to the AgentLedger Snowflake
|
|
11
|
+
Reports your Claude Code, Cursor CLI, Gemini CLI, OpenCode and Cortex Code sessions to the AgentLedger Snowflake
|
|
4
12
|
Native App, so your team sees agent cost, prompts and tool use per developer, repo, branch and
|
|
5
13
|
pull request.
|
|
6
14
|
|
|
@@ -23,6 +31,10 @@ After each turn the hook queues the session and returns at once. A background pr
|
|
|
23
31
|
into the app over your own connection, at most every two minutes per agent, so a session is
|
|
24
32
|
always stored as yours. Logs are in `~/.cache/agentledger/`.
|
|
25
33
|
|
|
34
|
+
OpenCode has no hook yet, so its sessions are uploaded by hand: `agentledger upload opencode`
|
|
35
|
+
reports the latest one, or name sessions by id (`opencode session list` shows them). They're read
|
|
36
|
+
from OpenCode 2's database, `~/.local/share/opencode/opencode.db` (`$XDG_DATA_HOME` moves it).
|
|
37
|
+
|
|
26
38
|
## Requirements
|
|
27
39
|
|
|
28
40
|
- A Snowflake connection in `~/.snowflake/connections.toml` that works without a prompt (key
|
|
@@ -39,6 +51,7 @@ agentledger install --app AGENTLEDGER_PROD # the app's name, if it
|
|
|
39
51
|
agentledger status # what's set up, and each agent's latest report
|
|
40
52
|
agentledger upload claude ~/.claude/projects/*/*.jsonl # report earlier sessions
|
|
41
53
|
agentledger upload gemini ~/.gemini/tmp/*/chats/*.jsonl
|
|
54
|
+
agentledger upload opencode ses_1a2b3c ses_4d5e6f # OpenCode sessions, by id
|
|
42
55
|
agentledger uninstall # remove the hooks and the plugin
|
|
43
56
|
```
|
|
44
57
|
|
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: agentledger-cli
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Report Claude Code, Cursor, Gemini CLI and Cortex Code sessions to the AgentLedger Snowflake Native App.
|
|
5
|
-
Requires-Python: >=3.10
|
|
6
|
-
Requires-Dist: snowflake-connector-python>=3.12
|
|
7
|
-
Description-Content-Type: text/markdown
|
|
8
|
-
|
|
9
1
|
# agentledger
|
|
10
2
|
|
|
11
|
-
Reports your Claude Code, Cursor CLI, Gemini CLI and Cortex Code sessions to the AgentLedger Snowflake
|
|
3
|
+
Reports your Claude Code, Cursor CLI, Gemini CLI, OpenCode and Cortex Code sessions to the AgentLedger Snowflake
|
|
12
4
|
Native App, so your team sees agent cost, prompts and tool use per developer, repo, branch and
|
|
13
5
|
pull request.
|
|
14
6
|
|
|
@@ -31,6 +23,10 @@ After each turn the hook queues the session and returns at once. A background pr
|
|
|
31
23
|
into the app over your own connection, at most every two minutes per agent, so a session is
|
|
32
24
|
always stored as yours. Logs are in `~/.cache/agentledger/`.
|
|
33
25
|
|
|
26
|
+
OpenCode has no hook yet, so its sessions are uploaded by hand: `agentledger upload opencode`
|
|
27
|
+
reports the latest one, or name sessions by id (`opencode session list` shows them). They're read
|
|
28
|
+
from OpenCode 2's database, `~/.local/share/opencode/opencode.db` (`$XDG_DATA_HOME` moves it).
|
|
29
|
+
|
|
34
30
|
## Requirements
|
|
35
31
|
|
|
36
32
|
- A Snowflake connection in `~/.snowflake/connections.toml` that works without a prompt (key
|
|
@@ -47,6 +43,7 @@ agentledger install --app AGENTLEDGER_PROD # the app's name, if it
|
|
|
47
43
|
agentledger status # what's set up, and each agent's latest report
|
|
48
44
|
agentledger upload claude ~/.claude/projects/*/*.jsonl # report earlier sessions
|
|
49
45
|
agentledger upload gemini ~/.gemini/tmp/*/chats/*.jsonl
|
|
46
|
+
agentledger upload opencode ses_1a2b3c ses_4d5e6f # OpenCode sessions, by id
|
|
50
47
|
agentledger uninstall # remove the hooks and the plugin
|
|
51
48
|
```
|
|
52
49
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agentledger-cli"
|
|
3
3
|
dynamic = ["version"]
|
|
4
|
-
description = "Report Claude Code, Cursor, Gemini CLI and Cortex Code sessions to the AgentLedger Snowflake Native App."
|
|
4
|
+
description = "Report Claude Code, Cursor, Gemini CLI, OpenCode and Cortex Code sessions to the AgentLedger Snowflake Native App."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
dependencies = ["snowflake-connector-python>=3.12"]
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""Report coding-agent sessions to the AgentLedger Snowflake Native App."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.2.0"
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""The agents agentledger sets up. Each module names itself (ID, NAME) and can tell whether the
|
|
2
2
|
agent is on this machine (detected), whether it's set up (hooked), and install / uninstall
|
|
3
3
|
itself. Agents whose sessions are loaded from the machine also turn a hook call into a job
|
|
4
|
-
(job), find their newest session (newest), and read a session into the app's shape (payload).
|
|
5
|
-
|
|
4
|
+
(job), find their newest session (newest), and read a session into the app's shape (payload).
|
|
5
|
+
OpenCode has no hook yet: its sessions are only uploaded by hand."""
|
|
6
|
+
from . import claude, cortex, cursor, gemini, opencode
|
|
6
7
|
|
|
7
8
|
AGENTS = {agent.ID: agent for agent in (claude, cursor, gemini, cortex)}
|
|
8
|
-
REPORTERS = {id: agent for id, agent in AGENTS.items() if hasattr(agent, "payload")}
|
|
9
|
+
REPORTERS = {id: agent for id, agent in [*AGENTS.items(), (opencode.ID, opencode)] if hasattr(agent, "payload")}
|
|
10
|
+
HOOKED = {id: agent for id, agent in REPORTERS.items() if hasattr(agent, "job")}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""OpenCode sessions, uploaded from its database with `agentledger upload opencode` (no hook yet).
|
|
2
|
+
|
|
3
|
+
OpenCode 2 keeps every session in one SQLite file, ~/.local/share/opencode/opencode.db
|
|
4
|
+
($XDG_DATA_HOME moves it): a row per session in session_v2 and its messages, in order, in
|
|
5
|
+
session_message. Each assistant message is one of the app's model calls, with its model, its
|
|
6
|
+
token counts and the times it was sent and completed; a user message the developer typed starts a
|
|
7
|
+
turn (step 0). OpenCode's system notices, the instructions it adds itself (synthetic), compaction
|
|
8
|
+
and idle markers aren't prompts. A session is named by its id (ses_...); subagent sessions (those
|
|
9
|
+
with a parent) count toward the session that started them.
|
|
10
|
+
|
|
11
|
+
Tool names are mapped to the ones the trace's file cards understand, read results are renumbered
|
|
12
|
+
in Cortex Code's "12... text" form, and edit results get Cortex Code's "Edit location" line from
|
|
13
|
+
the patch OpenCode keeps beside them. Reasoning is not loaded. The session is attributed to the
|
|
14
|
+
git repository its tool calls touched most, else to the folder it ran in, and to the branches the
|
|
15
|
+
repository's reflog shows checked out while it ran.
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sqlite3
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from ..repo import PATH_ARGS, git_reports, repo_of_paths
|
|
25
|
+
from ..trace import RESULT_CHARS, clip, clip_args, request
|
|
26
|
+
from .claude import HARNESS
|
|
27
|
+
|
|
28
|
+
ID, NAME = "opencode", "OpenCode"
|
|
29
|
+
HOME = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share").expanduser() / "opencode"
|
|
30
|
+
DB = HOME / "opencode.db"
|
|
31
|
+
|
|
32
|
+
TOOL_NAMES = {"shell": "bash", "webfetch": "web_fetch", "websearch": "web_search", "todowrite": "todo_write",
|
|
33
|
+
"question": "ask_user_question"}
|
|
34
|
+
ARG_RENAMES = {"path": "file_path", "filePath": "file_path", "oldString": "old_string", "newString": "new_string",
|
|
35
|
+
"replaceAll": "replace_all"}
|
|
36
|
+
READ_LINES = 400
|
|
37
|
+
# read's result: a "Read file <path>, lines 1-80" header, then "12: text" lines.
|
|
38
|
+
READ_HEADER = re.compile(r"\ARead file .*\n")
|
|
39
|
+
READ_LINE = re.compile(r"^(\d+): ?(.*)$")
|
|
40
|
+
HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NotFound(Exception):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def detected():
|
|
48
|
+
return DB.is_file()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _connect(db):
|
|
52
|
+
return sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def newest(db=None):
|
|
56
|
+
"""The id of the session that changed last, or None."""
|
|
57
|
+
db = Path(db or DB)
|
|
58
|
+
if not db.is_file():
|
|
59
|
+
return None
|
|
60
|
+
con = _connect(db)
|
|
61
|
+
try:
|
|
62
|
+
row = con.execute("SELECT id FROM session_v2 WHERE parent_id IS NULL "
|
|
63
|
+
"ORDER BY time_updated DESC LIMIT 1").fetchone()
|
|
64
|
+
finally:
|
|
65
|
+
con.close()
|
|
66
|
+
return row[0] if row else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _utc(ms):
|
|
70
|
+
return datetime.fromtimestamp(ms / 1000, timezone.utc).replace(tzinfo=None)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _ts(ms):
|
|
74
|
+
"""Epoch milliseconds as the naive UTC text Snowflake parses."""
|
|
75
|
+
return _utc(ms).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load(session_id, db=None):
|
|
79
|
+
"""The session's row and its messages in order, followed by those of its subagent sessions,
|
|
80
|
+
each message tagged with `_session`."""
|
|
81
|
+
con = _connect(Path(db or DB))
|
|
82
|
+
con.row_factory = sqlite3.Row
|
|
83
|
+
try:
|
|
84
|
+
session = con.execute("SELECT * FROM session_v2 WHERE id = ?", (session_id,)).fetchone()
|
|
85
|
+
if session is None:
|
|
86
|
+
raise NotFound(f"no OpenCode session {session_id}")
|
|
87
|
+
ids, frontier = [session_id], [session_id]
|
|
88
|
+
while frontier:
|
|
89
|
+
marks = ",".join("?" * len(frontier))
|
|
90
|
+
frontier = [r[0] for r in con.execute(f"SELECT id FROM session_v2 WHERE parent_id IN ({marks}) "
|
|
91
|
+
"ORDER BY time_created", frontier)]
|
|
92
|
+
ids += frontier
|
|
93
|
+
messages = []
|
|
94
|
+
for sid in ids:
|
|
95
|
+
for row in con.execute("SELECT id, type, data FROM session_message WHERE session_id = ? ORDER BY seq",
|
|
96
|
+
(sid,)):
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(row["data"])
|
|
99
|
+
except ValueError:
|
|
100
|
+
continue
|
|
101
|
+
if isinstance(data, dict):
|
|
102
|
+
messages.append({**data, "_id": row["id"], "_type": row["type"], "_session": sid})
|
|
103
|
+
finally:
|
|
104
|
+
con.close()
|
|
105
|
+
return dict(session), messages
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _time(message, key="created"):
|
|
109
|
+
value = (message.get("time") or {}).get(key)
|
|
110
|
+
return value if isinstance(value, (int, float)) else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def prompt_of(message, session_id):
|
|
114
|
+
"""The prompt a user message carries (without the context T3 Code adds), or None when it isn't
|
|
115
|
+
one: a subagent's instructions come from its parent, not the developer."""
|
|
116
|
+
if message.get("_type") != "user" or message.get("_session") != session_id:
|
|
117
|
+
return None
|
|
118
|
+
text = message.get("text")
|
|
119
|
+
text = HARNESS.sub("", text).strip() if isinstance(text, str) else ""
|
|
120
|
+
return text or None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def edit_location(metadata, new_string):
|
|
124
|
+
"""Where an edit's new text landed, in the words Cortex Code's edit results use ("Edit
|
|
125
|
+
location: lines 12-16"), from the unified diff OpenCode keeps beside the result."""
|
|
126
|
+
new = (new_string or "").split("\n")
|
|
127
|
+
first = next((line for line in new if line.strip()), None)
|
|
128
|
+
files = (metadata or {}).get("files") if isinstance(metadata, dict) else None
|
|
129
|
+
if first is None or not isinstance(files, list):
|
|
130
|
+
return None
|
|
131
|
+
for f in files:
|
|
132
|
+
n = None
|
|
133
|
+
for line in (f.get("patch") or "").splitlines() if isinstance(f, dict) else []:
|
|
134
|
+
if m := HUNK.match(line):
|
|
135
|
+
n = int(m[1])
|
|
136
|
+
elif n is not None and line[:1] in (" ", "+"):
|
|
137
|
+
if line[1:] == first:
|
|
138
|
+
start = n - new.index(first)
|
|
139
|
+
return f"Edit location: lines {start}-{start + len(new) - 1}"
|
|
140
|
+
n += 1
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _result(name, state, args):
|
|
145
|
+
texts = [c.get("text") for c in state.get("content") or [] if isinstance(c, dict)]
|
|
146
|
+
text = "\n".join(t for t in texts if isinstance(t, str))
|
|
147
|
+
if state.get("status") == "error":
|
|
148
|
+
error = state.get("error")
|
|
149
|
+
message = error.get("message") if isinstance(error, dict) else error
|
|
150
|
+
text = text or (message if isinstance(message, str) else "failed")
|
|
151
|
+
# The trace marks a tool call failed when its result reads as an error.
|
|
152
|
+
if not text.lstrip().lower().startswith("error"):
|
|
153
|
+
text = f"Error: {text}"
|
|
154
|
+
elif name == "read":
|
|
155
|
+
lines = READ_HEADER.sub("", text).splitlines()[:READ_LINES]
|
|
156
|
+
numbered = [READ_LINE.match(line) for line in lines]
|
|
157
|
+
if numbered and all(numbered):
|
|
158
|
+
text = "\n".join(f"{m[1]}... {m[2]}" for m in numbered)
|
|
159
|
+
elif name == "edit" and (where := edit_location(state.get("metadata"), args.get("new_string"))):
|
|
160
|
+
text = f"{text}\n{where}"
|
|
161
|
+
return clip(text, RESULT_CHARS)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _tools(message):
|
|
165
|
+
tools = []
|
|
166
|
+
for part in message.get("content") or []:
|
|
167
|
+
if not isinstance(part, dict) or part.get("type") != "tool":
|
|
168
|
+
continue
|
|
169
|
+
state = part.get("state") if isinstance(part.get("state"), dict) else {}
|
|
170
|
+
name = TOOL_NAMES.get(part.get("name"), part.get("name") or "tool")
|
|
171
|
+
args = clip_args(state.get("input") if isinstance(state.get("input"), dict) else {}, ARG_RENAMES)
|
|
172
|
+
tools.append({"id": part.get("id"), "name": name, "args": args, "result": _result(name, state, args)})
|
|
173
|
+
return tools
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def model_calls(messages, session_id):
|
|
177
|
+
"""One dict per assistant message, in order: prompt (for turn starts), step, model, tokens,
|
|
178
|
+
response text, tool calls, start (when it was sent) and end (when it completed)."""
|
|
179
|
+
calls, steps, pending_prompt = [], {}, None
|
|
180
|
+
for m in messages:
|
|
181
|
+
sid = m["_session"]
|
|
182
|
+
if (prompt := prompt_of(m, session_id)) is not None:
|
|
183
|
+
# Prompts sent before the agent answered open one turn together.
|
|
184
|
+
pending_prompt = prompt if pending_prompt is None else f"{pending_prompt}\n\n{prompt}"
|
|
185
|
+
steps[sid] = 0
|
|
186
|
+
continue
|
|
187
|
+
if m.get("_type") != "assistant" or (start := _time(m)) is None:
|
|
188
|
+
continue
|
|
189
|
+
end = _time(m, "completed") or _time(m, "streamed") or start
|
|
190
|
+
step = steps.get(sid, 1 if sid != session_id else 0)
|
|
191
|
+
model = m.get("model") if isinstance(m.get("model"), dict) else {}
|
|
192
|
+
text = "".join(p.get("text") or "" for p in m.get("content") or []
|
|
193
|
+
if isinstance(p, dict) and p.get("type") == "text").strip()
|
|
194
|
+
main = sid == session_id
|
|
195
|
+
calls.append({"id": m["_id"], "prompt": pending_prompt if main and step == 0 else None, "step": step,
|
|
196
|
+
"model": model.get("id"), "tokens": m.get("tokens") or {}, "text": text or None,
|
|
197
|
+
"tools": _tools(m), "start": start, "end": max(end, start)})
|
|
198
|
+
if main and step == 0:
|
|
199
|
+
pending_prompt = None
|
|
200
|
+
steps[sid] = step + 1
|
|
201
|
+
# Subagent sessions are loaded after their parent's; their calls belong where they ran.
|
|
202
|
+
return sorted(calls, key=lambda c: c["start"])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def to_requests(calls):
|
|
206
|
+
requests = []
|
|
207
|
+
for call in calls:
|
|
208
|
+
t = call["tokens"]
|
|
209
|
+
cache = t.get("cache") if isinstance(t.get("cache"), dict) else {}
|
|
210
|
+
cache_read, cache_write = cache.get("read") or 0, cache.get("write") or 0
|
|
211
|
+
requests.append(request(
|
|
212
|
+
f"opencode-{call['id']}", call["step"], _ts(call["start"]), _ts(call["end"]),
|
|
213
|
+
call["prompt"], call["text"], call["tools"],
|
|
214
|
+
model=call["model"],
|
|
215
|
+
# OpenCode counts cached input apart from input; Snowflake's telemetry counts it in.
|
|
216
|
+
# Reasoning is billed as output.
|
|
217
|
+
input_tokens=(t.get("input") or 0) + cache_read + cache_write,
|
|
218
|
+
output_tokens=(t.get("output") or 0) + (t.get("reasoning") or 0),
|
|
219
|
+
cache_read_tokens=cache_read,
|
|
220
|
+
cache_write_tokens=cache_write))
|
|
221
|
+
return requests
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def payload(session_id, repo_dir=None, folder=None, db=None):
|
|
225
|
+
"""The session for core.ingest_external_session. `repo_dir` pins its repository; `folder`
|
|
226
|
+
(where it ran) is used when its tool calls name none, else the directory OpenCode recorded."""
|
|
227
|
+
session, messages = load(session_id, db)
|
|
228
|
+
calls = model_calls(messages, session_id)
|
|
229
|
+
models = [c["model"] for c in calls if c["model"]]
|
|
230
|
+
folder = repo_dir or repo_of_paths(
|
|
231
|
+
tool["args"].get(key) for c in calls for tool in c["tools"] for key in PATH_ARGS) \
|
|
232
|
+
or folder or session.get("directory")
|
|
233
|
+
stamps = [t for m in messages if m["_session"] == session_id and (t := _time(m)) is not None]
|
|
234
|
+
started = min(stamps + [session["time_created"]])
|
|
235
|
+
prompts = sorted(_utc(_time(m)) for m in messages if _time(m) is not None and prompt_of(m, session_id))
|
|
236
|
+
reports = git_reports(folder, _utc(started), prompts) if folder else []
|
|
237
|
+
return {
|
|
238
|
+
"session_id": session_id,
|
|
239
|
+
"agent": "opencode",
|
|
240
|
+
"agent_name": NAME,
|
|
241
|
+
"model": max(set(models), key=models.count) if models else None,
|
|
242
|
+
"started_at": _ts(started),
|
|
243
|
+
**({"git_reports": reports} if reports else {}),
|
|
244
|
+
"prompts": sum(1 for c in calls if c["step"] == 0 and c["prompt"]),
|
|
245
|
+
"requests": to_requests(calls),
|
|
246
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""agentledger: report Claude Code, Cursor, Gemini CLI and Cortex Code sessions to the AgentLedger Snowflake app.
|
|
1
|
+
"""agentledger: report Claude Code, Cursor, Gemini CLI, OpenCode and Cortex Code sessions to the AgentLedger Snowflake app.
|
|
2
2
|
|
|
3
3
|
agentledger install set up every agent on this machine (once)
|
|
4
4
|
agentledger status what's set up, and each agent's latest report
|
|
@@ -16,7 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
from . import __version__, app, runner
|
|
18
18
|
from . import config as settings
|
|
19
|
-
from .agents import AGENTS, REPORTERS
|
|
19
|
+
from .agents import AGENTS, HOOKED, REPORTERS
|
|
20
20
|
|
|
21
21
|
INSTALL_FIRST = "Install agentledger first: uv tool install agentledger-cli (or pipx install agentledger-cli)."
|
|
22
22
|
|
|
@@ -173,7 +173,7 @@ def report(agent, config, jobs):
|
|
|
173
173
|
|
|
174
174
|
def cmd_hook(args):
|
|
175
175
|
"""Run by the agent after each turn. Returns at once and never fails the agent."""
|
|
176
|
-
agent =
|
|
176
|
+
agent = HOOKED[args.agent]
|
|
177
177
|
try:
|
|
178
178
|
try:
|
|
179
179
|
hook = json.loads(sys.stdin.read() or "{}")
|
|
@@ -219,13 +219,14 @@ def main(argv=None):
|
|
|
219
219
|
p = sub.add_parser("upload", help="report sessions now (default: the agent's newest)")
|
|
220
220
|
p.add_argument("agent", choices=REPORTERS)
|
|
221
221
|
p.add_argument("sessions", nargs="*", help="session files: Claude Code .jsonl transcripts, Cursor store.db "
|
|
222
|
-
"files, Gemini CLI chats/session-*.jsonl files"
|
|
222
|
+
"files, Gemini CLI chats/session-*.jsonl files; OpenCode "
|
|
223
|
+
"session ids (ses_...)")
|
|
223
224
|
p.add_argument("--repo-dir", help="attribute the sessions to this repository")
|
|
224
225
|
p.add_argument("--dry-run", action="store_true", help=f"write the payloads under {settings.STATE} instead")
|
|
225
226
|
p.set_defaults(run=cmd_upload)
|
|
226
227
|
|
|
227
228
|
p = sub.add_parser("hook") # run by the agents; without help, it stays out of the list
|
|
228
|
-
p.add_argument("agent", choices=
|
|
229
|
+
p.add_argument("agent", choices=HOOKED)
|
|
229
230
|
p.set_defaults(run=cmd_hook)
|
|
230
231
|
|
|
231
232
|
args = parser.parse_args(argv)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|