claude-finops 0.7.2 → 0.9.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/finops/etl.py CHANGED
@@ -5,6 +5,7 @@ Entities: account -> billing_period -> project -> session -> prompt -> request
5
5
  transcripts are stored as NULL and rendered as "Unavailable from connected Claude
6
6
  data" by the UI.
7
7
  """
8
+ import hashlib
8
9
  import json
9
10
  import os
10
11
  import re
@@ -15,9 +16,23 @@ from datetime import datetime, timezone
15
16
  from .classify import classify
16
17
  from .pricing import Pricing
17
18
 
18
- from .paths import ROOT, DB_PATH
19
+ from .paths import ROOT, DB_PATH, DESKTOP_SESSIONS
19
20
  DEFAULT_SOURCE = os.path.expanduser("~/.claude/projects")
20
21
 
22
+ SCHEMA_VERSION = 3 # 3: cross-session request_id dedup (resumed sessions copy history)
23
+
24
+
25
+ def needs_rebuild(db_path):
26
+ if not os.path.exists(db_path):
27
+ return True
28
+ try:
29
+ con = sqlite3.connect(db_path)
30
+ row = con.execute("SELECT value FROM meta WHERE key='schema_version'").fetchone()
31
+ con.close()
32
+ except sqlite3.Error:
33
+ return True
34
+ return not row or row[0] != str(SCHEMA_VERSION)
35
+
21
36
  SCHEMA = """
22
37
  PRAGMA journal_mode=WAL;
23
38
 
@@ -88,6 +103,8 @@ CREATE TABLE requests (
88
103
  context_tokens INTEGER, -- input + cache read + cache write (prompt side)
89
104
  est_cost_usd REAL,
90
105
  est_cost_no_cache_usd REAL,
106
+ priced_as TEXT, -- price list used; differs from model for long context
107
+ unpriced_long_context INTEGER DEFAULT 0, -- over the window with no [1m] price to use
91
108
  latency_ms REAL,
92
109
  tool_call_count INTEGER DEFAULT 0,
93
110
  is_sidechain INTEGER DEFAULT 0,
@@ -134,6 +151,9 @@ SLASH = re.compile(r"^\s*/([a-z0-9][\w:-]*)(?=\s|$)", re.I)
134
151
  CMD_NAME = re.compile(r"<command-name>/?([\w:-]+)</command-name>")
135
152
  WS = re.compile(r"\s+")
136
153
 
154
+ INJECTED_PREFIXES = ("<task-notification", "<local-command-stdout", "<local-command-caveat",
155
+ "<bash-input", "<bash-stdout", "<bash-stderr", "<system-reminder")
156
+
137
157
 
138
158
  def _ts(s):
139
159
  if not s:
@@ -183,14 +203,23 @@ def _slug_to_name(slug):
183
203
 
184
204
 
185
205
  class Loader:
186
- def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None, other_agents=True):
206
+ def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None, other_agents=True,
207
+ desktop_roots=None):
187
208
  self.other_agents = other_agents
209
+ # An explicit source means "load exactly this"; only the default also picks up
210
+ # the desktop app's Cowork sessions (same JSONL format, different config dir).
211
+ self.desktop_roots = (desktop_roots if desktop_roots is not None
212
+ else [DESKTOP_SESSIONS] if source == DEFAULT_SOURCE else [])
213
+ self.cowork = False
188
214
  self.db_path = db_path
189
215
  self.source = source
190
216
  self.pricing = pricing or Pricing()
191
217
  self.projects = {}
192
218
  self.agent = None
193
219
  self.pending_skill = None
220
+ self.pending_results = {}
221
+ self.group = None
222
+ self.seen_request_ids = set() # dedup request_id across sessions (resumed sessions)
194
223
 
195
224
  # ---------- infrastructure ----------
196
225
  def build(self, verbose=True):
@@ -209,13 +238,19 @@ class Loader:
209
238
  if n.endswith(".jsonl"):
210
239
  files.append(os.path.join(dirpath, n))
211
240
  files.sort()
212
- for i, fp in enumerate(files, 1):
241
+ marker = os.sep + os.path.join(".claude", "projects") + os.sep
242
+ desktop = sorted(os.path.join(d, n) for root in self.desktop_roots
243
+ for d, _, names in os.walk(root) for n in names
244
+ if n.endswith(".jsonl") and marker in d + os.sep)
245
+ for i, fp in enumerate(files + desktop, 1):
213
246
  if verbose and i % 20 == 0:
214
- print(f" ...{i}/{len(files)} transcripts", file=sys.stderr)
247
+ print(f" ...{i}/{len(files) + len(desktop)} transcripts", file=sys.stderr)
248
+ self.cowork = i > len(files)
215
249
  try:
216
250
  self.load_file(fp)
217
251
  except Exception as exc: # a corrupt transcript must not kill the load
218
252
  print(f" ! skipped {os.path.basename(fp)}: {exc}", file=sys.stderr)
253
+ self.cowork = False
219
254
  if self.other_agents:
220
255
  from .agents import AgentLoader
221
256
  counts = AgentLoader(self, log=lambda m: print(m, file=sys.stderr)).run()
@@ -230,13 +265,15 @@ class Loader:
230
265
  for k, v in (
231
266
  ("source_dir", self.source),
232
267
  ("transcript_files", str(len(files))),
268
+ ("desktop_transcript_files", str(len(desktop))),
233
269
  ("pricing_updated", str(self.pricing.updated)),
234
270
  ("pricing_source", str(self.pricing.source)),
235
271
  ("cost_basis", "estimated"),
272
+ ("schema_version", str(SCHEMA_VERSION)),
236
273
  ):
237
274
  self.db.execute("INSERT INTO meta VALUES (?,?)", (k, v))
238
275
  self.db.commit()
239
- return len(files)
276
+ return len(files) + len(desktop)
240
277
 
241
278
  def project_id(self, slug, cwd):
242
279
  if slug in self.projects:
@@ -247,6 +284,8 @@ class Loader:
247
284
  return pid
248
285
  is_sandbox = 1 if ("sandbox" in slug or slug.startswith("-private")) else 0
249
286
  name = os.path.basename(cwd) if cwd else _slug_to_name(slug)
287
+ if self.cowork:
288
+ name = f"Cowork · {name or 'session'}"
250
289
  cur = self.db.execute(
251
290
  "INSERT INTO projects (slug, path, name, is_sandbox) VALUES (?,?,?,?)",
252
291
  (slug, cwd, name or slug, is_sandbox))
@@ -287,6 +326,8 @@ class Loader:
287
326
  session_id = agent["parent"] if agent else os.path.splitext(os.path.basename(path))[0]
288
327
  self.agent = agent
289
328
  self.pending_skill = None
329
+ self.pending_results = {} # tool_use_id -> result chars, applied once the row exists
330
+ self.group = None
290
331
  cwd = next((r.get("cwd") for r in rows if r.get("cwd")), None)
291
332
  pid = self.project_id(slug, cwd)
292
333
  title = next((r.get("aiTitle") for r in rows if r.get("type") == "ai-title"), None)
@@ -301,37 +342,64 @@ class Loader:
301
342
 
302
343
  cur_prompt = None
303
344
  prev_time = None
304
-
305
- for r in rows:
306
- typ = r.get("type")
307
- ts = r.get("timestamp")
308
- t = _ts(ts)
309
-
310
- if typ == "user":
311
- msg = r.get("message") or {}
312
- self.record_results(r, msg)
313
- # tool results and meta lines are not human prompts
314
- if r.get("toolUseResult") is not None or r.get("isMeta"):
345
+ self.group = None # {"key", "lines": [...], "prev_time", "prompt_id"}
346
+
347
+ def flush():
348
+ if self.group:
349
+ self.insert_request(self.group["lines"], session_id, pid, self.group["prompt_id"],
350
+ self.group["prev_time"])
351
+ self.group = None
352
+
353
+ try:
354
+ for r in rows:
355
+ typ = r.get("type")
356
+ ts = r.get("timestamp")
357
+ t = _ts(ts)
358
+
359
+ if typ == "user":
360
+ msg = r.get("message") or {}
361
+ self.record_results(r, msg)
362
+ # tool results and meta lines are not human prompts, and must not
363
+ # split a request that is still waiting on its tool results
364
+ if r.get("toolUseResult") is not None or r.get("isMeta"):
365
+ prev_time = t or prev_time
366
+ continue
367
+ text = _text_of(msg.get("content"))
368
+ if text.lstrip().startswith(INJECTED_PREFIXES):
369
+ prev_time = t or prev_time
370
+ continue
371
+ if agent:
372
+ prev_time = t or prev_time
373
+ continue
374
+ if not text.strip():
375
+ prev_time = t or prev_time
376
+ continue
377
+ flush()
378
+ cur_prompt = self.insert_prompt(r, text, session_id, pid)
315
379
  prev_time = t or prev_time
316
- continue
317
- text = _text_of(msg.get("content"))
318
- if agent:
319
- prev_time = t or prev_time
320
- continue
321
- if not text.strip():
322
- prev_time = t or prev_time
323
- continue
324
- cur_prompt = self.insert_prompt(r, text, session_id, pid)
325
- prev_time = t or prev_time
326
380
 
327
- elif typ == "assistant":
328
- if agent and cur_prompt is None:
329
- cur_prompt = self.parent_prompt(session_id, r.get("timestamp"))
330
- self.insert_request(r, session_id, pid, cur_prompt, prev_time)
331
- prev_time = t or prev_time
381
+ elif typ == "assistant":
382
+ if agent and cur_prompt is None:
383
+ cur_prompt = self.parent_prompt(session_id, r.get("timestamp"))
384
+ key = (r.get("requestId") or (r.get("message") or {}).get("id") or r.get("uuid"))
385
+ if self.group and self.group["key"] == key:
386
+ self.group["lines"].append(r)
387
+ else:
388
+ flush()
389
+ self.pending_skill = None # never apply a stale skill id to a new group
390
+ self.group = {"key": key, "lines": [r], "prev_time": prev_time,
391
+ "prompt_id": cur_prompt}
392
+ prev_time = t or prev_time
332
393
 
333
- elif typ in ("attachment", "system"):
334
- prev_time = t or prev_time
394
+ elif typ in ("attachment", "system"):
395
+ prev_time = t or prev_time
396
+ finally:
397
+ flush()
398
+ # any result whose tool_calls row was inserted by an earlier flush
399
+ for tool_use_id, n in self.pending_results.items():
400
+ self.db.execute("UPDATE tool_calls SET result_chars=? WHERE tool_use_id=?",
401
+ (n, tool_use_id))
402
+ self.pending_results = {}
335
403
 
336
404
  def parent_prompt(self, session_id, ts):
337
405
  row = self.db.execute("SELECT id FROM prompts WHERE session_id=? AND ts<=? "
@@ -341,19 +409,34 @@ class Loader:
341
409
  def record_results(self, r, msg):
342
410
  """Size of tool results, and of skill bodies injected as meta messages."""
343
411
  content = msg.get("content")
344
- if r.get("isMeta") and getattr(self, "pending_skill", None):
345
- self.db.execute("UPDATE tool_calls SET result_chars=result_chars+? WHERE id=?",
346
- (len(_text_of(content)), self.pending_skill))
347
- self.pending_skill = None
348
- return
412
+ if r.get("isMeta"):
413
+ if self.group:
414
+ # the Skill's body can arrive before the group holding its tool_use
415
+ # flushes; stash it under that block's tool_use_id so insert_request
416
+ # applies it once the tool_calls row exists.
417
+ tool_use_id = None
418
+ for ln in self.group["lines"]:
419
+ for c in ((ln.get("message") or {}).get("content") or []):
420
+ if isinstance(c, dict) and c.get("type") == "tool_use" and c.get("name") == "Skill":
421
+ tool_use_id = c.get("id")
422
+ if tool_use_id:
423
+ n = len(_text_of(content))
424
+ self.pending_results[tool_use_id] = self.pending_results.get(tool_use_id, 0) + n
425
+ return
426
+ if getattr(self, "pending_skill", None):
427
+ self.db.execute("UPDATE tool_calls SET result_chars=result_chars+? WHERE id=?",
428
+ (len(_text_of(content)), self.pending_skill))
429
+ self.pending_skill = None
430
+ return
349
431
  if not isinstance(content, list):
350
432
  return
351
433
  for c in content:
352
434
  if isinstance(c, dict) and c.get("type") == "tool_result":
353
435
  body = c.get("content")
354
436
  n = len(body) if isinstance(body, str) else _result_chars(body)
355
- self.db.execute("UPDATE tool_calls SET result_chars=? WHERE tool_use_id=?",
356
- (n, c.get("tool_use_id")))
437
+ # the tool_calls row for this id may not exist yet: its request is
438
+ # still an open group and is only inserted when it flushes
439
+ self.pending_results[c.get("tool_use_id")] = n
357
440
 
358
441
  def insert_prompt(self, r, text, session_id, pid):
359
442
  cat, conf, ev = classify(text)
@@ -363,19 +446,23 @@ class Loader:
363
446
  src = f"slash:/{m.group(1)}"
364
447
  cat = cat if cat != "other" else "automation"
365
448
  ts = r.get("timestamp")
366
- norm = WS.sub(" ", text.strip().lower())[:500]
449
+ norm = WS.sub(" ", text.strip().lower())
450
+ norm_hash = hashlib.sha1(norm.encode("utf-8")).hexdigest()[:16]
367
451
  cur = self.db.execute(
368
452
  "INSERT INTO prompts (uuid, session_id, project_id, ts, day, text, char_len,"
369
453
  " word_len, category, category_confidence, category_evidence, source, norm_hash)"
370
454
  " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
371
455
  (r.get("uuid"), session_id, pid, ts, (ts or "")[:10], text, len(text),
372
- len(text.split()), cat, conf, json.dumps(ev), src, str(hash(norm))))
456
+ len(text.split()), cat, conf, json.dumps(ev), src, norm_hash))
373
457
  return cur.lastrowid
374
458
 
375
- def insert_request(self, r, session_id, pid, prompt_id, prev_time):
459
+ def insert_request(self, lines, session_id, pid, prompt_id, prev_time):
460
+ first, last = lines[0], lines[-1]
461
+ r = last # stop_reason / usage from the final line
376
462
  msg = r.get("message") or {}
377
463
  u = msg.get("usage") or {}
378
464
  model = msg.get("model") or "unknown"
465
+ speed = u.get("speed")
379
466
  inp = int(u.get("input_tokens") or 0)
380
467
  out = int(u.get("output_tokens") or 0)
381
468
  think = int((u.get("output_tokens_details") or {}).get("thinking_tokens") or 0)
@@ -389,11 +476,33 @@ class Loader:
389
476
  billable = inp + out + cr + cw
390
477
  context = inp + cr + cw
391
478
 
392
- cost = self.pricing.estimate(model, inp, out, cr, c5, c1)
393
- no_cache_part, cache_part = self.pricing.uncached_baseline(model, cr, c5, c1)
479
+ # Price against the variant the context proves was used, not just the name in
480
+ # the transcript: anything above the standard window was the long-context
481
+ # variant and is billed at a premium.
482
+ priced_as, unpriced_long = self.pricing.effective_model(model, context, speed=speed)
483
+ cost = self.pricing.estimate(priced_as, inp, out, cr, c5, c1)
484
+ no_cache_part, cache_part = self.pricing.uncached_baseline(priced_as, cr, c5, c1)
394
485
  cost_no_cache = cost - cache_part + no_cache_part
395
486
 
396
- ts = r.get("timestamp")
487
+ tools, seen = [], set()
488
+ for ln in lines:
489
+ for c in ((ln.get("message") or {}).get("content") or []):
490
+ if isinstance(c, dict) and c.get("type") == "tool_use" and c.get("id") not in seen:
491
+ seen.add(c.get("id"))
492
+ tools.append(c)
493
+
494
+ if billable == 0 and not tools:
495
+ return
496
+
497
+ request_id = first.get("requestId") or msg.get("id")
498
+ if request_id and request_id in self.seen_request_ids:
499
+ # resumed sessions copy earlier history verbatim, including request_ids
500
+ # already inserted from another session; keep only the first occurrence.
501
+ return
502
+ if request_id:
503
+ self.seen_request_ids.add(request_id)
504
+
505
+ ts = first.get("timestamp") # the request started at its first line
397
506
  t = _ts(ts)
398
507
  latency = None
399
508
  if t and prev_time:
@@ -401,25 +510,24 @@ class Loader:
401
510
  if 0 <= d <= 900_000: # ignore idle gaps > 15 min, they are not latency
402
511
  latency = d
403
512
 
404
- tools = [c for c in (msg.get("content") or [])
405
- if isinstance(c, dict) and c.get("type") == "tool_use"]
406
-
407
513
  cur = self.db.execute(
408
514
  "INSERT INTO requests (uuid, request_id, session_id, project_id, prompt_id,"
409
515
  " ts, day, hour, model, model_known, effort, service_tier, stop_reason,"
410
516
  " input_tokens, output_tokens, thinking_tokens, cache_read_tokens,"
411
517
  " cache_write_5m, cache_write_1h, cache_write_tokens, billable_tokens,"
412
- " context_tokens, est_cost_usd, est_cost_no_cache_usd, latency_ms,"
518
+ " context_tokens, est_cost_usd, est_cost_no_cache_usd,"
519
+ " priced_as, unpriced_long_context, latency_ms,"
413
520
  " tool_call_count, is_sidechain, agent_id, agent_type, agent_desc)"
414
- " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
415
- (r.get("uuid"), r.get("requestId"), session_id, pid, prompt_id, ts,
416
- (ts or "")[:10], (t.hour if t else None), model,
417
- 1 if self.pricing.is_known(model) else 0, r.get("effort"),
521
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
522
+ (first.get("uuid"), request_id, session_id, pid,
523
+ prompt_id, ts, (ts or "")[:10], (t.hour if t else None), model,
524
+ 1 if self.pricing.is_known(model) else 0, first.get("effort"),
418
525
  u.get("service_tier"), msg.get("stop_reason"), inp, out, think, cr,
419
- c5, c1, cw, billable, context, cost, cost_no_cache, latency,
420
- len(tools), 1 if (r.get("isSidechain") or self.agent) else 0,
526
+ c5, c1, cw, billable, context, cost, cost_no_cache,
527
+ priced_as, 1 if unpriced_long else 0, latency,
528
+ len(tools), 1 if (first.get("isSidechain") or self.agent) else 0,
421
529
  *((self.agent["id"], self.agent["type"], self.agent["desc"]) if self.agent
422
- else (None, "inline" if r.get("isSidechain") else None, None))))
530
+ else (None, "inline" if first.get("isSidechain") else None, None))))
423
531
  rpk = cur.lastrowid
424
532
 
425
533
  day = (ts or "")[:10]
@@ -449,6 +557,10 @@ class Loader:
449
557
  " VALUES (?,?,?,?,?,?,?,?,?,?,?)",
450
558
  (rpk, session_id, pid, prompt_id, ts, day, name, target, c.get("id"),
451
559
  kind, server))
560
+ tool_use_id = c.get("id")
561
+ if tool_use_id in self.pending_results:
562
+ self.db.execute("UPDATE tool_calls SET result_chars=? WHERE tool_use_id=?",
563
+ (self.pending_results.pop(tool_use_id), tool_use_id))
452
564
  if kind == "skill":
453
565
  self.pending_skill = self.db.execute("SELECT last_insert_rowid()").fetchone()[0]
454
566
  if name in FILE_TOOLS and isinstance(args, dict) and args.get("file_path"):
@@ -1,16 +1,14 @@
1
- """Live advice inside Claude Code itself: a prompt hook and a statusline.
1
+ """A statusline inside Claude Code itself, showing model and context usage.
2
2
 
3
- The dashboard can only advise you if you are looking at it. These two run where
4
- the decision is actually made — the terminal you are typing in.
3
+ claude-finops --statusline reads a statusline payload on stdin, prints model + context %
4
+ claude-finops --install-statusline wire it into settings
5
+ claude-finops --hook retired no-op, kept so existing installs do not error
6
+ claude-finops --install-hook prints that the hook is retired and does nothing else
5
7
 
6
- claude-finops --hook reads a UserPromptSubmit payload on stdin
7
- claude-finops --statusline reads a statusline payload on stdin
8
- claude-finops --install-hook / --install-statusline wire them into settings
9
-
10
- Both are on the path of every prompt, so both are built to be boring: fail
11
- silent, never block, never take long. A hook that erases someone's prompt
12
- because a database was locked is a far worse bug than missing advice, so the
13
- hook never returns a blocking exit code — it exits 0 whatever happens.
8
+ The prompt hook that used to "suggest a cheaper model" is retired: that
9
+ suggestion had no basis — it would have meant repricing work that never ran.
10
+ The statusline is on the path of every prompt, so it is built to be boring:
11
+ fail silent, never block, never take long.
14
12
  """
15
13
  import json
16
14
  import os
@@ -47,49 +45,28 @@ def _dig(d, *paths, default=None):
47
45
  return default
48
46
 
49
47
 
50
- def _payload_common(d):
51
- return (_dig(d, "model", "session.model", "model.id", "model.display_name"),
52
- _dig(d, "transcript_path", "session.transcript_path", "transcriptPath"))
53
-
54
-
55
48
  # -------------------------------------------------------------------- hook ----
56
49
 
57
50
  def hook():
58
- """UserPromptSubmit: judge the prompt in hand, before it is paid for.
59
-
60
- Advice goes to the user as `systemMessage`, not into Claude's context: it is
61
- for the person deciding which model to use, and feeding it to the model
62
- would just spend tokens telling it about its own price.
63
- """
51
+ """UserPromptSubmit: kept as a no-op so existing installed hooks do not error."""
64
52
  try:
65
- d = _stdin_json()
66
- prompt = _dig(d, "user_prompt", "prompt", default="")
67
- model, transcript = _payload_common(d)
68
- from .advisor import advise
69
- a = advise(model=model, transcript=transcript, prompt=prompt)
70
- if a:
71
- print(json.dumps({"systemMessage": f"finops: {a['line']} → {a['command']}"}))
53
+ _stdin_json()
72
54
  except Exception:
73
- pass # never let advice interfere with the prompt
55
+ pass
74
56
  return 0
75
57
 
76
58
 
77
59
  # -------------------------------------------------------------- statusline ----
78
60
 
79
61
  def statusline():
80
- """One line, refreshed constantly, so: what you are on, and what to try."""
62
+ """One line, refreshed constantly: model and context usage."""
81
63
  try:
82
64
  d = _stdin_json()
83
- model, transcript = _payload_common(d)
84
65
  pct = _dig(d, "context.percentUsed", "context.percent_used")
85
66
  name = _dig(d, "model.display_name", "session.model", "model") or "claude"
86
67
  bits = [str(name)]
87
68
  if isinstance(pct, (int, float)):
88
69
  bits.append(f"{pct:.0f}% ctx")
89
- from .advisor import advise
90
- a = advise(model=model, transcript=transcript)
91
- if a:
92
- bits.append(a["short"])
93
70
  print(" · ".join(bits))
94
71
  except Exception:
95
72
  print("") # an empty statusline beats a stack trace under the prompt
@@ -126,26 +103,14 @@ def _save_settings(data):
126
103
 
127
104
 
128
105
  def install_hook(remove=False):
129
- cmd = f"{_command()} --hook"
130
- s = _load_settings()
131
- hooks = s.setdefault("hooks", {}).setdefault("UserPromptSubmit", [])
132
- for group in hooks: # drop any earlier copy of ours
133
- group["hooks"] = [h for h in group.get("hooks", [])
134
- if "--hook" not in str(h.get("command", ""))
135
- or "finops" not in str(h.get("command", ""))]
136
- hooks[:] = [g for g in hooks if g.get("hooks")]
137
- if not remove:
138
- hooks.append({"matcher": "", "hooks": [{"type": "command", "command": cmd,
139
- "timeout": 10}]})
140
- if not hooks:
141
- s["hooks"].pop("UserPromptSubmit", None)
142
- if not s["hooks"]:
143
- s.pop("hooks")
144
- _save_settings(s)
145
- print(("Removed" if remove else "Installed") + f" the prompt hook in {SETTINGS}")
146
- if not remove:
147
- print(" It suggests a cheaper model when your own history backs one, before the turn runs.")
148
- print(" Start a new Claude Code session to pick it up. Undo: claude-finops --uninstall-hook")
106
+ """The prompt hook is retired: it never had a basis for its "cheaper model"
107
+ suggestion (that would mean repricing work that had not run yet). It is kept
108
+ as a no-op (see hook() above) so existing installs do not error, but nothing
109
+ new is installed here.
110
+ """
111
+ print("The finops prompt hook has been retired — it made suggestions with no "
112
+ "basis in your data. Nothing was installed or changed.")
113
+ print("The statusline (claude-finops --install-statusline) still shows model and context %.")
149
114
 
150
115
 
151
116
  def install_statusline(remove=False):
@@ -164,5 +129,5 @@ def install_statusline(remove=False):
164
129
  _save_settings(s)
165
130
  print(("Removed" if remove else "Installed") + f" the statusline in {SETTINGS}")
166
131
  if not remove:
167
- print(" Shows the model, context pressure, and a cheaper model when one is warranted.")
132
+ print(" Shows the model and context usage percentage.")
168
133
  print(" Undo: claude-finops --uninstall-statusline")
package/finops/paths.py CHANGED
@@ -109,3 +109,15 @@ def migrate(log=print):
109
109
  with open(_MARKER, "w") as fh:
110
110
  fh.write("state dir in use; delete this file to re-run migration\n")
111
111
  return bool(moved)
112
+
113
+
114
+ # The Claude desktop app's data dir. Read-only: Cowork transcripts and the plan
115
+ # usage history live here.
116
+ if os.name == "nt":
117
+ DESKTOP_DIR = os.path.join(os.environ.get("APPDATA", ""), "Claude")
118
+ elif os.uname().sysname == "Darwin":
119
+ DESKTOP_DIR = os.path.join(os.path.expanduser("~"), "Library", "Application Support", "Claude")
120
+ else:
121
+ DESKTOP_DIR = os.path.join(os.path.expanduser("~"), ".config", "Claude")
122
+ DESKTOP_SESSIONS = os.path.join(DESKTOP_DIR, "local-agent-mode-sessions")
123
+ PLAN_HISTORY_PATH = os.path.join(DESKTOP_DIR, "plan-usage-history.json")
@@ -0,0 +1,80 @@
1
+ """Plan-limit history, as the Claude desktop app records it.
2
+
3
+ The desktop app samples plan usage every few minutes into plan-usage-history.json:
4
+ `fh` is the 5-hour window and `sd` the weekly (seven-day) window, both percent used.
5
+ The format is undocumented, so anything unexpected returns {"ok": False, "reason"}
6
+ instead of raising. Read on request, cached on the file's mtime.
7
+ """
8
+ import json
9
+ import os
10
+ from datetime import datetime, timezone
11
+
12
+ from .paths import PLAN_HISTORY_PATH
13
+
14
+ KNOWN_VERSIONS = {2}
15
+ _cache = {}
16
+
17
+
18
+ def _num(v):
19
+ return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None
20
+
21
+
22
+ def _runs(values, threshold):
23
+ """Separate stretches at or above the threshold, not raw sample counts."""
24
+ n, above = 0, False
25
+ for v in values:
26
+ hit = v is not None and v >= threshold
27
+ n += hit and not above
28
+ above = hit
29
+ return n
30
+
31
+
32
+ def _peak(values):
33
+ return max((v for v in values if v is not None), default=None)
34
+
35
+
36
+ def history(path=None):
37
+ path = path or PLAN_HISTORY_PATH
38
+ try:
39
+ mtime = os.path.getmtime(path)
40
+ except OSError:
41
+ return {"ok": False, "reason": "No plan history on this machine. The Claude desktop "
42
+ "app records it; it is not installed or has not saved any yet."}
43
+ hit = _cache.get(path)
44
+ if hit and hit[0] == mtime:
45
+ return hit[1]
46
+ res = _read(path)
47
+ _cache[path] = (mtime, res)
48
+ return res
49
+
50
+
51
+ def _read(path):
52
+ try:
53
+ with open(path) as fh:
54
+ raw = json.load(fh)
55
+ except (OSError, ValueError):
56
+ return {"ok": False, "reason": "The plan history file could not be read."}
57
+ version = raw.get("version") if isinstance(raw, dict) else None
58
+ if version not in KNOWN_VERSIONS:
59
+ return {"ok": False, "reason": f"Unrecognised plan history format (version {version})."}
60
+ samples = [x for x in raw.get("samples") or []
61
+ if isinstance(x, dict) and _num(x.get("t")) is not None]
62
+ if not samples:
63
+ return {"ok": False, "reason": "The plan history file has no samples yet."}
64
+ samples.sort(key=lambda x: x["t"])
65
+ orgs = {x.get("org") for x in samples}
66
+ org = samples[-1].get("org")
67
+ series = []
68
+ for x in samples:
69
+ if x.get("org") != org:
70
+ continue
71
+ u = x.get("u") if isinstance(x.get("u"), dict) else {}
72
+ t = datetime.fromtimestamp(x["t"] / 1000, timezone.utc).isoformat().replace("+00:00", "Z")
73
+ series.append({"t": t, "five_hour": _num(u.get("fh")), "weekly": _num(u.get("sd"))})
74
+ fh = [r["five_hour"] for r in series]
75
+ sd = [r["weekly"] for r in series]
76
+ return {"ok": True, "source": path, "org_count": len(orgs), "series": series,
77
+ "summary": {"five_hour_peak": _peak(fh), "weekly_peak": _peak(sd),
78
+ "five_hour_ge90": _runs(fh, 90), "five_hour_hit100": _runs(fh, 100),
79
+ "weekly_ge90": _runs(sd, 90), "weekly_hit100": _runs(sd, 100),
80
+ "first": series[0]["t"], "last": series[-1]["t"]}}