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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""AgentLedger hook: tell the Snowflake app which repo and branch a coding-agent
|
|
3
|
+
session is working on, so its cost can be attributed to branches and pull requests.
|
|
4
|
+
|
|
5
|
+
Runs on SessionStart, UserPromptSubmit and SubagentStart (Cortex Code and Claude Code hook
|
|
6
|
+
format). Every prompt is reported with a running count, so the app can count turns exactly;
|
|
7
|
+
SessionStart is reported only when the git state changed. For SubagentStart it reports the
|
|
8
|
+
sub-agent's session with its parent, because Cortex Code's telemetry records sub-agents as
|
|
9
|
+
separate sessions without linking them.
|
|
10
|
+
It reads the hook payload from stdin, collects git state for the session's working
|
|
11
|
+
directory, and, when the state changed, calls <app>.core.register_git_context in the
|
|
12
|
+
background through the Snowflake CLI. The hook itself returns immediately and never fails
|
|
13
|
+
the agent: any problem is logged to ~/.cache/agentledger/hook.log.
|
|
14
|
+
|
|
15
|
+
No configuration is needed when the app publishes this plugin (it writes the app name to
|
|
16
|
+
config.json in the plugin root) and the agent is Cortex Code (the session's Snowflake
|
|
17
|
+
connection is read from its metadata file). Otherwise, or to override, use
|
|
18
|
+
~/.snowflake/agentledger.json:
|
|
19
|
+
{"app": "AGENTLEDGER", "connection": "my_conn", "role": "DEVELOPER"}
|
|
20
|
+
Environment variables AGENTLEDGER_APP / AGENTLEDGER_CONNECTION / AGENTLEDGER_ROLE override both.
|
|
21
|
+
"""
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
|
|
29
|
+
STATE_DIR = os.path.expanduser("~/.cache/agentledger")
|
|
30
|
+
CONFIG_PATH = os.path.expanduser("~/.snowflake/agentledger.json")
|
|
31
|
+
PLUGIN_CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json")
|
|
32
|
+
LOG_PATH = os.path.join(STATE_DIR, "hook.log")
|
|
33
|
+
MAX_LOG_BYTES = 256 * 1024
|
|
34
|
+
IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def log(message):
|
|
38
|
+
try:
|
|
39
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
40
|
+
if os.path.exists(LOG_PATH) and os.path.getsize(LOG_PATH) > MAX_LOG_BYTES:
|
|
41
|
+
os.replace(LOG_PATH, LOG_PATH + ".1")
|
|
42
|
+
with open(LOG_PATH, "a") as f:
|
|
43
|
+
f.write(f"{datetime.now(timezone.utc).isoformat()} {message}\n")
|
|
44
|
+
except OSError:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_json(path):
|
|
49
|
+
try:
|
|
50
|
+
with open(path) as f:
|
|
51
|
+
data = json.load(f)
|
|
52
|
+
return data if isinstance(data, dict) else {}
|
|
53
|
+
except (OSError, ValueError):
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_config(session_meta=None):
|
|
58
|
+
"""Defaults < plugin config.json < session metadata < user config file < environment."""
|
|
59
|
+
config = {"app": "AGENTLEDGER", "connection": None, "role": None}
|
|
60
|
+
sources = [
|
|
61
|
+
read_json(PLUGIN_CONFIG_PATH),
|
|
62
|
+
{"connection": (session_meta or {}).get("connection_name")},
|
|
63
|
+
read_json(CONFIG_PATH),
|
|
64
|
+
]
|
|
65
|
+
for source in sources:
|
|
66
|
+
config.update({k: v for k, v in source.items() if k in config and v})
|
|
67
|
+
for key in config:
|
|
68
|
+
config[key] = os.environ.get(f"AGENTLEDGER_{key.upper()}", config[key])
|
|
69
|
+
return config
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def session_metadata(transcript_path):
|
|
73
|
+
"""Cortex Code's per-session metadata (connection_name, git_branch, ...), if that's what
|
|
74
|
+
the hook's transcript_path points at. Claude Code transcripts are JSONL and yield {}."""
|
|
75
|
+
if not transcript_path or not transcript_path.endswith(".json"):
|
|
76
|
+
return {}
|
|
77
|
+
return read_json(transcript_path)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_remote(url):
|
|
81
|
+
"""Return (host, slug) for a git remote URL, dropping any credentials and the port.
|
|
82
|
+
|
|
83
|
+
Handles scp-style (git@host:group/repo.git), ssh://, https:// and git:// remotes, and
|
|
84
|
+
nested GitLab groups (host/group/subgroup/repo).
|
|
85
|
+
"""
|
|
86
|
+
if not url:
|
|
87
|
+
return None, None
|
|
88
|
+
url = url.strip()
|
|
89
|
+
scp = re.match(r"^(?:[^@/]+@)?([^:/]+):(?!//)(.+)$", url)
|
|
90
|
+
if scp and "://" not in url:
|
|
91
|
+
host, path = scp.group(1), scp.group(2)
|
|
92
|
+
else:
|
|
93
|
+
m = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://(?:[^@/]*@)?([^/:]+)(?::\d+)?/(.+)$", url)
|
|
94
|
+
if not m:
|
|
95
|
+
return None, None
|
|
96
|
+
host, path = m.group(1), m.group(2)
|
|
97
|
+
path = re.sub(r"\.git/?$", "", path.strip("/"))
|
|
98
|
+
if not path or "/" not in path:
|
|
99
|
+
return host.lower(), None
|
|
100
|
+
return host.lower(), path.lower()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def git(cwd, *args):
|
|
104
|
+
out = subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True, timeout=5)
|
|
105
|
+
return out.stdout.strip() if out.returncode == 0 else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def git_state(cwd):
|
|
109
|
+
if not git(cwd, "rev-parse", "--is-inside-work-tree"):
|
|
110
|
+
return None
|
|
111
|
+
host, slug = parse_remote(git(cwd, "config", "--get", "remote.origin.url"))
|
|
112
|
+
branch = git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
|
|
113
|
+
return {
|
|
114
|
+
"repo_host": host,
|
|
115
|
+
"repo_slug": slug,
|
|
116
|
+
"branch": None if branch == "HEAD" else branch, # detached HEAD
|
|
117
|
+
"head_sha": git(cwd, "rev-parse", "HEAD"),
|
|
118
|
+
"is_dirty": bool(git(cwd, "status", "--porcelain", "--untracked-files=no")),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sql_string(value):
|
|
123
|
+
"""Quote a value as a Snowflake single-quoted string literal."""
|
|
124
|
+
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_call(app, payload):
|
|
128
|
+
if not IDENTIFIER.match(app):
|
|
129
|
+
raise ValueError(f"invalid app name: {app!r}")
|
|
130
|
+
return f"CALL {app}.core.register_git_context(PARSE_JSON({sql_string(json.dumps(payload))}))"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def state_path(session_id):
|
|
134
|
+
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", session_id)[:128]
|
|
135
|
+
return os.path.join(STATE_DIR, f"session-{safe}.json")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def next_prompt(session_id):
|
|
139
|
+
"""Count this session's prompts (1, 2, ...), so the app can count turns exactly."""
|
|
140
|
+
path = state_path(session_id)[:-len(".json")] + ".prompts"
|
|
141
|
+
try:
|
|
142
|
+
with open(path) as f:
|
|
143
|
+
n = int(f.read().strip() or 0) + 1
|
|
144
|
+
except (OSError, ValueError):
|
|
145
|
+
n = 1
|
|
146
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
147
|
+
with open(path, "w") as f:
|
|
148
|
+
f.write(str(n))
|
|
149
|
+
return n
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def changed_since_last_report(session_id, state):
|
|
153
|
+
"""True if this session hasn't reported this repo/branch/commit yet."""
|
|
154
|
+
key = {k: state[k] for k in ("repo_host", "repo_slug", "branch", "head_sha")}
|
|
155
|
+
path = state_path(session_id)
|
|
156
|
+
try:
|
|
157
|
+
with open(path) as f:
|
|
158
|
+
if json.load(f) == key:
|
|
159
|
+
return False
|
|
160
|
+
except (OSError, ValueError):
|
|
161
|
+
pass
|
|
162
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
163
|
+
with open(path, "w") as f:
|
|
164
|
+
json.dump(key, f)
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def detect_agent(transcript_path):
|
|
169
|
+
if transcript_path and "/.snowflake/cortex/" in transcript_path:
|
|
170
|
+
return "cortex-code"
|
|
171
|
+
if transcript_path and "/.claude/" in transcript_path:
|
|
172
|
+
return "claude-code"
|
|
173
|
+
return "claude-code" if "CLAUDE_PROJECT_DIR" in os.environ else "cortex-code"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def send(config, sql):
|
|
177
|
+
cmd = ["snow", "sql", "--silent", "--enable-templating", "NONE", "-q", sql]
|
|
178
|
+
if config["connection"]:
|
|
179
|
+
cmd += ["-c", config["connection"]]
|
|
180
|
+
if config["role"]:
|
|
181
|
+
cmd += ["--role", config["role"]]
|
|
182
|
+
out = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
183
|
+
if out.returncode != 0 or "ok" not in out.stdout:
|
|
184
|
+
log(f"register_git_context failed ({out.returncode}): {(out.stderr or out.stdout).strip()[:500]}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main():
|
|
188
|
+
if len(sys.argv) == 3 and sys.argv[1] == "--send":
|
|
189
|
+
send(load_config(), sys.argv[2])
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
hook = json.load(sys.stdin)
|
|
194
|
+
except ValueError:
|
|
195
|
+
return
|
|
196
|
+
session_id = hook.get("session_id")
|
|
197
|
+
event = hook.get("hook_event_name")
|
|
198
|
+
parent = None
|
|
199
|
+
if event == "SubagentStart":
|
|
200
|
+
parent = hook.get("parent_session_id") or session_id
|
|
201
|
+
session_id = hook.get("agent_id")
|
|
202
|
+
cwd = hook.get("cwd") or os.environ.get("CORTEX_PROJECT_DIR") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
203
|
+
if not session_id:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
state = git_state(cwd)
|
|
207
|
+
prompt_seq = next_prompt(session_id) if event == "UserPromptSubmit" else None
|
|
208
|
+
changed = state is not None and changed_since_last_report(session_id, state)
|
|
209
|
+
if not (parent or prompt_seq or changed):
|
|
210
|
+
return
|
|
211
|
+
# Sub-agents and prompts are reported even outside a repo (the app counts turns from prompts).
|
|
212
|
+
state = state or {k: None for k in ("repo_host", "repo_slug", "branch", "head_sha", "is_dirty")}
|
|
213
|
+
|
|
214
|
+
transcript_path = hook.get("transcript_path")
|
|
215
|
+
config = load_config(session_metadata(transcript_path))
|
|
216
|
+
payload = {
|
|
217
|
+
"session_id": session_id,
|
|
218
|
+
"event": event,
|
|
219
|
+
"agent": detect_agent(transcript_path),
|
|
220
|
+
"observed_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f"),
|
|
221
|
+
**state,
|
|
222
|
+
}
|
|
223
|
+
if parent:
|
|
224
|
+
payload.update(parent_session_id=parent, agent_type=hook.get("agent_type"))
|
|
225
|
+
if prompt_seq:
|
|
226
|
+
payload["prompt_seq"] = prompt_seq
|
|
227
|
+
# Detach so the agent never waits on the Snowflake round trip. The resolved connection
|
|
228
|
+
# and role go through the environment, since the child has no hook payload.
|
|
229
|
+
env = dict(os.environ)
|
|
230
|
+
env.update({f"AGENTLEDGER_{k.upper()}": v for k, v in config.items() if v})
|
|
231
|
+
subprocess.Popen(
|
|
232
|
+
[sys.executable, os.path.abspath(__file__), "--send", build_call(config["app"], payload)],
|
|
233
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
234
|
+
start_new_session=True, env=env,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
try:
|
|
240
|
+
main()
|
|
241
|
+
except Exception as e: # a broken hook must never break the agent
|
|
242
|
+
log(f"hook error: {e!r}")
|
agentledger/hooks.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Adding and removing the agentledger hook in an agent's JSON hook file.
|
|
2
|
+
|
|
3
|
+
Cursor's ~/.cursor/hooks.json lists commands per event:
|
|
4
|
+
{"version": 1, "hooks": {"stop": [{"command": "..."}]}}
|
|
5
|
+
Claude Code's settings.json nests them in matcher groups:
|
|
6
|
+
{"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "..."}]}]}}
|
|
7
|
+
Only entries that run agentledger are touched; everything else in the file is kept as it was.
|
|
8
|
+
"""
|
|
9
|
+
import shlex
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .config import read_json, write_json
|
|
13
|
+
|
|
14
|
+
# The repo's own hook script, before the agentledger CLI. Replaced on install.
|
|
15
|
+
LEGACY = "session_hook.py"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_ours(command, legacy=True):
|
|
19
|
+
try:
|
|
20
|
+
words = shlex.split(command) if isinstance(command, str) else []
|
|
21
|
+
except ValueError:
|
|
22
|
+
return False
|
|
23
|
+
names = ("agentledger", LEGACY) if legacy else ("agentledger",)
|
|
24
|
+
return any(Path(w).name in names for w in words[:2])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _commands(entry, nested):
|
|
28
|
+
if not isinstance(entry, dict):
|
|
29
|
+
return []
|
|
30
|
+
if not nested:
|
|
31
|
+
return [entry.get("command")]
|
|
32
|
+
return [h.get("command") for h in entry.get("hooks") or [] if isinstance(h, dict)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _without_ours(entry, nested):
|
|
36
|
+
"""The entry with our commands taken out, or None when nothing else is left in it."""
|
|
37
|
+
if not any(is_ours(c) for c in _commands(entry, nested)):
|
|
38
|
+
return entry
|
|
39
|
+
if not nested:
|
|
40
|
+
return None
|
|
41
|
+
rest = [h for h in entry["hooks"] if not (isinstance(h, dict) and is_ours(h.get("command")))]
|
|
42
|
+
return {**entry, "hooks": rest} if rest else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _strip(hooks, nested):
|
|
46
|
+
for event in list(hooks):
|
|
47
|
+
if not isinstance(hooks[event], list):
|
|
48
|
+
continue
|
|
49
|
+
kept = [e for e in (_without_ours(e, nested) for e in hooks[event]) if e is not None]
|
|
50
|
+
if kept:
|
|
51
|
+
hooks[event] = kept
|
|
52
|
+
elif hooks[event]:
|
|
53
|
+
del hooks[event]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def present(path, nested):
|
|
57
|
+
"""True when the file runs the current agentledger hook."""
|
|
58
|
+
hooks = read_json(path).get("hooks") or {}
|
|
59
|
+
return any(is_ours(c, legacy=False) for entries in hooks.values() if isinstance(entries, list)
|
|
60
|
+
for entry in entries for c in _commands(entry, nested))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def install(path, events, command, nested):
|
|
64
|
+
"""Points `events` at `command`, replacing earlier agentledger entries. Returns whether the
|
|
65
|
+
file changed."""
|
|
66
|
+
data = read_json(path)
|
|
67
|
+
before = repr(data)
|
|
68
|
+
hooks = data.setdefault("hooks", {})
|
|
69
|
+
if not nested:
|
|
70
|
+
data.setdefault("version", 1)
|
|
71
|
+
_strip(hooks, nested)
|
|
72
|
+
for event in events:
|
|
73
|
+
hooks.setdefault(event, []).append(
|
|
74
|
+
{"hooks": [{"type": "command", "command": command}]} if nested else {"command": command})
|
|
75
|
+
if repr(data) == before:
|
|
76
|
+
return False
|
|
77
|
+
write_json(path, data)
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def remove(path, nested):
|
|
82
|
+
"""Removes every agentledger entry. Returns whether the file changed."""
|
|
83
|
+
data = read_json(path)
|
|
84
|
+
hooks = data.get("hooks")
|
|
85
|
+
if not isinstance(hooks, dict) or not hooks:
|
|
86
|
+
return False
|
|
87
|
+
before = repr(data)
|
|
88
|
+
_strip(hooks, nested)
|
|
89
|
+
if not hooks:
|
|
90
|
+
del data["hooks"]
|
|
91
|
+
if repr(data) == before:
|
|
92
|
+
return False
|
|
93
|
+
write_json(path, data)
|
|
94
|
+
return True
|
agentledger/repo.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Which git repository a session worked in, and on which branches."""
|
|
2
|
+
import re
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .git_context import git, git_state
|
|
8
|
+
|
|
9
|
+
# Reflog entries that leave HEAD on another branch (a rebase's own checkouts are transient).
|
|
10
|
+
MOVES = re.compile(r"^(?:checkout: moving from (?P<left>\S+) to |rebase \(finish\): returning to (?:refs/heads/)?)"
|
|
11
|
+
r"(?P<to>\S+)$")
|
|
12
|
+
SHA = re.compile(r"[0-9a-f]{7,40}")
|
|
13
|
+
|
|
14
|
+
# Tool arguments that name a file or folder, across agents.
|
|
15
|
+
PATH_ARGS = ("path", "file_path", "working_directory", "target_directory", "dir_path")
|
|
16
|
+
# A repository at the home folder holds dotfiles, never the project.
|
|
17
|
+
HOME = str(Path.home())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def toplevel(folder):
|
|
21
|
+
top = git(str(folder), "rev-parse", "--show-toplevel") if Path(folder).is_dir() else None
|
|
22
|
+
return None if top == HOME else top
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def repo_of_paths(paths):
|
|
26
|
+
"""The git repository most of these absolute paths are in, or None."""
|
|
27
|
+
tops, counts = {}, Counter()
|
|
28
|
+
for raw in paths:
|
|
29
|
+
if not isinstance(raw, str) or not raw.startswith("/"):
|
|
30
|
+
continue
|
|
31
|
+
folder = Path(raw) if Path(raw).is_dir() else Path(raw).parent
|
|
32
|
+
if folder not in tops:
|
|
33
|
+
tops[folder] = toplevel(folder)
|
|
34
|
+
if tops[folder]:
|
|
35
|
+
counts[tops[folder]] += 1
|
|
36
|
+
return counts.most_common(1)[0][0] if counts else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def repo_state(folder):
|
|
40
|
+
"""The git state the git hook would report for a folder, or None outside a repository. A
|
|
41
|
+
repository without a remote is named after its folder, on host "local"."""
|
|
42
|
+
top = toplevel(folder) if folder else None
|
|
43
|
+
state = git_state(top) if top else None
|
|
44
|
+
if state and not state["repo_slug"]:
|
|
45
|
+
state["repo_host"], state["repo_slug"] = "local", Path(top).name
|
|
46
|
+
return state
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def checkouts(top):
|
|
50
|
+
"""When HEAD moved to another branch, oldest first, from the reflog: (naive UTC datetime,
|
|
51
|
+
branch left, branch moved to). A detached HEAD (a commit or a remote branch) is None; so is
|
|
52
|
+
the branch a rebase left, which the reflog doesn't name."""
|
|
53
|
+
log = git(top, "reflog", "--date=unix", "--format=%gd%x09%gs") or ""
|
|
54
|
+
remotes = set((git(top, "for-each-ref", "--format=%(refname:short)", "refs/remotes") or "").split())
|
|
55
|
+
|
|
56
|
+
def branch(name):
|
|
57
|
+
return None if not name or name == "HEAD" or SHA.fullmatch(name) or name in remotes else name
|
|
58
|
+
|
|
59
|
+
moves = []
|
|
60
|
+
for line in log.splitlines():
|
|
61
|
+
stamp, _, subject = line.partition("\t")
|
|
62
|
+
when, move = re.search(r"\{(\d+)\}", stamp), MOVES.match(subject)
|
|
63
|
+
if when and move:
|
|
64
|
+
moves.append((datetime.fromtimestamp(int(when[1]), timezone.utc).replace(tzinfo=None),
|
|
65
|
+
branch(move["left"]), branch(move["to"])))
|
|
66
|
+
return moves[::-1]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def git_reports(folder, started, prompts):
|
|
70
|
+
"""Git reports for a session in `folder` whose files don't record branches: the state when it
|
|
71
|
+
started, at each branch checkout since (from the reflog) and at its latest prompt, each with
|
|
72
|
+
the number of prompts sent by then. Times are naive UTC datetimes; the commit and dirty flag
|
|
73
|
+
are known only for the branch checked out now. [] outside a repository."""
|
|
74
|
+
state = repo_state(folder)
|
|
75
|
+
if not state:
|
|
76
|
+
return []
|
|
77
|
+
moves = checkouts(toplevel(folder))
|
|
78
|
+
before, after = [m for m in moves if m[0] <= started], [m for m in moves if m[0] > started]
|
|
79
|
+
if before:
|
|
80
|
+
branch = before[-1][2]
|
|
81
|
+
elif after:
|
|
82
|
+
branch = after[0][1] if after[0][1] else after[0][2]
|
|
83
|
+
else:
|
|
84
|
+
branch = state["branch"]
|
|
85
|
+
points = [(started, branch)] + [(t, to) for t, _, to in after]
|
|
86
|
+
latest = max(prompts[-1] if prompts else started, started)
|
|
87
|
+
if points[-1][0] < latest:
|
|
88
|
+
points.append((latest, points[-1][1]))
|
|
89
|
+
reports = []
|
|
90
|
+
for when, branch in points:
|
|
91
|
+
report = {**state, "observed_at": f"{when:%Y-%m-%d %H:%M:%S}", "prompt_seq": sum(p <= when for p in prompts)}
|
|
92
|
+
if branch != state["branch"]:
|
|
93
|
+
report.update(branch=branch, head_sha=None, is_dirty=None)
|
|
94
|
+
reports.append(report)
|
|
95
|
+
reports[-1]["prompt_seq"] = len(prompts)
|
|
96
|
+
return reports
|
agentledger/runner.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Reporting sessions in the background, so an agent never waits on Snowflake.
|
|
2
|
+
|
|
3
|
+
A hook call queues its session under ~/.cache/agentledger/pending/ and returns. One detached
|
|
4
|
+
process per agent drains the queue, reading each session's file as it is at that moment, so turns
|
|
5
|
+
that end while it works are picked up by its next pass. Passes start at least MIN_INTERVAL
|
|
6
|
+
apart: a long session is reported every couple of minutes rather than after every turn.
|
|
7
|
+
"""
|
|
8
|
+
import fcntl
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
from .config import STATE
|
|
15
|
+
|
|
16
|
+
PENDING = STATE / "pending"
|
|
17
|
+
MIN_INTERVAL = 120
|
|
18
|
+
MAX_LOG_BYTES = 256 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def log_path(agent):
|
|
22
|
+
return STATE / f"{agent}-hook.log"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def log(agent, message):
|
|
26
|
+
path = log_path(agent)
|
|
27
|
+
STATE.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
if path.exists() and path.stat().st_size > MAX_LOG_BYTES:
|
|
29
|
+
os.replace(path, path.with_name(path.name + ".1"))
|
|
30
|
+
with path.open("a") as f:
|
|
31
|
+
f.write(f"{datetime.now(timezone.utc):%Y-%m-%d %H:%M:%S} {message}\n")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def enqueue(agent, job):
|
|
35
|
+
PENDING.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
path = PENDING / f"{agent}-{job['label']}.json"
|
|
37
|
+
tmp = path.with_name(f".{path.name}")
|
|
38
|
+
tmp.write_text(json.dumps(job))
|
|
39
|
+
os.replace(tmp, path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pending(agent):
|
|
43
|
+
return sorted(PENDING.glob(f"{agent}-*.json"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def detach(agent):
|
|
47
|
+
"""Forks a process outside the agent's session. Returns True in that process, with its output
|
|
48
|
+
going to the agent's log, and False in the caller."""
|
|
49
|
+
if os.fork():
|
|
50
|
+
return False
|
|
51
|
+
os.setsid()
|
|
52
|
+
if os.fork():
|
|
53
|
+
os._exit(0)
|
|
54
|
+
STATE.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
out = os.open(log_path(agent), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
56
|
+
null = os.open(os.devnull, os.O_RDONLY)
|
|
57
|
+
os.dup2(null, 0)
|
|
58
|
+
os.dup2(out, 1)
|
|
59
|
+
os.dup2(out, 2)
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _take(agent):
|
|
64
|
+
jobs = []
|
|
65
|
+
for path in pending(agent):
|
|
66
|
+
try:
|
|
67
|
+
jobs.append(json.loads(path.read_text()))
|
|
68
|
+
except (OSError, ValueError):
|
|
69
|
+
pass
|
|
70
|
+
path.unlink(missing_ok=True)
|
|
71
|
+
return jobs
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def drain(agent, report):
|
|
75
|
+
"""Calls report(jobs) until the queue is empty. Returns at once when another process is
|
|
76
|
+
already draining it; that one picks up what this one queued."""
|
|
77
|
+
STATE.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
last = STATE / f"{agent}-last-pass"
|
|
79
|
+
while pending(agent):
|
|
80
|
+
with open(STATE / f"{agent}.lock", "a+") as lock:
|
|
81
|
+
try:
|
|
82
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
83
|
+
except BlockingIOError:
|
|
84
|
+
return
|
|
85
|
+
while pending(agent):
|
|
86
|
+
since = time.time() - (last.stat().st_mtime if last.exists() else 0)
|
|
87
|
+
if since < MIN_INTERVAL:
|
|
88
|
+
time.sleep(MIN_INTERVAL - since)
|
|
89
|
+
last.touch()
|
|
90
|
+
report(_take(agent))
|
|
91
|
+
# Anything queued after the last check, while the lock was still held, is drained by the
|
|
92
|
+
# next turn of the outer loop.
|
agentledger/trace.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The shape every loader reduces a session to: the model-call rows core.ingest_external_session
|
|
2
|
+
loads, with tool calls packed the way Cortex Code's telemetry packs them."""
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
RESULT_CHARS = 12000
|
|
6
|
+
ARG_CHARS = 20000
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def clip(text, limit):
|
|
10
|
+
text = "" if text is None else str(text)
|
|
11
|
+
return text if len(text) <= limit else f"{text[:limit]}\n… ({len(text) - limit:,} more characters)"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def clip_args(raw, renames=None):
|
|
15
|
+
renames = renames or {}
|
|
16
|
+
return {renames.get(k, k): clip(v, ARG_CHARS) if isinstance(v, str) else v for k, v in (raw or {}).items()}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def request(request_id, step, start, end, query, response, tools, **usage):
|
|
20
|
+
"""One model call. `tools` are {"id", "name", "args", "result"}; `usage` is any of model, status
|
|
21
|
+
and the token counts."""
|
|
22
|
+
ids = json.dumps([t["id"] for t in tools])
|
|
23
|
+
names = [t["name"] for t in tools]
|
|
24
|
+
arg_names = json.dumps([",".join(t["args"]) for t in tools])
|
|
25
|
+
arg_values = json.dumps([",".join(json.dumps(v) for v in t["args"].values()) for t in tools])
|
|
26
|
+
return {
|
|
27
|
+
"request_id": request_id,
|
|
28
|
+
"step_number": step,
|
|
29
|
+
"start_ts": start,
|
|
30
|
+
"end_ts": end,
|
|
31
|
+
**usage,
|
|
32
|
+
"query": query,
|
|
33
|
+
"response": response,
|
|
34
|
+
"tool_names": names,
|
|
35
|
+
"selected_ids": ids,
|
|
36
|
+
"selected_names": json.dumps(names),
|
|
37
|
+
"selected_arg_names": arg_names,
|
|
38
|
+
"selected_arg_values": arg_values,
|
|
39
|
+
"executed_ids": ids,
|
|
40
|
+
"executed_arg_names": arg_names,
|
|
41
|
+
"executed_arg_values": arg_values,
|
|
42
|
+
"executed_results": json.dumps([t["result"] for t in tools]),
|
|
43
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
# agentledger
|
|
10
|
+
|
|
11
|
+
Reports your Claude Code, Cursor CLI, Gemini CLI and Cortex Code sessions to the AgentLedger Snowflake
|
|
12
|
+
Native App, so your team sees agent cost, prompts and tool use per developer, repo, branch and
|
|
13
|
+
pull request.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv tool install agentledger-cli # or: pipx install agentledger-cli
|
|
17
|
+
agentledger install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`install` checks that your Snowflake connection can report to the app, then sets up every agent
|
|
21
|
+
it finds:
|
|
22
|
+
|
|
23
|
+
| Agent | What it adds |
|
|
24
|
+
|---|---|
|
|
25
|
+
| Claude Code | `Stop` and `SessionEnd` hooks in `~/.claude/settings.json` |
|
|
26
|
+
| Cursor CLI | `stop` and `sessionEnd` hooks in `~/.cursor/hooks.json` |
|
|
27
|
+
| Gemini CLI | `AfterAgent` and `SessionEnd` hooks in `~/.gemini/settings.json` (`$GEMINI_CLI_HOME` moves it); Gemini CLI runs hooks in trusted folders |
|
|
28
|
+
| Cortex Code | the app's git plugin (`cortex plugin install <APP>.PLUGINS.AGENTLEDGER`); Snowflake records its sessions already |
|
|
29
|
+
|
|
30
|
+
After each turn the hook queues the session and returns at once. A background process loads it
|
|
31
|
+
into the app over your own connection, at most every two minutes per agent, so a session is
|
|
32
|
+
always stored as yours. Logs are in `~/.cache/agentledger/`.
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- A Snowflake connection in `~/.snowflake/connections.toml` that works without a prompt (key
|
|
37
|
+
pair or cached SSO), whose role an admin has granted the reporter role:
|
|
38
|
+
`GRANT APPLICATION ROLE AGENTLEDGER.APP_REPORTER TO ROLE <role>;`
|
|
39
|
+
- Python 3.10 or later.
|
|
40
|
+
|
|
41
|
+
## Options
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
agentledger install --connection work --role DEVELOPER # pick the connection and role
|
|
45
|
+
agentledger install --only ~/work # report only sessions in these folders
|
|
46
|
+
agentledger install --app AGENTLEDGER_PROD # the app's name, if it isn't AGENTLEDGER
|
|
47
|
+
agentledger status # what's set up, and each agent's latest report
|
|
48
|
+
agentledger upload claude ~/.claude/projects/*/*.jsonl # report earlier sessions
|
|
49
|
+
agentledger upload gemini ~/.gemini/tmp/*/chats/*.jsonl
|
|
50
|
+
agentledger uninstall # remove the hooks and the plugin
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Settings live in `~/.snowflake/agentledger.json`, which the Cortex Code plugin reads too.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
agentledger/__init__.py,sha256=V7ljJUrzkRr6xRlk9ZDqNFkntCZd5ROK9-BIw4S91Jw,98
|
|
2
|
+
agentledger/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
agentledger/app.py,sha256=KlzLPcKGtzWf_IL1oNFVRaRFZ1wMGgdV8ye4H3dXsfg,1750
|
|
4
|
+
agentledger/cli.py,sha256=cQtIgzVjmJB4c60RjrjYOuF22Y-OMxWp21brNBzdcO0,10200
|
|
5
|
+
agentledger/config.py,sha256=u5MoyOl0Nwt6NJBIQw_8x8DVSLQSEjyy0k2RPA9A0-Y,2421
|
|
6
|
+
agentledger/git_context.py,sha256=CzDQ7rl8XTDKbUJKiAKUM4aHngllsjTz7m0SZqqJN-M,9391
|
|
7
|
+
agentledger/hooks.py,sha256=TiWzxiT4sAjXS9CQyoyaQv_Oc_0ld1QVUH43tsrBhh8,3188
|
|
8
|
+
agentledger/repo.py,sha256=eLqrbmLH2WlMaHiSYOBI9hLGBmOu0LKdavpTt1p1qq0,4164
|
|
9
|
+
agentledger/runner.py,sha256=2YJ2ACkJFQhr6pzeN-fqw_BdgYgRGWb58bi_qDjz-PA,3009
|
|
10
|
+
agentledger/trace.py,sha256=AkFqhNOgZ7c6UEpNru22VUpZ7pUx6ErC7lUBWVW5Hs4,1608
|
|
11
|
+
agentledger/agents/__init__.py,sha256=BnuXvKsIQah3zkAwh7hpgAT-n-CQmA6FFcuIaoUPvIg,579
|
|
12
|
+
agentledger/agents/claude.py,sha256=5d6C1Df7dLvwQlqvwKEc0L4zmJTR3wUverR0u3ZMY1Y,12908
|
|
13
|
+
agentledger/agents/cortex.py,sha256=VDwT-doMUDzxFke1jpEded0axrUCOeVLqxlh7e9A_dw,1351
|
|
14
|
+
agentledger/agents/cursor.py,sha256=kB9L9cO85vK57MP3JJFJJ80xN5jSBXt8VFKANMN4QvA,10058
|
|
15
|
+
agentledger/agents/gemini.py,sha256=2yFI527xAO8cQQrtVrPnSfe1oBS--4GsJKzC-MDEtVY,12391
|
|
16
|
+
agentledger_cli-0.1.0.dist-info/METADATA,sha256=jHxoSXiDXDJHX9YUe_NGMibOO2xtKgNI2OWYpgtzSPI,2455
|
|
17
|
+
agentledger_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
18
|
+
agentledger_cli-0.1.0.dist-info/entry_points.txt,sha256=BVWEN762nxZkUGqr-uD0F3iRPK4tWjrngVf9CEJI2Qc,53
|
|
19
|
+
agentledger_cli-0.1.0.dist-info/RECORD,,
|