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/README.md +12 -47
- package/config/pricing.json +166 -24
- package/config/settings.json +11 -5
- package/finops/actions.py +1 -1
- package/finops/agents.py +2 -1
- package/finops/analytics.py +721 -611
- package/finops/api.py +179 -55
- package/finops/diagnose.py +17 -20
- package/finops/etl.py +169 -57
- package/finops/integrate.py +22 -57
- package/finops/paths.py +12 -0
- package/finops/plan_history.py +80 -0
- package/finops/pricing.py +57 -6
- package/finops/procs.py +7 -5
- package/finops/report.py +29 -23
- package/finops/segments.py +149 -0
- package/package.json +1 -1
- package/run.cmd +3 -2
- package/run.py +26 -59
- package/web/app.js +297 -333
- package/web/charts.js +60 -16
- package/finops/advisor.py +0 -188
- package/finops/trial.py +0 -169
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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")
|
|
345
|
-
self.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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())
|
|
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,
|
|
456
|
+
len(text.split()), cat, conf, json.dumps(ev), src, norm_hash))
|
|
373
457
|
return cur.lastrowid
|
|
374
458
|
|
|
375
|
-
def insert_request(self,
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
(
|
|
416
|
-
(ts or "")[:10], (t.hour if t else None), model,
|
|
417
|
-
1 if self.pricing.is_known(model) else 0,
|
|
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,
|
|
420
|
-
|
|
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
|
|
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"):
|
package/finops/integrate.py
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""A statusline inside Claude Code itself, showing model and context usage.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
55
|
+
pass
|
|
74
56
|
return 0
|
|
75
57
|
|
|
76
58
|
|
|
77
59
|
# -------------------------------------------------------------- statusline ----
|
|
78
60
|
|
|
79
61
|
def statusline():
|
|
80
|
-
"""One line, refreshed constantly
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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"]}}
|