claude-finops 0.14.0 → 0.15.1
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.
- package/README.md +38 -1
- package/config/model_compare.json +4 -2
- package/config/pricing.json +17 -6
- package/config/settings.json +8 -0
- package/finops/actions.py +54 -27
- package/finops/agents.py +13 -39
- package/finops/analytics.py +362 -141
- package/finops/api.py +69 -12
- package/finops/classify.py +6 -0
- package/finops/cloud.py +8 -4
- package/finops/diagnose.py +18 -5
- package/finops/etl.py +171 -7
- package/finops/jev.py +3 -1
- package/finops/localtime.py +53 -0
- package/finops/pricing.py +27 -0
- package/finops/procs.py +9 -4
- package/finops/subagents.py +358 -0
- package/package.json +1 -1
- package/run.py +2 -0
- package/web/app.js +216 -65
- package/web/charts.js +14 -6
- package/web/styles.css +2 -0
package/README.md
CHANGED
|
@@ -347,6 +347,20 @@ Everything the app writes lives outside the install folder, in one state directo
|
|
|
347
347
|
secrets.local.json provider API keys (0600)
|
|
348
348
|
```
|
|
349
349
|
|
|
350
|
+
Days, "today" and the billing period follow this computer's time zone (Claude Code
|
|
351
|
+
stamps transcripts in UTC; finops converts them). Set `CLAUDE_FINOPS_TZ` to an IANA name
|
|
352
|
+
such as `Asia/Kolkata` to use another one, then press **Sync**.
|
|
353
|
+
|
|
354
|
+
Claude Code deletes transcripts older than its `cleanupPeriodDays` (30 by default). A
|
|
355
|
+
sync keeps the sessions it already had when their transcript disappears, re-costed at
|
|
356
|
+
current prices, so your history outlives that cleanup. If you delete transcripts on
|
|
357
|
+
purpose and want them gone from the dashboard too, set
|
|
358
|
+
`"history": {"keep_deleted_transcripts": false}` in `settings.local.json`.
|
|
359
|
+
|
|
360
|
+
While the dashboard runs it re-reads your transcripts by itself once the data is an hour
|
|
361
|
+
old, so recent days don't read as idle. `CLAUDE_FINOPS_AUTOSYNC_MINUTES` changes the
|
|
362
|
+
interval; `0` turns it off and leaves only the **Sync** button.
|
|
363
|
+
|
|
350
364
|
Set `CLAUDE_FINOPS_HOME=/some/path` to put it elsewhere. The install folder holds
|
|
351
365
|
only code and the shared defaults in `config/`, so it can be replaced on upgrade —
|
|
352
366
|
or shipped as a package — without touching your data. An older in-tree `data/`
|
|
@@ -443,6 +457,29 @@ files on Windows.
|
|
|
443
457
|
- **Install** under Live warnings (Budgets, block ③, or `--install-guard`) adds one `PreToolUse` hook entry to
|
|
444
458
|
`~/.claude/settings.json`, keeping a `.finops-backup` copy. Your other hooks are left
|
|
445
459
|
alone, and uninstalling removes only that entry.
|
|
460
|
+
- **Start experiment** (Subagent models) adds `env.CLAUDE_CODE_SUBAGENT_MODEL` to
|
|
461
|
+
`~/.claude/settings.json`, keeping a `.finops-backup` copy, after showing you the exact change.
|
|
462
|
+
It won't overwrite a value you set yourself. **Stop** removes it only if it still holds the
|
|
463
|
+
value finops wrote.
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
## Subagent models
|
|
468
|
+
|
|
469
|
+
Subagents are the one place a model switch costs no cache rewrite: each run starts its own cache,
|
|
470
|
+
and Claude Code lets you choose its model. The **Subagent models** page (sidebar, Optimize):
|
|
471
|
+
|
|
472
|
+
- **Headline:** what subagents cost and their share of your Claude Code spend. That share is the
|
|
473
|
+
most routing could ever save, even if every run were free.
|
|
474
|
+
- **By type and model:** each subagent type, the models it ran on, and the median cost, turns,
|
|
475
|
+
output and context per run. Measured from runs that happened, never repriced, and no savings
|
|
476
|
+
total. A cheaper model is suggested only when both models have at least `subagents.min_runs`
|
|
477
|
+
runs of that type (5, in `config/settings.json`) and the cheaper one cost less per run. Your own
|
|
478
|
+
agent files get the exact `model:` line to add; built-in types point to an experiment. Forks get
|
|
479
|
+
context advice instead, since they always use your conversation's model and context.
|
|
480
|
+
- **Experiment:** run subagents on one model (`haiku`, `sonnet`, `opus` or `fable`) for a while,
|
|
481
|
+
then compare each type's runs before and after the start. Before is the same length of time
|
|
482
|
+
just before it, at most 30 days. Runs that used another model anyway are counted separately.
|
|
446
483
|
|
|
447
484
|
---
|
|
448
485
|
|
|
@@ -475,7 +512,7 @@ Besides Claude Code, the warehouse loads every other coding agent it finds on th
|
|
|
475
512
|
|---|---|---|
|
|
476
513
|
| Codex | `~/.codex/sessions` | Tokens + model per turn; cost at OpenAI list price |
|
|
477
514
|
| Gemini CLI | `~/.gemini/tmp/*/chats` | Tokens + model per reply; cost at Gemini list price |
|
|
478
|
-
| Cursor |
|
|
515
|
+
| Cursor | Cursor IDE `state.vscdb` (read-only) | Prompts, times and tokens as the IDE stored them; no model, not priced. Agent transcripts are skipped: they carry no times or tokens |
|
|
479
516
|
|
|
480
517
|
Pick agents with the chips at the top: click for one, Cmd/Ctrl-click to combine, **All** for
|
|
481
518
|
everything. Every page follows the selection, and **Agents** shows them side by side.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Qualitative 1-5 ratings for the Compare view. Judgement from model size, age and known behaviour in agentic coding. Not benchmark scores; edit freely. Keys match model ids in pricing.json or ids in free_models.json.",
|
|
3
3
|
"ratings": {
|
|
4
|
+
"claude-fable-5-1": {"tools": 5, "reasoning": 5, "multifile": 5, "speed": "Slower; long turns", "best_for": "The hardest problems; priced well above Opus"},
|
|
5
|
+
"claude-opus-5-5": {"tools": 5, "reasoning": 5, "multifile": 5, "speed": "Fast", "best_for": "Hard bugs, architecture, reviews; cheaper than Opus 5"},
|
|
4
6
|
"claude-opus-5": {"tools": 5, "reasoning": 5, "multifile": 5, "speed": "Fast", "best_for": "Hard bugs, architecture, reviews"},
|
|
5
|
-
"claude-sonnet-5":
|
|
6
|
-
"claude-
|
|
7
|
+
"claude-sonnet-5-5": {"tools": 5, "reasoning": 4, "multifile": 5, "speed": "Fast", "best_for": "Everyday coding: best value"},
|
|
8
|
+
"claude-sonnet-5": {"tools": 5, "reasoning": 4, "multifile": 5, "speed": "Fast", "best_for": "Everyday coding (previous Sonnet)"},
|
|
7
9
|
"claude-haiku-4-5-20251001": {"tools": 4, "reasoning": 3, "multifile": 3, "speed": "Fastest", "best_for": "Lookups, Explore subagents, small edits"},
|
|
8
10
|
"qwen-openrouter": {"tools": 3, "reasoning": 3, "multifile": 3, "speed": "Medium, throttled on free tier", "best_for": "Free general coding, if sending code to OpenRouter is OK"},
|
|
9
11
|
"qwen38-local": {"tools": 3, "reasoning": 3, "multifile": 3, "speed": "Needs 32 GB+ RAM", "best_for": "Strongest local option on a big machine"},
|
package/config/pricing.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"_comment": "Model pricing in USD per 1,000,000 tokens. Kept SEPARATE from usage data so it can be updated without touching the dashboard. Any cost derived from these numbers is ESTIMATED, never actual billed cost.",
|
|
3
3
|
"currency": "USD",
|
|
4
4
|
"unit": "per_million_tokens",
|
|
5
|
-
"updated": "2026-
|
|
6
|
-
"source": "Anthropic list prices (platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-28)",
|
|
5
|
+
"updated": "2026-10-02",
|
|
6
|
+
"source": "Anthropic list prices (platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-28; Sonnet 5.5 added 2026-10-02); OpenAI (developers.openai.com/api/docs/pricing) and Google (ai.google.dev/gemini-api/docs/pricing) checked 2026-10-02",
|
|
7
7
|
"models": {
|
|
8
8
|
"claude-opus-5": {
|
|
9
9
|
"display_name": "Claude Opus 5",
|
|
@@ -51,6 +51,17 @@
|
|
|
51
51
|
"provider": "anthropic",
|
|
52
52
|
"_note": "Research-preview fast mode, 2x standard. Selected when usage.speed == 'fast'."
|
|
53
53
|
},
|
|
54
|
+
"claude-sonnet-5-5": {
|
|
55
|
+
"display_name": "Claude Sonnet 5.5",
|
|
56
|
+
"tier": "balanced",
|
|
57
|
+
"input": 2.0,
|
|
58
|
+
"output": 10.0,
|
|
59
|
+
"cache_write_5m": 2.5,
|
|
60
|
+
"cache_write_1h": 4.0,
|
|
61
|
+
"cache_read": 0.2,
|
|
62
|
+
"context_window": 1000000,
|
|
63
|
+
"provider": "anthropic"
|
|
64
|
+
},
|
|
54
65
|
"claude-sonnet-5": {
|
|
55
66
|
"display_name": "Claude Sonnet 5",
|
|
56
67
|
"tier": "balanced",
|
|
@@ -311,7 +322,7 @@
|
|
|
311
322
|
"provider": "google",
|
|
312
323
|
"input": 0.3,
|
|
313
324
|
"output": 2.5,
|
|
314
|
-
"cache_read": 0.
|
|
325
|
+
"cache_read": 0.03,
|
|
315
326
|
"cache_write_5m": 0.3,
|
|
316
327
|
"cache_write_1h": 0.3,
|
|
317
328
|
"context_window": 1048576,
|
|
@@ -323,7 +334,7 @@
|
|
|
323
334
|
"provider": "google",
|
|
324
335
|
"input": 1.25,
|
|
325
336
|
"output": 10.0,
|
|
326
|
-
"cache_read": 0.
|
|
337
|
+
"cache_read": 0.125,
|
|
327
338
|
"cache_write_5m": 1.25,
|
|
328
339
|
"cache_write_1h": 1.25,
|
|
329
340
|
"context_window": 1048576,
|
|
@@ -335,7 +346,7 @@
|
|
|
335
346
|
"provider": "google",
|
|
336
347
|
"input": 1.5,
|
|
337
348
|
"output": 9.0,
|
|
338
|
-
"cache_read": 0.
|
|
349
|
+
"cache_read": 0.15,
|
|
339
350
|
"cache_write_5m": 1.5,
|
|
340
351
|
"cache_write_1h": 1.5,
|
|
341
352
|
"context_window": 1048576
|
|
@@ -346,7 +357,7 @@
|
|
|
346
357
|
"provider": "google",
|
|
347
358
|
"input": 0.75,
|
|
348
359
|
"output": 3.75,
|
|
349
|
-
"cache_read": 0.
|
|
360
|
+
"cache_read": 0.075,
|
|
350
361
|
"cache_write_5m": 0.75,
|
|
351
362
|
"cache_write_1h": 0.75,
|
|
352
363
|
"context_window": 1048576,
|
package/config/settings.json
CHANGED
|
@@ -39,6 +39,14 @@
|
|
|
39
39
|
"projects": {},
|
|
40
40
|
"sessions": {}
|
|
41
41
|
},
|
|
42
|
+
"history": {
|
|
43
|
+
"_comment": "Claude Code deletes old transcripts (cleanupPeriodDays, 30 days by default). With keep_deleted_transcripts on, a sync keeps the sessions it already had instead of dropping them; turn it off if you delete transcripts on purpose and want them gone from the dashboard too.",
|
|
44
|
+
"keep_deleted_transcripts": true
|
|
45
|
+
},
|
|
46
|
+
"subagents": {
|
|
47
|
+
"_comment": "Subagent models page: a cheaper model is only suggested, and an experiment only compared, once each side has at least min_runs subagent runs.",
|
|
48
|
+
"min_runs": 5
|
|
49
|
+
},
|
|
42
50
|
"waste_rules": {
|
|
43
51
|
"long_prompt_chars": 6000,
|
|
44
52
|
"large_context_tokens": 150000,
|
package/finops/actions.py
CHANGED
|
@@ -127,7 +127,9 @@ def sync(log, source=DEFAULT_SOURCE):
|
|
|
127
127
|
"""Rebuild the warehouse into a temp file, then swap it in (the dashboard keeps serving)."""
|
|
128
128
|
tmp = DB_PATH + ".sync"
|
|
129
129
|
log("Reading Claude transcripts…")
|
|
130
|
-
|
|
130
|
+
# the live warehouse is the "previous" one: sessions whose transcripts were deleted
|
|
131
|
+
# since are carried into the new build (history.keep_deleted_transcripts)
|
|
132
|
+
Loader(db_path=tmp, source=source, keep_from=DB_PATH if os.path.exists(DB_PATH) else None).build(verbose=False)
|
|
131
133
|
con = sqlite3.connect(tmp)
|
|
132
134
|
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
133
135
|
n = con.execute("SELECT COUNT(*) FROM requests").fetchone()[0]
|
|
@@ -210,6 +212,7 @@ def free_models():
|
|
|
210
212
|
have_model = os.path.exists(os.path.join(KEY_DIR, "openrouter.key"))
|
|
211
213
|
fits = not m.get("min_ram_gb") or ram is None or ram >= m["min_ram_gb"]
|
|
212
214
|
out.append({**m, "installed": installed and have_model, "launcher": launcher,
|
|
215
|
+
"downloaded": bool(have_model) and m["provider"] == "ollama",
|
|
213
216
|
"fits_ram": fits, "ram_gb": round(ram or 0), "free_disk_gb": round(disk)})
|
|
214
217
|
return {"models": out, "ollama_installed": bool(_ollama_bin()), "os": OS_NAME,
|
|
215
218
|
"ollama_running": pulled is not None, "bin_on_path": _on_path(BIN_DIR),
|
|
@@ -236,11 +239,18 @@ def free_model_plan(mid):
|
|
|
236
239
|
if ram and m.get("min_ram_gb") and ram < m["min_ram_gb"]:
|
|
237
240
|
steps.append({"do": f"Warning: this model wants {m['min_ram_gb']} GB RAM and this "
|
|
238
241
|
f"machine has {ram:.0f} GB. It may be very slow.", "warn": True})
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
242
|
+
pulled = _ollama_running() or []
|
|
243
|
+
have = any(n == m["model"] or n == m["model"] + ":latest" for n in pulled)
|
|
244
|
+
if have:
|
|
245
|
+
# ollama pull on a model you already have only checks it is current
|
|
246
|
+
steps.append({"do": f"{m['model']} is already downloaded; ollama pull only checks it is current",
|
|
247
|
+
"consent": False})
|
|
248
|
+
else:
|
|
249
|
+
if m.get("download_gb") and _free_disk_gb() < m["download_gb"] + 2:
|
|
250
|
+
return {"model": m, "blocked": f"Needs ~{m['download_gb']} GB free disk; only "
|
|
251
|
+
f"{_free_disk_gb():.0f} GB free."}
|
|
252
|
+
steps.append({"do": f"Download {m['model']} (~{m['download_gb']} GB) with ollama pull",
|
|
253
|
+
"consent": True})
|
|
244
254
|
else:
|
|
245
255
|
if not os.path.exists(os.path.join(KEY_DIR, "openrouter.key")):
|
|
246
256
|
needs.append({"field": "api_key", "label": "OpenRouter API key",
|
|
@@ -427,15 +437,22 @@ def _slug(s):
|
|
|
427
437
|
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")[:40] or "task"
|
|
428
438
|
|
|
429
439
|
|
|
430
|
-
def suggestions(a):
|
|
440
|
+
def suggestions(a, f=None):
|
|
431
441
|
"""Skills and MCP servers worth adding, from recurring work (actual transcripts).
|
|
432
442
|
|
|
433
|
-
|
|
434
|
-
|
|
443
|
+
Only Claude Code's own prompts and commands count (these are Claude Code skills and
|
|
444
|
+
MCP servers; another agent's templated prompts would suggest things Claude never
|
|
445
|
+
repeats), within the date range when one is given. Scanning is cached per range on
|
|
446
|
+
the warehouse (reset by sync); installed-state is re-checked on every call.
|
|
435
447
|
"""
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
448
|
+
f = f or {}
|
|
449
|
+
key = (f.get("start"), f.get("end"))
|
|
450
|
+
cache = getattr(a, "_suggest", None)
|
|
451
|
+
if not isinstance(cache, dict):
|
|
452
|
+
cache = a._suggest = {}
|
|
453
|
+
if key not in cache:
|
|
454
|
+
cache[key] = _suggestions(a, f)
|
|
455
|
+
out = cache[key]
|
|
439
456
|
installed = _installed_mcp()
|
|
440
457
|
existing = set(os.listdir(SKILLS_DIR)) if os.path.isdir(SKILLS_DIR) else set()
|
|
441
458
|
for m in out["mcp"]:
|
|
@@ -445,13 +462,21 @@ def suggestions(a):
|
|
|
445
462
|
return out
|
|
446
463
|
|
|
447
464
|
|
|
448
|
-
def _suggestions(a):
|
|
465
|
+
def _suggestions(a, f=None):
|
|
466
|
+
f = f or {}
|
|
467
|
+
rng, p = "", []
|
|
468
|
+
if f.get("start"):
|
|
469
|
+
rng += " AND {t}.day >= ?"; p.append(f["start"])
|
|
470
|
+
if f.get("end"):
|
|
471
|
+
rng += " AND {t}.day <= ?"; p.append(f["end"])
|
|
449
472
|
used_tools = {r["name"] for r in a.q("SELECT DISTINCT name FROM tool_calls WHERE name LIKE 'mcp__%'")}
|
|
450
473
|
installed = _installed_mcp()
|
|
451
|
-
corpus = a.q("""SELECT p.session_id, p.text, p.source, pj.name project FROM prompts p
|
|
452
|
-
JOIN projects pj ON pj.id=p.project_id
|
|
453
|
-
|
|
454
|
-
|
|
474
|
+
corpus = a.q(f"""SELECT p.session_id, p.text, p.source, pj.name project FROM prompts p
|
|
475
|
+
JOIN projects pj ON pj.id=p.project_id
|
|
476
|
+
WHERE p.text IS NOT NULL AND p.agent='claude' {rng.format(t='p')}""", p)
|
|
477
|
+
bash = a.q(f"""SELECT t.session_id, t.target, pj.name project FROM tool_calls t
|
|
478
|
+
JOIN projects pj ON pj.id=t.project_id
|
|
479
|
+
WHERE t.name='Bash' AND t.agent='claude' {rng.format(t='t')}""", p)
|
|
455
480
|
|
|
456
481
|
mcps = []
|
|
457
482
|
for mid, name, what, rx, args, need in MCP_CATALOG:
|
|
@@ -527,8 +552,8 @@ def _suggestions(a):
|
|
|
527
552
|
"sessions": len(o["sessions"]), "runs": len(o["sessions"]),
|
|
528
553
|
"projects": sorted(o["projects"], key=o["projects"].get, reverse=True)[:3],
|
|
529
554
|
"examples": o["examples"], "installed": name in existing,
|
|
530
|
-
"what": f"You
|
|
531
|
-
f"as a skill and trigger it with /{name}."})
|
|
555
|
+
"what": f"You typed prompts starting \"{k}…\" in {len(o['sessions'])} sessions. "
|
|
556
|
+
f"Save it as a skill and trigger it with /{name}."})
|
|
532
557
|
skills.sort(key=lambda x: (x["installed"], -x["sessions"]))
|
|
533
558
|
return {"mcp": mcps[:12], "skills": skills[:15], "skills_dir": SKILLS_DIR,
|
|
534
559
|
"note": "Suggestions come from keywords in your prompts and the shell commands Claude "
|
|
@@ -623,7 +648,7 @@ def free_model_test(log, mid):
|
|
|
623
648
|
return {"ok": True, "command": m["command"]}
|
|
624
649
|
|
|
625
650
|
|
|
626
|
-
def compare(a, agents=None):
|
|
651
|
+
def compare(a, agents=None, f=None):
|
|
627
652
|
"""Each selected agent's priced models (with your usage) side by side; free models too
|
|
628
653
|
when Claude Code is selected, since they plug into Claude Code."""
|
|
629
654
|
with open(os.path.join(ROOT, "config", "model_compare.json")) as fh:
|
|
@@ -632,18 +657,20 @@ def compare(a, agents=None):
|
|
|
632
657
|
prov = {"claude": "anthropic", "codex": "openai", "gemini": "google"}
|
|
633
658
|
want = {prov[x] for x in agents if x in prov}
|
|
634
659
|
where = {"anthropic": "Anthropic cloud", "openai": "OpenAI cloud", "google": "Google cloud"}
|
|
635
|
-
|
|
636
|
-
used = {r["model"]: r for r in a.q(f"""SELECT model, COUNT(*) requests, SUM(est_cost_usd) cost,
|
|
637
|
-
SUM(billable_tokens) tokens FROM requests
|
|
638
|
-
WHERE
|
|
660
|
+
w, wp = a.where(dict(f or {}, agents=agents))
|
|
661
|
+
used = {r["model"]: r for r in a.q(f"""SELECT r.model, COUNT(*) requests, SUM(r.est_cost_usd) cost,
|
|
662
|
+
SUM(r.billable_tokens) tokens FROM requests r
|
|
663
|
+
WHERE {w} GROUP BY r.model""", wp)}
|
|
639
664
|
rows = []
|
|
640
665
|
for mid, p in a.pricing.models.items():
|
|
641
666
|
pv = p.get("provider", "anthropic")
|
|
642
667
|
if p.get("tier") in (None, "none", "other") or pv not in want or mid.endswith("[1m]"):
|
|
643
668
|
continue
|
|
644
|
-
if pv == "anthropic" and mid not in ratings:
|
|
645
|
-
continue
|
|
646
669
|
u = used.get(mid) or {}
|
|
670
|
+
# An unrated Claude model still belongs in the table when you used it; unused
|
|
671
|
+
# unrated ones (old ids kept for pricing history) would only add noise.
|
|
672
|
+
if pv == "anthropic" and mid not in ratings and not u:
|
|
673
|
+
continue
|
|
647
674
|
rt = ratings.get(mid) or {"tools": None, "reasoning": None, "multifile": None,
|
|
648
675
|
"speed": "—", "best_for": p.get("tier", "").capitalize() + " tier"}
|
|
649
676
|
rows.append({"id": mid, "name": p.get("display_name", mid), "kind": "claude" if pv == "anthropic" else pv,
|
|
@@ -666,6 +693,6 @@ def compare(a, agents=None):
|
|
|
666
693
|
return {"rows": rows, "os": fm["os"], "ram_gb": fm["models"][0]["ram_gb"] if fm["models"] else None,
|
|
667
694
|
"agents": agents,
|
|
668
695
|
"note": "Ratings are judgement, not benchmarks; edit config/model_compare.json. "
|
|
669
|
-
"
|
|
696
|
+
"Models you used that have no rating show — until you add them there. Tool use matters most: coding "
|
|
670
697
|
"agents work by calling tools, and weak tool use means loops, broken edits "
|
|
671
698
|
"and early stops."}
|
package/finops/agents.py
CHANGED
|
@@ -17,6 +17,8 @@ import sqlite3
|
|
|
17
17
|
import sys
|
|
18
18
|
from datetime import datetime, timezone
|
|
19
19
|
|
|
20
|
+
from . import localtime
|
|
21
|
+
|
|
20
22
|
HOME = os.path.expanduser("~")
|
|
21
23
|
IS_WIN, IS_MAC = os.name == "nt", sys.platform == "darwin"
|
|
22
24
|
|
|
@@ -44,10 +46,10 @@ AGENTS = {
|
|
|
44
46
|
"note": "Tokens and model per reply. Cost estimated at Gemini API list prices; "
|
|
45
47
|
"the free tier costs nothing."},
|
|
46
48
|
"cursor": {"name": "Cursor", "data": "activity",
|
|
47
|
-
"paths": [
|
|
48
|
-
"note": "
|
|
49
|
-
"
|
|
50
|
-
"
|
|
49
|
+
"paths": [_cursor_state_db()],
|
|
50
|
+
"note": "Cursor IDE chats: prompts, times and token counts as the IDE stored "
|
|
51
|
+
"them, with no model. Cursor bills by subscription, so no cost is "
|
|
52
|
+
"estimated. Agent transcripts are not read: they have no times or tokens."},
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
|
|
@@ -61,7 +63,7 @@ def detect():
|
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
def _day(ts):
|
|
64
|
-
return (ts
|
|
66
|
+
return localtime.day(ts)
|
|
65
67
|
|
|
66
68
|
|
|
67
69
|
def _iso_ms(ms):
|
|
@@ -71,10 +73,6 @@ def _iso_ms(ms):
|
|
|
71
73
|
return None
|
|
72
74
|
|
|
73
75
|
|
|
74
|
-
def _iso_mtime(path):
|
|
75
|
-
return datetime.fromtimestamp(os.path.getmtime(path), timezone.utc).isoformat().replace("+00:00", "Z")
|
|
76
|
-
|
|
77
|
-
|
|
78
76
|
class AgentLoader:
|
|
79
77
|
"""Writes other agents' usage through the Claude Loader's tables and helpers."""
|
|
80
78
|
|
|
@@ -132,7 +130,7 @@ class AgentLoader:
|
|
|
132
130
|
" cache_write_5m, cache_write_1h, cache_write_tokens, billable_tokens,"
|
|
133
131
|
" context_tokens, est_cost_usd, est_cost_no_cache_usd, tool_call_count,"
|
|
134
132
|
" is_sidechain, agent) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,0,0,0,?,?,?,?,?,0,?)",
|
|
135
|
-
(sid, pid, prompt_id, ts, _day(ts),
|
|
133
|
+
(sid, pid, prompt_id, ts, _day(ts), localtime.hour(ts), model,
|
|
136
134
|
1 if priced else 0, inp, out, think, cached, inp + out + cached, inp + cached,
|
|
137
135
|
cost, cost - c_part + nc_part, len(tools), agent))
|
|
138
136
|
rpk = cur.lastrowid
|
|
@@ -225,35 +223,11 @@ class AgentLoader:
|
|
|
225
223
|
|
|
226
224
|
# ---------- Cursor ----------
|
|
227
225
|
def cursor(self):
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
cwd = guess if os.path.isdir(guess) else None
|
|
234
|
-
pid = self.project("cursor", cwd, slug.rsplit("-", 1)[-1] or slug)
|
|
235
|
-
sid = "cursor-" + os.path.splitext(os.path.basename(fp))[0]
|
|
236
|
-
ts = _iso_mtime(fp) # transcripts carry no timestamps; use last write
|
|
237
|
-
self.session("cursor", sid, pid, fp)
|
|
238
|
-
prompt_id = None
|
|
239
|
-
with open(fp, encoding="utf-8", errors="replace") as fh:
|
|
240
|
-
for line in fh:
|
|
241
|
-
try:
|
|
242
|
-
o = json.loads(line)
|
|
243
|
-
except ValueError:
|
|
244
|
-
continue
|
|
245
|
-
content = (o.get("message") or {}).get("content") or []
|
|
246
|
-
if o.get("role") == "user":
|
|
247
|
-
text = " ".join(c.get("text", "") for c in content if isinstance(c, dict))
|
|
248
|
-
text = text.replace("<user_query>", "").replace("</user_query>", "").strip()
|
|
249
|
-
if text:
|
|
250
|
-
prompt_id = self.prompt("cursor", sid, pid, ts, text)
|
|
251
|
-
elif o.get("role") == "assistant":
|
|
252
|
-
tools = [(c.get("name"), (c.get("input") or "")[:300]
|
|
253
|
-
if isinstance(c.get("input"), str) else
|
|
254
|
-
json.dumps(c.get("input"))[:300])
|
|
255
|
-
for c in content if isinstance(c, dict) and c.get("type") == "tool_use"]
|
|
256
|
-
self.request("cursor", sid, pid, prompt_id, ts, "cursor", tools=tools)
|
|
226
|
+
# Cursor's agent transcripts (~/.cursor/projects/*/agent-transcripts) are not
|
|
227
|
+
# read: they carry no timestamps and no token counts, so every line would have
|
|
228
|
+
# been dated by the file's last write and counted as a request it never was.
|
|
229
|
+
# Only the IDE's own store, which records when each message was sent and its
|
|
230
|
+
# token counts, is loaded.
|
|
257
231
|
self.cursor_ide()
|
|
258
232
|
|
|
259
233
|
def cursor_ide(self):
|