agentledger-cli 0.1.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/.gitignore +9 -0
- agentledger_cli-0.1.0/PKG-INFO +53 -0
- agentledger_cli-0.1.0/README.md +45 -0
- agentledger_cli-0.1.0/pyproject.toml +30 -0
- agentledger_cli-0.1.0/src/agentledger/__init__.py +2 -0
- agentledger_cli-0.1.0/src/agentledger/__main__.py +3 -0
- agentledger_cli-0.1.0/src/agentledger/agents/__init__.py +8 -0
- agentledger_cli-0.1.0/src/agentledger/agents/claude.py +258 -0
- agentledger_cli-0.1.0/src/agentledger/agents/cortex.py +42 -0
- agentledger_cli-0.1.0/src/agentledger/agents/cursor.py +242 -0
- agentledger_cli-0.1.0/src/agentledger/agents/gemini.py +251 -0
- agentledger_cli-0.1.0/src/agentledger/app.py +45 -0
- agentledger_cli-0.1.0/src/agentledger/cli.py +232 -0
- agentledger_cli-0.1.0/src/agentledger/config.py +66 -0
- agentledger_cli-0.1.0/src/agentledger/git_context.py +242 -0
- agentledger_cli-0.1.0/src/agentledger/hooks.py +94 -0
- agentledger_cli-0.1.0/src/agentledger/repo.py +96 -0
- agentledger_cli-0.1.0/src/agentledger/runner.py +92 -0
- agentledger_cli-0.1.0/src/agentledger/trace.py +43 -0
|
@@ -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,45 @@
|
|
|
1
|
+
# agentledger
|
|
2
|
+
|
|
3
|
+
Reports your Claude Code, Cursor CLI, Gemini CLI and Cortex Code sessions to the AgentLedger Snowflake
|
|
4
|
+
Native App, so your team sees agent cost, prompts and tool use per developer, repo, branch and
|
|
5
|
+
pull request.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv tool install agentledger-cli # or: pipx install agentledger-cli
|
|
9
|
+
agentledger install
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`install` checks that your Snowflake connection can report to the app, then sets up every agent
|
|
13
|
+
it finds:
|
|
14
|
+
|
|
15
|
+
| Agent | What it adds |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Claude Code | `Stop` and `SessionEnd` hooks in `~/.claude/settings.json` |
|
|
18
|
+
| Cursor CLI | `stop` and `sessionEnd` hooks in `~/.cursor/hooks.json` |
|
|
19
|
+
| Gemini CLI | `AfterAgent` and `SessionEnd` hooks in `~/.gemini/settings.json` (`$GEMINI_CLI_HOME` moves it); Gemini CLI runs hooks in trusted folders |
|
|
20
|
+
| Cortex Code | the app's git plugin (`cortex plugin install <APP>.PLUGINS.AGENTLEDGER`); Snowflake records its sessions already |
|
|
21
|
+
|
|
22
|
+
After each turn the hook queues the session and returns at once. A background process loads it
|
|
23
|
+
into the app over your own connection, at most every two minutes per agent, so a session is
|
|
24
|
+
always stored as yours. Logs are in `~/.cache/agentledger/`.
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- A Snowflake connection in `~/.snowflake/connections.toml` that works without a prompt (key
|
|
29
|
+
pair or cached SSO), whose role an admin has granted the reporter role:
|
|
30
|
+
`GRANT APPLICATION ROLE AGENTLEDGER.APP_REPORTER TO ROLE <role>;`
|
|
31
|
+
- Python 3.10 or later.
|
|
32
|
+
|
|
33
|
+
## Options
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
agentledger install --connection work --role DEVELOPER # pick the connection and role
|
|
37
|
+
agentledger install --only ~/work # report only sessions in these folders
|
|
38
|
+
agentledger install --app AGENTLEDGER_PROD # the app's name, if it isn't AGENTLEDGER
|
|
39
|
+
agentledger status # what's set up, and each agent's latest report
|
|
40
|
+
agentledger upload claude ~/.claude/projects/*/*.jsonl # report earlier sessions
|
|
41
|
+
agentledger upload gemini ~/.gemini/tmp/*/chats/*.jsonl
|
|
42
|
+
agentledger uninstall # remove the hooks and the plugin
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Settings live in `~/.snowflake/agentledger.json`, which the Cortex Code plugin reads too.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agentledger-cli"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Report Claude Code, Cursor, Gemini CLI and Cortex Code sessions to the AgentLedger Snowflake Native App."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = ["snowflake-connector-python>=3.12"]
|
|
8
|
+
|
|
9
|
+
[project.scripts]
|
|
10
|
+
agentledger = "agentledger.cli:main"
|
|
11
|
+
|
|
12
|
+
# The distribution is agentledger-cli (agent-ledger on PyPI is someone else's); the module and the
|
|
13
|
+
# command stay agentledger.
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["src/agentledger"]
|
|
16
|
+
|
|
17
|
+
[tool.hatch.version]
|
|
18
|
+
path = "src/agentledger/__init__.py"
|
|
19
|
+
|
|
20
|
+
# src/agentledger/git_context.py links to the Cortex Code plugin's hook, which the app also ships;
|
|
21
|
+
# the source distribution carries the file itself.
|
|
22
|
+
[tool.hatch.build.targets.sdist]
|
|
23
|
+
exclude = ["src/agentledger/git_context.py"]
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.sdist.force-include]
|
|
26
|
+
"../plugin/scripts/git_context.py" = "src/agentledger/git_context.py"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
@@ -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
|