claude-finops 0.13.0 → 0.15.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.
package/README.md CHANGED
@@ -204,14 +204,61 @@ billed at another model's rate.
204
204
  },
205
205
  "budgets": { "monthly_usd": null, "daily_usd": null, "per_project_usd": {} },
206
206
  "alert_thresholds_pct": [50, 75, 90, 100],
207
+ "guard": { // per-session token budget + the session guard hook
208
+ "session_tokens": null, // billable tokens per Claude Code session; null => off
209
+ "warn_pct": [75, 80], // warn (never block) as a session crosses these
210
+ "after_approval": "step", // "step": ask again every step_pct; "once": never again
211
+ "step_pct": 25,
212
+ "projects": {} // {"/abs/path": {"session_tokens": n} | {"off": true}}
213
+ },
207
214
  "waste_rules": { /* thresholds for each detector */ },
208
215
  "anomaly": { /* z-score and ratio triggers */ },
209
216
  "scorecard": { /* the reference points each dimension is graded against */ }
210
217
  }
211
218
  ```
212
219
 
213
- The **Budgets** view edits limits, budgets and thresholds from the browser and writes
214
- them back to this file.
220
+ The **Budgets** page has three numbered blocks (① money, ② tokens, ③ one conversation) and an
221
+ Advanced section, folded away, for plan limits and alert thresholds. **How does this work?** (and
222
+ the small **?** on each block) opens a short picture guide; it also opens by itself the first
223
+ time. Values are saved to `~/.claude-finops/settings.local.json`. Each amount suggests values
224
+ from your own last 30 days, accepts shorthand (`20M`, `500k`, `$3,000`), and explains a bad
225
+ value before saving.
226
+
227
+ **Live warnings** (inside block ③) is the optional session guard hook. Your budgets work
228
+ without it; it only adds the warning and "continue?" prompt inside Claude Code.
229
+
230
+ **Settings** (sidebar, Setup) stores the optional API keys used by **Billed vs local**, in
231
+ `~/.claude-finops/secrets.local.json` (0600, the same file `--set-key` writes). The page only
232
+ ever gets back whether a key is set, where from, and its last four characters. An environment
233
+ variable (`ANTHROPIC_ADMIN_KEY`, `CURSOR_API_KEY`) takes priority over a stored key.
234
+
235
+ ### Per-session token budget and the session guard
236
+
237
+ `guard.session_tokens` is a budget for one Claude Code session, in billable tokens (input +
238
+ output + cache read + cache write, the same figure as the Tokens column in Sessions). Cache
239
+ reads dominate long sessions, which is exactly what a session cap is for. A project override
240
+ (matched on the project path; the most specific path wins) sets a different budget or turns
241
+ it off for that project.
242
+
243
+ With a budget set, **Budgets** shows the largest session this billing period against its
244
+ budget and how many sessions went over, and the dots in **Sessions** mark sessions at 75%
245
+ (amber) and 100% (red) of theirs.
246
+
247
+ The **session guard** is an opt-in Claude Code `PreToolUse` hook (`--install-guard`, or
248
+ **Install** under Live warnings on the Budgets page, block ③). Before each tool call it counts the session's tokens
249
+ from its transcript (only the lines added since the last call), and:
250
+
251
+ - below the budget, warns you once at each `warn_pct` and suggests `/compact` to Claude;
252
+ - at 100%, asks you to approve the next tool call; after you approve, it asks again every
253
+ `step_pct` (`"step"`) or not at all for that session (`"once"`). If you decline, it asks
254
+ again on the next tool call.
255
+
256
+ Checked on Claude Code 2.1.284: the approval prompt appears in the default mode, in
257
+ `--dangerously-skip-permissions` (bypass) mode, and for tools on your allow list. In
258
+ non-interactive `claude -p` there is nobody to ask, so the tool call is refused. A hook cannot
259
+ end a session, and the guard fails open: if it errors or cannot read the transcript, the tool
260
+ call goes ahead. It keeps a small state file per session in `~/.claude-finops/data/guard/`
261
+ (deleted after 30 days).
215
262
 
216
263
  Note on `waste_rules.low_output_ratio_vs_median`: sessions are flagged relative to
217
264
  *your own* median output ratio rather than an absolute number, because a healthy ratio
@@ -277,6 +324,8 @@ claude-finops --set-key store a provider API key (hidden prompt, 0600)
277
324
  claude-finops --keys which provider keys are configured
278
325
  claude-finops --install-statusline model, context % and 5-hour limit under every prompt
279
326
  claude-finops --uninstall-statusline remove it again
327
+ claude-finops --install-guard warn near, and ask at, your per-session token budget
328
+ claude-finops --uninstall-guard remove it again
280
329
  claude-finops --help everything
281
330
  ```
282
331
 
@@ -298,6 +347,20 @@ Everything the app writes lives outside the install folder, in one state directo
298
347
  secrets.local.json provider API keys (0600)
299
348
  ```
300
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
+
301
364
  Set `CLAUDE_FINOPS_HOME=/some/path` to put it elsewhere. The install folder holds
302
365
  only code and the shared defaults in `config/`, so it can be replaced on upgrade —
303
366
  or shipped as a package — without touching your data. An older in-tree `data/`
@@ -328,7 +391,8 @@ The app makes exactly one outbound request of its own: once a day it asks
328
391
  you when an upgrade is out (npm has no way to push one at you). It sends nothing
329
392
  about you or your usage. Turn it off with `NO_UPDATE_NOTIFIER=1` or
330
393
  `CLAUDE_FINOPS_NO_UPDATE_CHECK=1`. Provider cost APIs are called only if you
331
- configure a key with `--set-key`.
394
+ configure a key (with `--set-key` or on the Settings page), and only
395
+ when you click Refresh on Billed vs local.
332
396
 
333
397
  ---
334
398
 
@@ -390,6 +454,53 @@ files on Windows.
390
454
  - **Install statusline** (Act now, or `--install-statusline`) adds a `statusLine` entry to
391
455
  `~/.claude/settings.json`, keeping a `.finops-backup` copy. It won't replace a statusline you
392
456
  already have.
457
+ - **Install** under Live warnings (Budgets, block ③, or `--install-guard`) adds one `PreToolUse` hook entry to
458
+ `~/.claude/settings.json`, keeping a `.finops-backup` copy. Your other hooks are left
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.
483
+
484
+ ---
485
+
486
+ ## Jev (fast decisions)
487
+
488
+ [Jev](https://docs.typesafe.ai) is TypeSafe AI's "System One" model: very fast, very cheap typed
489
+ decisions (pick one, yes or no, a score, a value). It does not write text or code, so it does not
490
+ make Claude Code itself cheaper; it makes the apps and scripts you build cheaper where an LLM call
491
+ only decides something.
492
+
493
+ The **Jev (fast decisions)** page (sidebar, Optimize):
494
+
495
+ - **Install** runs Claude Code's own plugin manager, after showing you the commands:
496
+ `claude plugin marketplace add typesafe-ai/skills` then `claude plugin install typesafe@typesafe-ai`.
497
+ **Uninstall** runs `claude plugin uninstall typesafe@typesafe-ai`.
498
+ - **API key** is saved as `env.TYPESAFE_API_KEY` in `~/.claude/settings.json` (backed up first,
499
+ nothing else changed), so new Claude Code sessions can use it. The page only ever gets back
500
+ its last four characters. A key exported in your shell takes priority.
501
+ - **Where Jev fits** counts your Claude Code prompts that were really just a decision (short, no
502
+ tools, worded as "which one", "yes or no", "classify"…) and what they would cost at TypeSafe's
503
+ published price ($0.042 per million input tokens). Read-only.
393
504
 
394
505
  ---
395
506
 
@@ -401,7 +512,7 @@ Besides Claude Code, the warehouse loads every other coding agent it finds on th
401
512
  |---|---|---|
402
513
  | Codex | `~/.codex/sessions` | Tokens + model per turn; cost at OpenAI list price |
403
514
  | Gemini CLI | `~/.gemini/tmp/*/chats` | Tokens + model per reply; cost at Gemini list price |
404
- | Cursor | `~/.cursor/projects/*/agent-transcripts`, Cursor IDE `state.vscdb` (read-only) | Prompts + tool calls; tokens only where Cursor stored them; no model, not priced |
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 |
405
516
 
406
517
  Pick agents with the chips at the top: click for one, Cmd/Ctrl-click to combine, **All** for
407
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": {"tools": 5, "reasoning": 4, "multifile": 5, "speed": "Fast", "best_for": "Everyday coding: best value"},
6
- "claude-fable-5-1": {"tools": 5, "reasoning": 4, "multifile": 4, "speed": "Fast", "best_for": "Same price tier as Sonnet"},
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"},
@@ -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-09-28",
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.075,
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.3125,
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.375,
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.1875,
360
+ "cache_read": 0.075,
350
361
  "cache_write_5m": 0.75,
351
362
  "cache_write_1h": 0.75,
352
363
  "context_window": 1048576,
@@ -30,6 +30,23 @@
30
30
  90,
31
31
  100
32
32
  ],
33
+ "guard": {
34
+ "_comment": "Per-session token budget (billable tokens) and the opt-in session guard hook (claude-finops --install-guard). session_tokens null => no per-session budget and the guard allows everything. projects maps an absolute project path to {\"session_tokens\": n} or {\"off\": true}; sessions maps one Claude Code session id to the same (set from Running sessions, forgotten after 30 days).",
35
+ "session_tokens": null,
36
+ "warn_pct": [75, 80],
37
+ "after_approval": "step",
38
+ "step_pct": 25,
39
+ "projects": {},
40
+ "sessions": {}
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
+ },
33
50
  "waste_rules": {
34
51
  "long_prompt_chars": 6000,
35
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
- Loader(db_path=tmp, source=source).build(verbose=False)
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
- if m.get("download_gb") and _free_disk_gb() < m["download_gb"] + 2:
240
- return {"model": m, "blocked": f"Needs ~{m['download_gb']} GB free disk; only "
241
- f"{_free_disk_gb():.0f} GB free."}
242
- steps.append({"do": f"Download {m['model']} (~{m['download_gb']} GB) with ollama pull",
243
- "consent": True})
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
- Scans every prompt and shell command, so it's cached on the warehouse (reset by sync);
434
- installed-state is re-checked on every call.
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
- if getattr(a, "_suggest", None) is None:
437
- a._suggest = _suggestions(a)
438
- out = a._suggest
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 WHERE p.text IS NOT NULL""")
453
- bash = a.q("""SELECT t.session_id, t.target, pj.name project FROM tool_calls t
454
- JOIN projects pj ON pj.id=t.project_id WHERE t.name='Bash'""")
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 started {len(o['sessions'])} sessions with \"{k}…\". Save it "
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
- ph = ",".join("?" * len(agents))
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 agent IN ({ph}) GROUP BY model""", agents)}
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
- "Unrated models show — until you add them there. Tool use matters most: coding "
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": [os.path.join(HOME, ".cursor", "projects"), _cursor_state_db()],
48
- "note": "Prompts and tool calls from agent transcripts; token counts only where "
49
- "the Cursor IDE stored them, with no model. Cursor bills by subscription, "
50
- "so no cost is estimated."},
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 or "")[:10]
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), t.hour if t else None, model,
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
- root = os.path.join(HOME, ".cursor", "projects")
229
- for fp in sorted(glob.glob(os.path.join(root, "*", "agent-transcripts", "*", "*.jsonl"))):
230
- slug = os.path.relpath(fp, root).split(os.sep)[0]
231
- # "Users-me-Documents-JIRA" -> best effort real path, else show the slug
232
- guess = "/" + slug.replace("-", "/")
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):