claude-finops 0.1.0

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.
@@ -0,0 +1,296 @@
1
+ """Loaders for other coding agents on this machine, into the same warehouse as Claude Code.
2
+
3
+ Each agent's rows carry `agent` so every view can be filtered to one agent or several.
4
+ Only what the agent actually records is loaded; missing fields stay NULL/0 and the UI
5
+ says so (see AGENTS[...]["data"]).
6
+
7
+ codex ~/.codex/sessions/**/rollout-*.jsonl tokens per turn, model, prompts, tool calls
8
+ gemini ~/.gemini/tmp/<hash>/chats/*.json tokens per reply, model, prompts
9
+ cursor ~/.cursor/projects/*/agent-transcripts prompts + tool calls (no tokens, no model)
10
+ Cursor IDE state.vscdb tokens on some replies (no model)
11
+ """
12
+ import glob
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import sqlite3
17
+ import sys
18
+ from datetime import datetime, timezone
19
+
20
+ HOME = os.path.expanduser("~")
21
+ IS_WIN, IS_MAC = os.name == "nt", sys.platform == "darwin"
22
+
23
+
24
+ def _cursor_state_db():
25
+ if IS_MAC:
26
+ base = os.path.join(HOME, "Library", "Application Support", "Cursor")
27
+ elif IS_WIN:
28
+ base = os.path.join(os.environ.get("APPDATA", ""), "Cursor")
29
+ else:
30
+ base = os.path.join(HOME, ".config", "Cursor")
31
+ return os.path.join(base, "User", "globalStorage", "state.vscdb")
32
+
33
+
34
+ AGENTS = {
35
+ "claude": {"name": "Claude Code", "data": "full",
36
+ "note": "Tokens, model, cost, prompts and tool calls per request."},
37
+ "codex": {"name": "Codex", "data": "tokens",
38
+ "paths": [os.path.join(HOME, ".codex", "sessions")],
39
+ "note": "Tokens and model per turn. Cost estimated at OpenAI API list prices; "
40
+ "on a ChatGPT plan you don't pay per token."},
41
+ "gemini": {"name": "Gemini CLI", "data": "tokens",
42
+ "paths": [os.path.join(HOME, ".gemini", "tmp")],
43
+ "note": "Tokens and model per reply. Cost estimated at Gemini API list prices; "
44
+ "the free tier costs nothing."},
45
+ "cursor": {"name": "Cursor", "data": "activity",
46
+ "paths": [os.path.join(HOME, ".cursor", "projects"), _cursor_state_db()],
47
+ "note": "Prompts and tool calls from agent transcripts; token counts only where "
48
+ "the Cursor IDE stored them, with no model. Cursor bills by subscription, "
49
+ "so no cost is estimated."},
50
+ }
51
+
52
+
53
+ def detect():
54
+ """Which agents have data here (Claude is always listed)."""
55
+ out = {"claude": True}
56
+ for k, a in AGENTS.items():
57
+ if k != "claude":
58
+ out[k] = any(os.path.exists(p) for p in a.get("paths", []))
59
+ return out
60
+
61
+
62
+ def _day(ts):
63
+ return (ts or "")[:10]
64
+
65
+
66
+ def _iso_ms(ms):
67
+ try:
68
+ return datetime.fromtimestamp(int(ms) / 1000, timezone.utc).isoformat().replace("+00:00", "Z")
69
+ except (TypeError, ValueError, OSError):
70
+ return None
71
+
72
+
73
+ def _iso_mtime(path):
74
+ return datetime.fromtimestamp(os.path.getmtime(path), timezone.utc).isoformat().replace("+00:00", "Z")
75
+
76
+
77
+ class AgentLoader:
78
+ """Writes other agents' usage through the Claude Loader's tables and helpers."""
79
+
80
+ def __init__(self, loader, log=print):
81
+ self.L = loader
82
+ self.db = loader.db
83
+ self.log = log
84
+ self.counts = {}
85
+
86
+ def run(self):
87
+ found = detect()
88
+ for key, fn in (("codex", self.codex), ("gemini", self.gemini), ("cursor", self.cursor)):
89
+ if not found.get(key):
90
+ continue
91
+ try:
92
+ fn()
93
+ except Exception as exc: # one agent's odd data must not break the load
94
+ self.log(f" ! {key}: {exc}")
95
+ return self.counts
96
+
97
+ # ---------- shared writers ----------
98
+ def project(self, agent, cwd, fallback):
99
+ slug = f"{agent}:{cwd or fallback}"
100
+ pid = self.L.project_id(slug, cwd)
101
+ self.db.execute("UPDATE projects SET agent=? WHERE id=?", (agent, pid))
102
+ if not cwd:
103
+ self.db.execute("UPDATE projects SET name=? WHERE id=?", (fallback, pid))
104
+ return pid
105
+
106
+ def session(self, agent, sid, pid, src, title=None, version=None):
107
+ self.db.execute("INSERT OR REPLACE INTO sessions (id, project_id, source_file, title,"
108
+ " cli_version, agent) VALUES (?,?,?,?,?,?)",
109
+ (sid, pid, src, title, version, agent))
110
+
111
+ def prompt(self, agent, sid, pid, ts, text, uid=None):
112
+ prompt_id = self.L.insert_prompt({"timestamp": ts, "uuid": uid, "promptSource": "typed"},
113
+ text, sid, pid)
114
+ self.db.execute("UPDATE prompts SET agent=? WHERE id=?", (agent, prompt_id))
115
+ return prompt_id
116
+
117
+ def request(self, agent, sid, pid, prompt_id, ts, model, inp=0, out=0, cached=0,
118
+ think=0, tools=()):
119
+ p = self.L.pricing
120
+ priced = p.is_known(model)
121
+ cost = p.estimate(model, inp, out, cached) if priced else 0.0
122
+ nc_part, c_part = p.uncached_baseline(model, cached, 0, 0) if priced else (0.0, 0.0)
123
+ t = None
124
+ try:
125
+ t = datetime.fromisoformat((ts or "").replace("Z", "+00:00"))
126
+ except ValueError:
127
+ pass
128
+ cur = self.db.execute(
129
+ "INSERT INTO requests (session_id, project_id, prompt_id, ts, day, hour, model,"
130
+ " model_known, input_tokens, output_tokens, thinking_tokens, cache_read_tokens,"
131
+ " cache_write_5m, cache_write_1h, cache_write_tokens, billable_tokens,"
132
+ " context_tokens, est_cost_usd, est_cost_no_cache_usd, tool_call_count,"
133
+ " is_sidechain, agent) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,0,0,0,?,?,?,?,?,0,?)",
134
+ (sid, pid, prompt_id, ts, _day(ts), t.hour if t else None, model,
135
+ 1 if priced else 0, inp, out, think, cached, inp + out + cached, inp + cached,
136
+ cost, cost - c_part + nc_part, len(tools), agent))
137
+ rpk = cur.lastrowid
138
+ for name, target in tools:
139
+ self.db.execute("INSERT INTO tool_calls (request_pk, session_id, project_id,"
140
+ " prompt_id, ts, day, name, target, kind, agent)"
141
+ " VALUES (?,?,?,?,?,?,?,?,'builtin',?)",
142
+ (rpk, sid, pid, prompt_id, ts, _day(ts), name,
143
+ (target or "")[:300] or None, agent))
144
+ self.counts[agent] = self.counts.get(agent, 0) + 1
145
+
146
+ # ---------- Codex ----------
147
+ def codex(self):
148
+ files = sorted(glob.glob(os.path.join(HOME, ".codex", "sessions", "**", "*.jsonl"),
149
+ recursive=True))
150
+ for fp in files:
151
+ rows = []
152
+ with open(fp, encoding="utf-8", errors="replace") as fh:
153
+ for line in fh:
154
+ try:
155
+ rows.append(json.loads(line))
156
+ except ValueError:
157
+ continue
158
+ meta = next((r.get("payload") or {} for r in rows if r.get("type") == "session_meta"), {})
159
+ sid = "codex-" + (meta.get("id") or os.path.basename(fp))
160
+ cwd = meta.get("cwd")
161
+ pid = self.project("codex", cwd, "Codex")
162
+ self.session("codex", sid, pid, fp, version=meta.get("cli_version"))
163
+ model, prompt_id, prev_total, tools = "unknown", None, None, []
164
+ for r in rows:
165
+ typ, p = r.get("type"), r.get("payload") or {}
166
+ if typ == "turn_context":
167
+ model = p.get("model") or model
168
+ elif typ == "event_msg" and p.get("type") == "user_message":
169
+ text = (p.get("message") or "").strip()
170
+ if text:
171
+ prompt_id = self.prompt("codex", sid, pid, r.get("timestamp"), text)
172
+ elif typ == "response_item" and p.get("type") in ("function_call", "custom_tool_call"):
173
+ try:
174
+ args = json.loads(p.get("arguments") or "{}")
175
+ except ValueError:
176
+ args = {}
177
+ target = args.get("cmd") or args.get("command") or args.get("path") \
178
+ if isinstance(args, dict) else None
179
+ tools.append((p.get("name"), target if isinstance(target, str) else
180
+ " ".join(target) if isinstance(target, list) else None))
181
+ elif typ == "event_msg" and p.get("type") == "token_count" and p.get("info"):
182
+ tot = (p["info"] or {}).get("total_token_usage") or {}
183
+ key = tuple(sorted(tot.items()))
184
+ if key == prev_total: # Codex repeats the same count; skip duplicates
185
+ continue
186
+ prev_total = key
187
+ last = (p["info"] or {}).get("last_token_usage") or {}
188
+ cached = int(last.get("cached_input_tokens") or 0)
189
+ inp = max(int(last.get("input_tokens") or 0) - cached, 0)
190
+ self.request("codex", sid, pid, prompt_id, r.get("timestamp"), model,
191
+ inp, int(last.get("output_tokens") or 0), cached,
192
+ int(last.get("reasoning_output_tokens") or 0), tools)
193
+ tools = []
194
+
195
+ # ---------- Gemini CLI ----------
196
+ def gemini(self):
197
+ known = {hashlib.sha256(p.encode()).hexdigest(): p for (p,) in
198
+ self.db.execute("SELECT DISTINCT path FROM projects WHERE path IS NOT NULL")}
199
+ for fp in sorted(glob.glob(os.path.join(HOME, ".gemini", "tmp", "*", "chats", "*.json"))):
200
+ with open(fp, encoding="utf-8", errors="replace") as fh:
201
+ d = json.load(fh)
202
+ h = d.get("projectHash") or os.path.basename(os.path.dirname(os.path.dirname(fp)))
203
+ cwd = known.get(h)
204
+ pid = self.project("gemini", cwd, f"Gemini project {h[:8]}")
205
+ sid = "gemini-" + (d.get("sessionId") or os.path.basename(fp))
206
+ self.session("gemini", sid, pid, fp)
207
+ prompt_id = None
208
+ for m in d.get("messages") or []:
209
+ ts = m.get("timestamp")
210
+ if m.get("type") == "user":
211
+ text = m.get("content") if isinstance(m.get("content"), str) else \
212
+ " ".join(x.get("text", "") for x in m.get("content") or [] if isinstance(x, dict))
213
+ if (text or "").strip():
214
+ prompt_id = self.prompt("gemini", sid, pid, ts, text, m.get("id"))
215
+ elif m.get("type") == "gemini":
216
+ t = m.get("tokens") or {}
217
+ cached = int(t.get("cached") or 0)
218
+ tools = [(c.get("name"), json.dumps(c.get("args"))[:300] if c.get("args") else None)
219
+ for c in m.get("toolCalls") or [] if isinstance(c, dict)]
220
+ self.request("gemini", sid, pid, prompt_id, ts, m.get("model") or "gemini",
221
+ max(int(t.get("input") or 0) - cached, 0),
222
+ int(t.get("output") or 0) + int(t.get("thoughts") or 0),
223
+ cached, int(t.get("thoughts") or 0), tools)
224
+
225
+ # ---------- Cursor ----------
226
+ def cursor(self):
227
+ root = os.path.join(HOME, ".cursor", "projects")
228
+ for fp in sorted(glob.glob(os.path.join(root, "*", "agent-transcripts", "*", "*.jsonl"))):
229
+ slug = os.path.relpath(fp, root).split(os.sep)[0]
230
+ # "Users-me-Documents-JIRA" -> best effort real path, else show the slug
231
+ guess = "/" + slug.replace("-", "/")
232
+ cwd = guess if os.path.isdir(guess) else None
233
+ pid = self.project("cursor", cwd, slug.rsplit("-", 1)[-1] or slug)
234
+ sid = "cursor-" + os.path.splitext(os.path.basename(fp))[0]
235
+ ts = _iso_mtime(fp) # transcripts carry no timestamps; use last write
236
+ self.session("cursor", sid, pid, fp)
237
+ prompt_id = None
238
+ with open(fp, encoding="utf-8", errors="replace") as fh:
239
+ for line in fh:
240
+ try:
241
+ o = json.loads(line)
242
+ except ValueError:
243
+ continue
244
+ content = (o.get("message") or {}).get("content") or []
245
+ if o.get("role") == "user":
246
+ text = " ".join(c.get("text", "") for c in content if isinstance(c, dict))
247
+ text = text.replace("<user_query>", "").replace("</user_query>", "").strip()
248
+ if text:
249
+ prompt_id = self.prompt("cursor", sid, pid, ts, text)
250
+ elif o.get("role") == "assistant":
251
+ tools = [(c.get("name"), (c.get("input") or "")[:300]
252
+ if isinstance(c.get("input"), str) else
253
+ json.dumps(c.get("input"))[:300])
254
+ for c in content if isinstance(c, dict) and c.get("type") == "tool_use"]
255
+ self.request("cursor", sid, pid, prompt_id, ts, "cursor", tools=tools)
256
+ self.cursor_ide()
257
+
258
+ def cursor_ide(self):
259
+ """Cursor IDE chat: token counts where Cursor stored them (read-only, never locks)."""
260
+ db = _cursor_state_db()
261
+ if not os.path.exists(db):
262
+ return
263
+ uri = "file:" + db.replace("\\", "/") + "?immutable=1"
264
+ con = sqlite3.connect(uri, uri=True)
265
+ rows = con.execute("""
266
+ SELECT substr(key, 10, 36) composer, json_extract(value,'$.type') typ,
267
+ json_extract(value,'$.createdAt') ts, json_extract(value,'$.text') text,
268
+ json_extract(value,'$.tokenCount.inputTokens') i,
269
+ json_extract(value,'$.tokenCount.outputTokens') o
270
+ FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'
271
+ AND (json_extract(value,'$.type') = 1
272
+ OR json_extract(value,'$.tokenCount.inputTokens') > 0)""").fetchall()
273
+ comps = {c for c, *_ in rows}
274
+ started = {}
275
+ for c in comps:
276
+ r = con.execute("SELECT json_extract(value,'$.createdAt'), json_extract(value,'$.name')"
277
+ " FROM cursorDiskKV WHERE key=?", ("composerData:" + c,)).fetchone()
278
+ if r:
279
+ started[c] = (_iso_ms(r[0]), r[1])
280
+ con.close()
281
+ pid = self.project("cursor", None, "Cursor IDE chats")
282
+ seen, prompt_for = set(), {}
283
+ for c, typ, ts, text, i, o in rows:
284
+ ts = ts or (started.get(c) or (None,))[0]
285
+ if not ts:
286
+ continue
287
+ sid = "cursor-ide-" + c
288
+ if sid not in seen:
289
+ self.session("cursor", sid, pid, db, title=(started.get(c) or (None, None))[1])
290
+ seen.add(sid)
291
+ if typ == 1:
292
+ if (text or "").strip():
293
+ prompt_for[sid] = self.prompt("cursor", sid, pid, ts, text)
294
+ elif i:
295
+ self.request("cursor", sid, pid, prompt_for.get(sid), ts, "cursor",
296
+ int(i or 0), int(o or 0))