agentledger-cli 0.1.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.
- agentledger/__init__.py +2 -0
- agentledger/__main__.py +3 -0
- agentledger/agents/__init__.py +8 -0
- agentledger/agents/claude.py +258 -0
- agentledger/agents/cortex.py +42 -0
- agentledger/agents/cursor.py +242 -0
- agentledger/agents/gemini.py +251 -0
- agentledger/app.py +45 -0
- agentledger/cli.py +232 -0
- agentledger/config.py +66 -0
- agentledger/git_context.py +242 -0
- agentledger/hooks.py +94 -0
- agentledger/repo.py +96 -0
- agentledger/runner.py +92 -0
- agentledger/trace.py +43 -0
- agentledger_cli-0.1.0.dist-info/METADATA +53 -0
- agentledger_cli-0.1.0.dist-info/RECORD +19 -0
- agentledger_cli-0.1.0.dist-info/WHEEL +4 -0
- agentledger_cli-0.1.0.dist-info/entry_points.txt +2 -0
agentledger/__init__.py
ADDED
agentledger/__main__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The agents agentledger sets up. Each module names itself (ID, NAME) and can tell whether the
|
|
2
|
+
agent is on this machine (detected), whether it's set up (hooked), and install / uninstall
|
|
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
|
|
6
|
+
|
|
7
|
+
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")}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Claude Code sessions, hooked from ~/.claude/settings.json (Stop and SessionEnd).
|
|
2
|
+
|
|
3
|
+
The transcript is ~/.claude/projects/<project>/<session id>.jsonl. Claude Code writes each API
|
|
4
|
+
response as several lines (thinking, text, tool calls) sharing one message id, with the final
|
|
5
|
+
usage on the last of them; each response becomes one of the app's model calls, with its model,
|
|
6
|
+
token counts and timing. A prompt the developer typed starts a turn (step 0). Background-task
|
|
7
|
+
notices and Claude Code's own meta messages continue the current turn, and local slash commands
|
|
8
|
+
and interrupts are left out. Tool calls keep their arguments and results (clipped); names are
|
|
9
|
+
mapped to the ones the trace's file cards understand, Read results are renumbered in Cortex
|
|
10
|
+
Code's "12... text" form, and Edit results get the "Edit location" line Cortex Code's carry.
|
|
11
|
+
Thinking is not loaded, and <system-reminder> blocks and client harness context (T3 Code's
|
|
12
|
+
attachment notes) are removed from prompts. The session is attributed to the git repository its
|
|
13
|
+
tool calls touched most, else each prompt to the folder it ran in.
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from collections import OrderedDict
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .. import hooks
|
|
23
|
+
from ..repo import PATH_ARGS, repo_of_paths, repo_state, toplevel
|
|
24
|
+
from ..trace import RESULT_CHARS, clip, clip_args, request
|
|
25
|
+
|
|
26
|
+
ID, NAME = "claude", "Claude Code"
|
|
27
|
+
HOME = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude").expanduser()
|
|
28
|
+
SETTINGS = HOME / "settings.json"
|
|
29
|
+
PROJECTS = HOME / "projects"
|
|
30
|
+
EVENTS = ("Stop", "SessionEnd")
|
|
31
|
+
|
|
32
|
+
TOOL_NAMES = {"Read": "read", "Edit": "edit", "Write": "write", "Bash": "bash", "Grep": "grep", "Glob": "glob",
|
|
33
|
+
"Skill": "skill", "Agent": "task", "Task": "task", "WebFetch": "web_fetch", "WebSearch": "web_search",
|
|
34
|
+
"AskUserQuestion": "ask_user_question", "NotebookEdit": "notebook_edit", "TodoWrite": "todo_write"}
|
|
35
|
+
READ_LINES = 400
|
|
36
|
+
REMINDER = re.compile(r"<system-reminder>.*?</system-reminder>\s*", re.S)
|
|
37
|
+
# Context a client harness adds around the typed prompt (T3 Code's attachment metadata).
|
|
38
|
+
HARNESS = re.compile(r"<t3_context\b.*?</t3_context>\s*|^\[Attached image .*? is saved at: .*?\]\s*$", re.S | re.M)
|
|
39
|
+
CLAUDE_READ_LINE = re.compile(r"^\s*(\d+)\t(.*)$")
|
|
40
|
+
# User messages that aren't prompts to the agent: local slash commands and their output, interrupts.
|
|
41
|
+
NOT_PROMPTS = ("<command-name>", "<local-command-stdout>", "<local-command-caveat>", "[Request interrupted by user")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def detected():
|
|
45
|
+
return HOME.is_dir()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def hooked():
|
|
49
|
+
return hooks.present(SETTINGS, nested=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def install(command, config):
|
|
53
|
+
if hooks.install(SETTINGS, EVENTS, command, nested=True):
|
|
54
|
+
return f"hook added to {SETTINGS}"
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def uninstall(config):
|
|
59
|
+
return hooks.remove(SETTINGS, nested=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def job(hook):
|
|
63
|
+
"""The session a Stop / SessionEnd hook is about, or None."""
|
|
64
|
+
transcript = Path(hook.get("transcript_path") or "").expanduser().resolve()
|
|
65
|
+
if transcript.suffix != ".jsonl" or PROJECTS.resolve() not in transcript.parents or not transcript.is_file():
|
|
66
|
+
return None
|
|
67
|
+
cwd = hook.get("cwd") if isinstance(hook.get("cwd"), str) else None
|
|
68
|
+
return {"path": str(transcript), "label": transcript.stem, "folders": [cwd] if cwd else [], "folder": cwd}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def newest():
|
|
72
|
+
return max(PROJECTS.glob("*/*.jsonl"), key=lambda p: p.stat().st_mtime, default=None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ts(value):
|
|
76
|
+
"""Transcript timestamps (2026-10-09T00:50:29.548Z) as the naive UTC text Snowflake parses."""
|
|
77
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _text(content):
|
|
81
|
+
"""Text of a message's content: a string, or its text blocks; images become "[image]" only when
|
|
82
|
+
there's no text (a prompt names its own attachments)."""
|
|
83
|
+
if isinstance(content, str):
|
|
84
|
+
return content
|
|
85
|
+
blocks = [b for b in content if isinstance(b, dict)] if isinstance(content, list) else []
|
|
86
|
+
texts = [b.get("text", "") for b in blocks if b.get("type") == "text"]
|
|
87
|
+
return "\n".join(texts) if any(t.strip() for t in texts) else "\n".join(
|
|
88
|
+
"[image]" for b in blocks if b.get("type") == "image")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def prompt_of(entry):
|
|
92
|
+
"""The prompt a user line carries, or None when it isn't one the developer typed."""
|
|
93
|
+
if entry.get("isMeta") or entry.get("turnOrigin") == "task_notification":
|
|
94
|
+
return None
|
|
95
|
+
content = entry["message"].get("content")
|
|
96
|
+
if isinstance(content, list) and any(isinstance(b, dict) and b.get("type") == "tool_result" for b in content):
|
|
97
|
+
return None
|
|
98
|
+
text = HARNESS.sub("", REMINDER.sub("", _text(content))).strip()
|
|
99
|
+
if not text or text.startswith(NOT_PROMPTS) or text.startswith("<task-notification>"):
|
|
100
|
+
return None
|
|
101
|
+
return text
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def edit_location(patch, old_string, new_string):
|
|
105
|
+
"""Where an Edit's new text landed, from the structured patch Claude Code keeps beside the result,
|
|
106
|
+
in the words Cortex Code's edit results use ("Edit location: lines 12-16"), so the trace's diff
|
|
107
|
+
card can number its lines. None without a patch."""
|
|
108
|
+
old = (old_string or "").split("\n")
|
|
109
|
+
skip = next((i for i, line in enumerate(old) if line.strip()), 0) # leading blank lines
|
|
110
|
+
for hunk in patch or []:
|
|
111
|
+
n = hunk.get("oldStart")
|
|
112
|
+
for line in hunk.get("lines") or []:
|
|
113
|
+
if line[:1] not in (" ", "-"):
|
|
114
|
+
continue
|
|
115
|
+
if n is not None and line[1:] == old[skip]:
|
|
116
|
+
start = n - skip
|
|
117
|
+
return f"Edit location: lines {start}-{start + max(len((new_string or '').split(chr(10))), 1) - 1}"
|
|
118
|
+
n = None if n is None else n + 1
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _result(name, block, tool_result=None, args=None):
|
|
123
|
+
text = _text(block.get("content"))
|
|
124
|
+
if name == "edit" and isinstance(tool_result, dict):
|
|
125
|
+
if where := edit_location(tool_result.get("structuredPatch"), (args or {}).get("old_string"),
|
|
126
|
+
(args or {}).get("new_string")):
|
|
127
|
+
text = f"{text}\n{where}"
|
|
128
|
+
if name == "read":
|
|
129
|
+
lines = text.splitlines()[:READ_LINES]
|
|
130
|
+
numbered = [CLAUDE_READ_LINE.match(line) for line in lines]
|
|
131
|
+
if numbered and all(numbered):
|
|
132
|
+
text = "\n".join(f"{m.group(1)}... {m.group(2)}" for m in numbered)
|
|
133
|
+
# The trace marks a tool call failed when its result reads as an error.
|
|
134
|
+
if block.get("is_error") and not text.lstrip().lower().startswith("error"):
|
|
135
|
+
text = "Error: " + text
|
|
136
|
+
return clip(text, RESULT_CHARS)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def model_calls(entries):
|
|
140
|
+
"""One dict per API response on the main thread, in order: prompt (for turn starts), step,
|
|
141
|
+
model, usage, response text, tool calls, start (when its input was sent) and end."""
|
|
142
|
+
calls = OrderedDict()
|
|
143
|
+
results = {}
|
|
144
|
+
last_input, step, pending_prompt = None, 0, None
|
|
145
|
+
for e in entries:
|
|
146
|
+
if e.get("isSidechain"):
|
|
147
|
+
continue
|
|
148
|
+
kind = e.get("type")
|
|
149
|
+
if kind == "user":
|
|
150
|
+
last_input = e["timestamp"]
|
|
151
|
+
content = e["message"].get("content")
|
|
152
|
+
for block in content if isinstance(content, list) else []:
|
|
153
|
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
154
|
+
# Claude Code writes each result on its own line, with its details (an edit's
|
|
155
|
+
# structured patch) in toolUseResult.
|
|
156
|
+
results[block.get("tool_use_id")] = (block, e.get("toolUseResult"))
|
|
157
|
+
if (prompt := prompt_of(e)) is not None:
|
|
158
|
+
# Prompts sent before the agent answered (e.g. after an interrupt) open one turn together.
|
|
159
|
+
pending_prompt = prompt if pending_prompt is None else f"{pending_prompt}\n\n{prompt}"
|
|
160
|
+
step = 0
|
|
161
|
+
elif kind == "assistant":
|
|
162
|
+
m = e["message"]
|
|
163
|
+
if m.get("model") == "<synthetic>" or not m.get("id"):
|
|
164
|
+
continue
|
|
165
|
+
call = calls.get(m["id"])
|
|
166
|
+
if call is None:
|
|
167
|
+
call = calls[m["id"]] = {"prompt": pending_prompt, "step": step, "model": m.get("model"),
|
|
168
|
+
"start": last_input or e["timestamp"], "end": e["timestamp"],
|
|
169
|
+
"usage": {}, "texts": [], "tools": [], "error": e.get("isApiErrorMessage")}
|
|
170
|
+
pending_prompt, step = None, step + 1
|
|
171
|
+
call["end"] = e["timestamp"]
|
|
172
|
+
usage = m.get("usage") or {}
|
|
173
|
+
if (usage.get("output_tokens") or 0) >= (call["usage"].get("output_tokens") or 0):
|
|
174
|
+
call["usage"] = usage
|
|
175
|
+
for block in m.get("content") or []:
|
|
176
|
+
if block.get("type") == "text" and block.get("text", "").strip():
|
|
177
|
+
call["texts"].append(block["text"].strip())
|
|
178
|
+
elif block.get("type") == "tool_use":
|
|
179
|
+
name = block.get("name") or "tool"
|
|
180
|
+
call["tools"].append({"id": block.get("id"), "name": TOOL_NAMES.get(name, name.lower()),
|
|
181
|
+
"args": clip_args(block.get("input"))})
|
|
182
|
+
for call in calls.values():
|
|
183
|
+
for tool in call["tools"]:
|
|
184
|
+
block = results.get(tool["id"])
|
|
185
|
+
tool["result"] = _result(tool["name"], block[0], block[1], tool["args"]) if block else None
|
|
186
|
+
return list(calls.values())
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def to_requests(calls, session_id):
|
|
190
|
+
requests = []
|
|
191
|
+
for i, call in enumerate(calls):
|
|
192
|
+
u = call["usage"]
|
|
193
|
+
cache_read, cache_write = u.get("cache_read_input_tokens") or 0, u.get("cache_creation_input_tokens") or 0
|
|
194
|
+
requests.append(request(
|
|
195
|
+
f"claude-{session_id[:8]}-{i}", call["step"], _ts(call["start"]), _ts(call["end"]),
|
|
196
|
+
call["prompt"] if call["step"] == 0 else None, "\n\n".join(call["texts"]) or None, call["tools"],
|
|
197
|
+
model=call["model"],
|
|
198
|
+
status="ERROR" if call["error"] else "SUCCESS",
|
|
199
|
+
# The app's input count includes cached input, as Snowflake's telemetry does.
|
|
200
|
+
input_tokens=(u.get("input_tokens") or 0) + cache_read + cache_write,
|
|
201
|
+
output_tokens=u.get("output_tokens") or 0,
|
|
202
|
+
cache_read_tokens=cache_read,
|
|
203
|
+
cache_write_tokens=cache_write))
|
|
204
|
+
return requests
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def git_reports(entries, repo_dir=None):
|
|
208
|
+
"""One report per prompt, like the git hook's UserPromptSubmit: the git state of the folder the
|
|
209
|
+
prompt ran in (or of repo_dir), with the running prompt count. The branch is the one Claude Code
|
|
210
|
+
recorded with the prompt when it ran in that repository, so work on earlier branches keeps its
|
|
211
|
+
branch; the commit and dirty flag are known only for the branch checked out now."""
|
|
212
|
+
reports, states, tops = [], {}, {}
|
|
213
|
+
for e in entries:
|
|
214
|
+
if e.get("type") != "user" or e.get("isSidechain") or prompt_of(e) is None:
|
|
215
|
+
continue
|
|
216
|
+
folder = repo_dir or e.get("cwd")
|
|
217
|
+
if folder not in states:
|
|
218
|
+
states[folder] = repo_state(folder)
|
|
219
|
+
state = states[folder]
|
|
220
|
+
if not state:
|
|
221
|
+
continue
|
|
222
|
+
report = {"observed_at": _ts(e["timestamp"]), "prompt_seq": len(reports) + 1, **state}
|
|
223
|
+
cwd, recorded = e.get("cwd"), e.get("gitBranch")
|
|
224
|
+
if recorded and cwd:
|
|
225
|
+
for path in {cwd, folder}:
|
|
226
|
+
if path not in tops:
|
|
227
|
+
tops[path] = toplevel(path)
|
|
228
|
+
if tops[cwd] and tops[cwd] == tops[folder]:
|
|
229
|
+
branch = None if recorded == "HEAD" else recorded
|
|
230
|
+
if branch != state["branch"]:
|
|
231
|
+
report.update(branch=branch, head_sha=None, is_dirty=None)
|
|
232
|
+
reports.append(report)
|
|
233
|
+
return reports
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def payload(path, repo_dir=None, folder=None):
|
|
237
|
+
"""The session for core.ingest_external_session. `repo_dir` pins its repository. Claude Code
|
|
238
|
+
records each prompt's folder itself, so `folder` isn't needed."""
|
|
239
|
+
entries = [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
|
|
240
|
+
entries = [e for e in entries if e.get("type") in ("user", "assistant") and e.get("timestamp")]
|
|
241
|
+
session_id = next(e["sessionId"] for e in entries if e.get("sessionId"))
|
|
242
|
+
calls = model_calls(entries)
|
|
243
|
+
models = [c["model"] for c in calls if c["model"]]
|
|
244
|
+
repo_dir = repo_dir or repo_of_paths(
|
|
245
|
+
(block.get("input") or {}).get(key)
|
|
246
|
+
for e in entries if e.get("type") == "assistant" and isinstance((e.get("message") or {}).get("content"), list)
|
|
247
|
+
for block in e["message"]["content"] if isinstance(block, dict) and block.get("type") == "tool_use"
|
|
248
|
+
for key in PATH_ARGS)
|
|
249
|
+
return {
|
|
250
|
+
"session_id": session_id,
|
|
251
|
+
"agent": "claude-code",
|
|
252
|
+
"agent_name": NAME,
|
|
253
|
+
"model": max(set(models), key=models.count) if models else None,
|
|
254
|
+
"started_at": _ts(entries[0]["timestamp"]),
|
|
255
|
+
"prompts": sum(1 for c in calls if c["step"] == 0 and c["prompt"]),
|
|
256
|
+
"git_reports": git_reports(entries, repo_dir),
|
|
257
|
+
"requests": to_requests(calls, session_id),
|
|
258
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Cortex Code. Its sessions reach the app through Snowflake's own telemetry; what it needs on the
|
|
2
|
+
developer machine is the git hook plugin the app publishes as a Cortex Extension."""
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
ID, NAME = "cortex", "Cortex Code"
|
|
8
|
+
PLUGIN = "agentledger"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SetupError(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _plugin(*args):
|
|
16
|
+
return subprocess.run(["cortex", "plugin", *args], capture_output=True, text=True, timeout=180)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detected():
|
|
20
|
+
return shutil.which("cortex") is not None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def hooked():
|
|
24
|
+
return bool(re.search(rf"^\s*{PLUGIN} v\S+ \[enabled", _plugin("list").stdout, re.M))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def install(command, config):
|
|
28
|
+
if hooked():
|
|
29
|
+
return None
|
|
30
|
+
fqn = f"{config['app']}.PLUGINS.AGENTLEDGER"
|
|
31
|
+
out = _plugin("install", fqn, *(["-c", config["connection"]] if config["connection"] else []))
|
|
32
|
+
if out.returncode:
|
|
33
|
+
reason = (out.stderr or out.stdout).strip().splitlines()[-1:] or ["no output"]
|
|
34
|
+
raise SetupError(f"couldn't install the git plugin {fqn} ({reason[0]}). An app admin publishes it "
|
|
35
|
+
"from the dashboard's Setup page, under Git integration.")
|
|
36
|
+
return f"git plugin {fqn} installed"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def uninstall(config):
|
|
40
|
+
if not re.search(rf"^\s*{PLUGIN} v", _plugin("list").stdout, re.M):
|
|
41
|
+
return False
|
|
42
|
+
return _plugin("uninstall", PLUGIN).returncode == 0
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Cursor CLI sessions, hooked from ~/.cursor/hooks.json (stop and sessionEnd).
|
|
2
|
+
|
|
3
|
+
The store is ~/.cursor/acp-sessions/<id>/store.db. Its ordered message list (user, assistant,
|
|
4
|
+
tool) maps onto the app's model calls and tool calls. After compaction the live checkpoint
|
|
5
|
+
only keeps a short tail, so the loader uses the fullest snapshot still in the file and appends
|
|
6
|
+
the live continuation. The file has no token counts and no per-message clock, so tokens stay
|
|
7
|
+
zero. Each turn starts at the time Cursor stamps on its prompt (to the minute) and each call
|
|
8
|
+
two seconds after the previous one. Cursor's own user messages (workspace context, summaries
|
|
9
|
+
after compaction) aren't prompts; a typed one comes wrapped in <user_query>. Tool names are
|
|
10
|
+
renamed to the ones the trace's file cards understand (Read, StrReplace, Write). The session is
|
|
11
|
+
attributed to the git repository its tool calls touched most, else to its workspace, and to the
|
|
12
|
+
branches the repository's reflog shows checked out while it ran.
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sqlite3
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .. import hooks
|
|
21
|
+
from ..repo import PATH_ARGS, git_reports, repo_of_paths
|
|
22
|
+
from ..trace import RESULT_CHARS, clip, clip_args, request
|
|
23
|
+
|
|
24
|
+
ID, NAME = "cursor", "Cursor CLI"
|
|
25
|
+
HOME = Path.home() / ".cursor"
|
|
26
|
+
HOOKS_FILE = HOME / "hooks.json"
|
|
27
|
+
SESSIONS = HOME / "acp-sessions"
|
|
28
|
+
EVENTS = ("stop", "sessionEnd")
|
|
29
|
+
|
|
30
|
+
TOOL_NAMES = {"Read": "read", "StrReplace": "edit", "Write": "write"}
|
|
31
|
+
ARG_RENAMES = {"path": "file_path", "contents": "content"}
|
|
32
|
+
READ_LINES = 200
|
|
33
|
+
# Cursor doesn't record the model in the store.
|
|
34
|
+
MODEL = "grok-4.7"
|
|
35
|
+
|
|
36
|
+
# "<timestamp>Sunday, Oct 11, 2026, 12:30 PM (UTC-5)</timestamp>" before each typed prompt.
|
|
37
|
+
STAMP = re.compile(r"<timestamp>\s*(.+?)\s*\(UTC(?:([+-])(\d{1,2})(?::?(\d{2}))?)?\)\s*</timestamp>")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def detected():
|
|
41
|
+
return HOME.is_dir()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def hooked():
|
|
45
|
+
return hooks.present(HOOKS_FILE, nested=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def install(command, config):
|
|
49
|
+
if hooks.install(HOOKS_FILE, EVENTS, command, nested=False):
|
|
50
|
+
return f"hook added to {HOOKS_FILE}"
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def uninstall(config):
|
|
55
|
+
return hooks.remove(HOOKS_FILE, nested=False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def job(hook):
|
|
59
|
+
"""The session a stop / sessionEnd hook is about, or None."""
|
|
60
|
+
session = hook.get("conversation_id") or hook.get("session_id") or ""
|
|
61
|
+
store = SESSIONS / session / "store.db"
|
|
62
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", session) or not store.is_file():
|
|
63
|
+
return None
|
|
64
|
+
roots = [r for r in hook.get("workspace_roots") or [] if isinstance(r, str)]
|
|
65
|
+
return {"path": str(store), "label": session, "folders": roots, "folder": roots[0] if roots else None}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def newest():
|
|
69
|
+
return max(SESSIONS.glob("*/store.db"), key=lambda p: p.stat().st_mtime, default=None)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _varint(buf, i):
|
|
73
|
+
n = shift = 0
|
|
74
|
+
while True:
|
|
75
|
+
b = buf[i]
|
|
76
|
+
i += 1
|
|
77
|
+
n |= (b & 0x7F) << shift
|
|
78
|
+
if not b & 0x80:
|
|
79
|
+
break
|
|
80
|
+
shift += 7
|
|
81
|
+
return n, i
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def message_hashes(root):
|
|
85
|
+
"""Field 1 of a checkpoint blob: message hashes in conversation order."""
|
|
86
|
+
hashes, i = [], 0
|
|
87
|
+
while i < len(root) and root[i] == 0x0A:
|
|
88
|
+
i += 1
|
|
89
|
+
n, i = _varint(root, i)
|
|
90
|
+
chunk = root[i:i + n]
|
|
91
|
+
i += n
|
|
92
|
+
if n != 32:
|
|
93
|
+
break
|
|
94
|
+
hashes.append(chunk.hex())
|
|
95
|
+
return hashes
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def stitch(fullest, latest):
|
|
99
|
+
"""Fullest snapshot plus whatever the live checkpoint added after their last shared message."""
|
|
100
|
+
if not latest:
|
|
101
|
+
return fullest
|
|
102
|
+
if not fullest:
|
|
103
|
+
return latest
|
|
104
|
+
seen = set(fullest)
|
|
105
|
+
shared = [i for i, h in enumerate(latest) if h in seen]
|
|
106
|
+
if not shared:
|
|
107
|
+
return latest
|
|
108
|
+
return fullest + [h for h in latest[shared[-1] + 1:] if h not in seen]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ordered_messages(db_path):
|
|
112
|
+
"""Messages in conversation order.
|
|
113
|
+
|
|
114
|
+
The live root is a compacted checkpoint (a short tail). Older checkpoints in the same
|
|
115
|
+
file still hold the longer list, and the longest of those is a prefix of the next.
|
|
116
|
+
"""
|
|
117
|
+
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
118
|
+
try:
|
|
119
|
+
blobs = {i: data for i, data in con.execute("SELECT id, data FROM blobs")}
|
|
120
|
+
raw = con.execute("SELECT value FROM meta").fetchone()[0]
|
|
121
|
+
finally:
|
|
122
|
+
con.close()
|
|
123
|
+
text = bytes.fromhex(raw.decode() if isinstance(raw, (bytes, bytearray)) else raw).decode()
|
|
124
|
+
meta = json.loads(text)
|
|
125
|
+
snapshots = [message_hashes(data) for data in blobs.values() if data[:2] == b"\x0a\x20"]
|
|
126
|
+
snapshots = [h for h in snapshots if h]
|
|
127
|
+
fullest = max(snapshots, key=len) if snapshots else []
|
|
128
|
+
latest = message_hashes(blobs[meta["latestRootBlobId"]]) if meta.get("latestRootBlobId") in blobs else []
|
|
129
|
+
messages = []
|
|
130
|
+
for h in stitch(fullest, latest):
|
|
131
|
+
data = blobs.get(h)
|
|
132
|
+
if data and data.startswith(b"{"):
|
|
133
|
+
messages.append(json.loads(data))
|
|
134
|
+
return meta, messages
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _text(content):
|
|
138
|
+
if isinstance(content, str):
|
|
139
|
+
return content
|
|
140
|
+
if isinstance(content, list):
|
|
141
|
+
return "\n".join(c.get("text", "") for c in content if isinstance(c, dict) and c.get("type") == "text").strip()
|
|
142
|
+
return ""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_prompt(text):
|
|
146
|
+
"""True for a prompt the person typed. Cursor wraps those in <user_query>; its own messages
|
|
147
|
+
(<user_info>, <conversation_summary>, ...) start with a tag."""
|
|
148
|
+
return bool(text) and ("<user_query>" in text or not text.lstrip().startswith("<"))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def sent_at(text):
|
|
152
|
+
"""When a prompt was sent (UTC, naive), from Cursor's <timestamp>; None without one."""
|
|
153
|
+
m = STAMP.search(text or "")
|
|
154
|
+
if not m:
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
local = datetime.strptime(m[1], "%A, %b %d, %Y, %I:%M %p")
|
|
158
|
+
except ValueError:
|
|
159
|
+
return None
|
|
160
|
+
offset = timedelta(hours=int(m[3] or 0), minutes=int(m[4] or 0))
|
|
161
|
+
return local - offset if m[2] != "-" else local + offset
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _result(name, args, result):
|
|
165
|
+
if isinstance(result, (dict, list)):
|
|
166
|
+
result = json.dumps(result)
|
|
167
|
+
result = "" if result is None else str(result)
|
|
168
|
+
if name == "read":
|
|
169
|
+
start = int(args.get("offset") or 1)
|
|
170
|
+
lines = result.splitlines()[:READ_LINES]
|
|
171
|
+
return "\n".join(f"{start + i}... {line}" for i, line in enumerate(lines))
|
|
172
|
+
return clip(result, RESULT_CHARS)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def to_requests(messages, session_id, started, ended):
|
|
176
|
+
"""One app model call per assistant message. A user message starts a turn (step 0)."""
|
|
177
|
+
calls = []
|
|
178
|
+
prompt, step, pending = None, 0, None
|
|
179
|
+
for message in messages:
|
|
180
|
+
role = message.get("role")
|
|
181
|
+
if role == "user":
|
|
182
|
+
if is_prompt(text := _text(message.get("content"))):
|
|
183
|
+
prompt, step = text, 0
|
|
184
|
+
elif role == "assistant":
|
|
185
|
+
content = message.get("content") if isinstance(message.get("content"), list) else []
|
|
186
|
+
tools = [{"id": c.get("toolCallId"), "args": clip_args(c.get("args"), ARG_RENAMES), "result": None,
|
|
187
|
+
"name": TOOL_NAMES.get(c.get("toolName"), (c.get("toolName") or "tool").lower())}
|
|
188
|
+
for c in content if isinstance(c, dict) and c.get("type") == "tool-call"]
|
|
189
|
+
pending = {"step": step, "query": prompt if step == 0 else None,
|
|
190
|
+
"at": sent_at(prompt) if step == 0 else None, "response": _text(content) or None,
|
|
191
|
+
"tools": tools}
|
|
192
|
+
calls.append(pending)
|
|
193
|
+
prompt, step = None, step + 1
|
|
194
|
+
elif role == "tool" and pending:
|
|
195
|
+
for block in message.get("content") or []:
|
|
196
|
+
if not isinstance(block, dict):
|
|
197
|
+
continue
|
|
198
|
+
for tool in pending["tools"]:
|
|
199
|
+
if tool["id"] and tool["id"] == block.get("toolCallId"):
|
|
200
|
+
tool["result"] = _result(tool["name"], tool["args"], block.get("result"))
|
|
201
|
+
requests, clock = [], started
|
|
202
|
+
for i, call in enumerate(calls):
|
|
203
|
+
# The store has no per-message clock. A turn starts when its prompt was sent, and its
|
|
204
|
+
# calls follow two seconds apart, never before the previous call.
|
|
205
|
+
start = max(clock, call["at"] or clock)
|
|
206
|
+
end, clock = start + timedelta(seconds=1), start + timedelta(seconds=2)
|
|
207
|
+
requests.append(request(f"cursor-{session_id[:8]}-{i}", call["step"], f"{start:%Y-%m-%d %H:%M:%S}",
|
|
208
|
+
f"{end:%Y-%m-%d %H:%M:%S}", call["query"], call["response"], call["tools"]))
|
|
209
|
+
return requests
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def worked_in(messages):
|
|
213
|
+
"""The git repository the session's tool calls touched most, or None."""
|
|
214
|
+
return repo_of_paths((tool.get("args") or {}).get(key)
|
|
215
|
+
for message in messages if message.get("role") == "assistant"
|
|
216
|
+
and isinstance(message.get("content"), list)
|
|
217
|
+
for tool in message["content"] if isinstance(tool, dict) and tool.get("type") == "tool-call"
|
|
218
|
+
for key in PATH_ARGS)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def payload(path, repo_dir=None, folder=None):
|
|
222
|
+
"""The session for core.ingest_external_session. `repo_dir` pins its repository; `folder`
|
|
223
|
+
(its workspace) is used when its tool calls name none."""
|
|
224
|
+
meta, messages = ordered_messages(path)
|
|
225
|
+
created = datetime.fromtimestamp(meta["createdAt"] / 1000, timezone.utc).replace(tzinfo=None)
|
|
226
|
+
ended = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
227
|
+
session_id = meta["agentId"]
|
|
228
|
+
prompts = [text for m in messages if m.get("role") == "user" and is_prompt(text := _text(m.get("content")))]
|
|
229
|
+
# The session starts with its first prompt; the store can be created long before it.
|
|
230
|
+
stamps = [at for at in map(sent_at, prompts) if at]
|
|
231
|
+
started = stamps[0] if stamps else created
|
|
232
|
+
reports = git_reports(repo_dir or worked_in(messages) or folder, started, stamps)
|
|
233
|
+
return {
|
|
234
|
+
"session_id": session_id,
|
|
235
|
+
"agent": "cursor-cli",
|
|
236
|
+
"agent_name": NAME,
|
|
237
|
+
"model": MODEL,
|
|
238
|
+
"started_at": f"{started:%Y-%m-%d %H:%M:%S}",
|
|
239
|
+
**({"git_reports": reports} if reports else {}),
|
|
240
|
+
"prompts": len(prompts),
|
|
241
|
+
"requests": to_requests(messages, session_id, started, max(ended, started + timedelta(seconds=1))),
|
|
242
|
+
}
|