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 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 | `~/.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 |
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": {"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,
@@ -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
- 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):