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.
Files changed (20) hide show
  1. agentledger_cli-0.1.0/README.md → agentledger_cli-0.2.0/PKG-INFO +14 -1
  2. agentledger_cli-0.1.0/PKG-INFO → agentledger_cli-0.2.0/README.md +6 -9
  3. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/pyproject.toml +1 -1
  4. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/__init__.py +1 -1
  5. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/agents/__init__.py +5 -3
  6. agentledger_cli-0.2.0/src/agentledger/agents/opencode.py +246 -0
  7. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/cli.py +6 -5
  8. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/.gitignore +0 -0
  9. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/__main__.py +0 -0
  10. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/agents/claude.py +0 -0
  11. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/agents/cortex.py +0 -0
  12. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/agents/cursor.py +0 -0
  13. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/agents/gemini.py +0 -0
  14. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/app.py +0 -0
  15. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/config.py +0 -0
  16. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/git_context.py +0 -0
  17. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/hooks.py +0 -0
  18. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/repo.py +0 -0
  19. {agentledger_cli-0.1.0 → agentledger_cli-0.2.0}/src/agentledger/runner.py +0 -0
  20. {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.1.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
- from . import claude, cortex, cursor, gemini
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 = REPORTERS[args.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=REPORTERS)
229
+ p.add_argument("agent", choices=HOOKED)
229
230
  p.set_defaults(run=cmd_hook)
230
231
 
231
232
  args = parser.parse_args(argv)