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 +115 -4
- package/config/model_compare.json +4 -2
- package/config/pricing.json +17 -6
- package/config/settings.json +17 -0
- package/finops/actions.py +54 -27
- package/finops/agents.py +13 -39
- package/finops/analytics.py +432 -135
- package/finops/api.py +199 -16
- package/finops/classify.py +6 -0
- package/finops/cloud.py +59 -7
- package/finops/diagnose.py +18 -5
- package/finops/etl.py +171 -7
- package/finops/guard.py +412 -0
- package/finops/integrate.py +78 -0
- package/finops/jev.py +177 -0
- package/finops/localtime.py +53 -0
- package/finops/pricing.py +27 -0
- package/finops/subagents.py +358 -0
- package/package.json +1 -1
- package/run.py +18 -12
- package/web/app.js +1046 -124
- package/web/charts.js +14 -6
- package/web/guide/budgets.png +0 -0
- package/web/styles.css +73 -0
package/finops/etl.py
CHANGED
|
@@ -14,9 +14,10 @@ import sys
|
|
|
14
14
|
from datetime import datetime, timezone
|
|
15
15
|
|
|
16
16
|
from .classify import classify
|
|
17
|
+
from . import localtime
|
|
17
18
|
from .pricing import Pricing
|
|
18
19
|
|
|
19
|
-
from .paths import ROOT, DB_PATH, DESKTOP_SESSIONS
|
|
20
|
+
from .paths import ROOT, DB_PATH, DESKTOP_SESSIONS, SETTINGS_PATH, LOCAL_SETTINGS_PATH
|
|
20
21
|
DEFAULT_SOURCE = os.path.expanduser("~/.claude/projects")
|
|
21
22
|
|
|
22
23
|
SCHEMA_VERSION = 3 # 3: cross-session request_id dedup (resumed sessions copy history)
|
|
@@ -202,9 +203,23 @@ def _slug_to_name(slug):
|
|
|
202
203
|
return os.path.basename(p.rstrip("/")) or slug
|
|
203
204
|
|
|
204
205
|
|
|
206
|
+
def keep_deleted_history():
|
|
207
|
+
"""history.keep_deleted_transcripts (default on), local settings winning over shared."""
|
|
208
|
+
val = True
|
|
209
|
+
for path in (SETTINGS_PATH, LOCAL_SETTINGS_PATH):
|
|
210
|
+
try:
|
|
211
|
+
with open(path) as fh:
|
|
212
|
+
h = (json.load(fh) or {}).get("history") or {}
|
|
213
|
+
if isinstance(h.get("keep_deleted_transcripts"), bool):
|
|
214
|
+
val = h["keep_deleted_transcripts"]
|
|
215
|
+
except (OSError, ValueError, AttributeError):
|
|
216
|
+
pass
|
|
217
|
+
return val
|
|
218
|
+
|
|
219
|
+
|
|
205
220
|
class Loader:
|
|
206
221
|
def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None, other_agents=True,
|
|
207
|
-
desktop_roots=None):
|
|
222
|
+
desktop_roots=None, keep_from=None):
|
|
208
223
|
self.other_agents = other_agents
|
|
209
224
|
# An explicit source means "load exactly this"; only the default also picks up
|
|
210
225
|
# the desktop app's Cowork sessions (same JSONL format, different config dir).
|
|
@@ -220,11 +235,30 @@ class Loader:
|
|
|
220
235
|
self.pending_results = {}
|
|
221
236
|
self.group = None
|
|
222
237
|
self.seen_request_ids = set() # dedup request_id across sessions (resumed sessions)
|
|
238
|
+
# The warehouse this build replaces: sessions whose transcript has since been
|
|
239
|
+
# deleted are carried over from it, so history outlives Claude Code's cleanup.
|
|
240
|
+
self.keep_from = keep_from
|
|
223
241
|
|
|
224
242
|
# ---------- infrastructure ----------
|
|
225
243
|
def build(self, verbose=True):
|
|
244
|
+
prev = self.keep_from
|
|
245
|
+
moved = None
|
|
226
246
|
if os.path.exists(self.db_path):
|
|
227
|
-
|
|
247
|
+
if prev is None and keep_deleted_history():
|
|
248
|
+
moved = prev = self.db_path + ".prev"
|
|
249
|
+
# fold the write-ahead log into the file first: moving the file without
|
|
250
|
+
# its -wal leaves a database SQLite can no longer read
|
|
251
|
+
try:
|
|
252
|
+
c = sqlite3.connect(self.db_path)
|
|
253
|
+
c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
254
|
+
c.close()
|
|
255
|
+
except sqlite3.Error:
|
|
256
|
+
pass
|
|
257
|
+
for suffix in ("", "-wal", "-shm"):
|
|
258
|
+
if os.path.exists(self.db_path + suffix):
|
|
259
|
+
os.replace(self.db_path + suffix, moved + suffix)
|
|
260
|
+
else:
|
|
261
|
+
os.remove(self.db_path)
|
|
228
262
|
for suffix in ("-wal", "-shm"):
|
|
229
263
|
p = self.db_path + suffix
|
|
230
264
|
if os.path.exists(p):
|
|
@@ -237,7 +271,16 @@ class Loader:
|
|
|
237
271
|
for n in names:
|
|
238
272
|
if n.endswith(".jsonl"):
|
|
239
273
|
files.append(os.path.join(dirpath, n))
|
|
240
|
-
|
|
274
|
+
|
|
275
|
+
def created(fp):
|
|
276
|
+
# A resumed session copies earlier responses into a new, later file; reading
|
|
277
|
+
# files oldest-created first credits each response to the session that made it.
|
|
278
|
+
try:
|
|
279
|
+
st = os.stat(fp)
|
|
280
|
+
return (getattr(st, "st_birthtime", None) or st.st_mtime, fp)
|
|
281
|
+
except OSError:
|
|
282
|
+
return (float("inf"), fp)
|
|
283
|
+
files.sort(key=created)
|
|
241
284
|
marker = os.sep + os.path.join(".claude", "projects") + os.sep
|
|
242
285
|
desktop = sorted(os.path.join(d, n) for root in self.desktop_roots
|
|
243
286
|
for d, _, names in os.walk(root) for n in names
|
|
@@ -257,16 +300,34 @@ class Loader:
|
|
|
257
300
|
if verbose and counts:
|
|
258
301
|
print(" other agents: " + ", ".join(f"{k} {v:,} requests" for k, v in counts.items()),
|
|
259
302
|
file=sys.stderr)
|
|
303
|
+
carried = 0
|
|
304
|
+
if prev and keep_deleted_history() and os.path.exists(prev):
|
|
305
|
+
self.db.commit() # the fresh read is safe whatever happens next
|
|
306
|
+
try:
|
|
307
|
+
carried = self.carry_over(prev)
|
|
308
|
+
except Exception as exc: # an old or damaged warehouse must not stop a build
|
|
309
|
+
print(f" ! kept no deleted history: {exc}", file=sys.stderr)
|
|
310
|
+
self.db.rollback()
|
|
311
|
+
if verbose and carried:
|
|
312
|
+
print(f" kept {carried} sessions whose transcripts were deleted", file=sys.stderr)
|
|
313
|
+
if moved:
|
|
314
|
+
for suffix in ("", "-wal", "-shm"):
|
|
315
|
+
if os.path.exists(moved + suffix):
|
|
316
|
+
os.remove(moved + suffix)
|
|
260
317
|
self.rollup()
|
|
261
318
|
self.db.execute(
|
|
262
319
|
"INSERT INTO meta VALUES (?,?)",
|
|
263
320
|
("built_at", datetime.now(timezone.utc).isoformat()),
|
|
264
321
|
)
|
|
322
|
+
self.db.execute("INSERT INTO meta VALUES (?,?)", ("kept_deleted_sessions", str(carried)))
|
|
265
323
|
for k, v in (
|
|
266
324
|
("source_dir", self.source),
|
|
267
325
|
("transcript_files", str(len(files))),
|
|
268
326
|
("desktop_transcript_files", str(len(desktop))),
|
|
269
327
|
("pricing_updated", str(self.pricing.updated)),
|
|
328
|
+
# day/hour columns are local calendar days (finops/localtime.py); older
|
|
329
|
+
# warehouses cut them at UTC and the readers fall back accordingly
|
|
330
|
+
("day_basis", "local"),
|
|
270
331
|
("pricing_source", str(self.pricing.source)),
|
|
271
332
|
("cost_basis", "estimated"),
|
|
272
333
|
("schema_version", str(SCHEMA_VERSION)),
|
|
@@ -452,7 +513,7 @@ class Loader:
|
|
|
452
513
|
"INSERT INTO prompts (uuid, session_id, project_id, ts, day, text, char_len,"
|
|
453
514
|
" word_len, category, category_confidence, category_evidence, source, norm_hash)"
|
|
454
515
|
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
455
|
-
(r.get("uuid"), session_id, pid, ts, (ts
|
|
516
|
+
(r.get("uuid"), session_id, pid, ts, localtime.day(ts), text, len(text),
|
|
456
517
|
len(text.split()), cat, conf, json.dumps(ev), src, norm_hash))
|
|
457
518
|
return cur.lastrowid
|
|
458
519
|
|
|
@@ -520,7 +581,7 @@ class Loader:
|
|
|
520
581
|
" tool_call_count, is_sidechain, agent_id, agent_type, agent_desc)"
|
|
521
582
|
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
522
583
|
(first.get("uuid"), request_id, session_id, pid,
|
|
523
|
-
prompt_id, ts, (ts
|
|
584
|
+
prompt_id, ts, localtime.day(ts), localtime.hour(ts), model,
|
|
524
585
|
1 if self.pricing.is_known(model) else 0, first.get("effort"),
|
|
525
586
|
u.get("service_tier"), msg.get("stop_reason"), inp, out, think, cr,
|
|
526
587
|
c5, c1, cw, billable, context, cost, cost_no_cache,
|
|
@@ -530,7 +591,7 @@ class Loader:
|
|
|
530
591
|
else (None, "inline" if first.get("isSidechain") else None, None))))
|
|
531
592
|
rpk = cur.lastrowid
|
|
532
593
|
|
|
533
|
-
day = (ts
|
|
594
|
+
day = localtime.day(ts)
|
|
534
595
|
for c in tools:
|
|
535
596
|
name = c.get("name")
|
|
536
597
|
args = c.get("input") or {}
|
|
@@ -570,6 +631,109 @@ class Loader:
|
|
|
570
631
|
(session_id, pid, prompt_id, args["file_path"], FILE_TOOLS[name], ts))
|
|
571
632
|
|
|
572
633
|
# ---------- aggregates ----------
|
|
634
|
+
def carry_over(self, prev):
|
|
635
|
+
"""Copy sessions whose transcript no longer exists from the previous warehouse.
|
|
636
|
+
|
|
637
|
+
Claude Code deletes old transcripts (cleanupPeriodDays), and a rebuild reads only
|
|
638
|
+
what is on disk, so without this every sync would erase that history. Rows are
|
|
639
|
+
re-derived where the rules may have changed since: cost from the current price
|
|
640
|
+
table, day and hour in local time, and the prompt category. A session whose
|
|
641
|
+
file still exists is never carried (the fresh read wins), and a request already
|
|
642
|
+
loaded from another file is not copied twice.
|
|
643
|
+
"""
|
|
644
|
+
d = self.db
|
|
645
|
+
d.execute("ATTACH DATABASE ? AS old", (prev,))
|
|
646
|
+
try:
|
|
647
|
+
d.row_factory = sqlite3.Row
|
|
648
|
+
cols = lambda db, t: [r[1] for r in d.execute(f"PRAGMA {db}.table_info({t})")]
|
|
649
|
+
common = lambda t: [c for c in cols("main", t) if c in set(cols("old", t)) and c != "id"]
|
|
650
|
+
gone = [r for r in d.execute(
|
|
651
|
+
"SELECT * FROM old.sessions WHERE id NOT IN (SELECT id FROM main.sessions)")
|
|
652
|
+
if r["source_file"] and not os.path.exists(r["source_file"])]
|
|
653
|
+
if not gone:
|
|
654
|
+
return 0
|
|
655
|
+
sids = [r["id"] for r in gone]
|
|
656
|
+
ph = ",".join("?" * len(sids))
|
|
657
|
+
|
|
658
|
+
proj = {}
|
|
659
|
+
for r in d.execute(f"SELECT * FROM old.projects WHERE id IN "
|
|
660
|
+
f"(SELECT project_id FROM old.sessions WHERE id IN ({ph}))", sids):
|
|
661
|
+
hit = d.execute("SELECT id FROM main.projects WHERE slug=?", (r["slug"],)).fetchone()
|
|
662
|
+
if hit:
|
|
663
|
+
proj[r["id"]] = hit[0]
|
|
664
|
+
else:
|
|
665
|
+
c = common("projects")
|
|
666
|
+
cur = d.execute(f"INSERT INTO main.projects ({','.join(c)}) VALUES ({','.join('?' * len(c))})",
|
|
667
|
+
[r[k] for k in c])
|
|
668
|
+
proj[r["id"]] = cur.lastrowid
|
|
669
|
+
|
|
670
|
+
c = common("sessions") + ["id"]
|
|
671
|
+
for r in gone:
|
|
672
|
+
v = {k: r[k] for k in c}
|
|
673
|
+
v["project_id"] = proj.get(r["project_id"], r["project_id"])
|
|
674
|
+
d.execute(f"INSERT INTO main.sessions ({','.join(c)}) VALUES ({','.join('?' * len(c))})",
|
|
675
|
+
[v[k] for k in c])
|
|
676
|
+
|
|
677
|
+
pmap = {}
|
|
678
|
+
c = common("prompts")
|
|
679
|
+
for r in d.execute(f"SELECT * FROM old.prompts WHERE session_id IN ({ph})", sids).fetchall():
|
|
680
|
+
v = {k: r[k] for k in c}
|
|
681
|
+
v["project_id"] = proj.get(r["project_id"], r["project_id"])
|
|
682
|
+
v["day"] = localtime.day(r["ts"])
|
|
683
|
+
if r["text"]:
|
|
684
|
+
v["category"], v["category_confidence"], ev = classify(r["text"])
|
|
685
|
+
v["category_evidence"] = json.dumps(ev)
|
|
686
|
+
cur = d.execute(f"INSERT INTO main.prompts ({','.join(c)}) VALUES ({','.join('?' * len(c))})",
|
|
687
|
+
[v[k] for k in c])
|
|
688
|
+
pmap[r["id"]] = cur.lastrowid
|
|
689
|
+
|
|
690
|
+
rmap = {}
|
|
691
|
+
c = common("requests")
|
|
692
|
+
for r in d.execute(f"SELECT * FROM old.requests WHERE session_id IN ({ph})", sids).fetchall():
|
|
693
|
+
rid = r["request_id"]
|
|
694
|
+
if rid and (rid in self.seen_request_ids or d.execute(
|
|
695
|
+
"SELECT 1 FROM main.requests WHERE request_id=?", (rid,)).fetchone()):
|
|
696
|
+
continue
|
|
697
|
+
v = {k: r[k] for k in c}
|
|
698
|
+
v["project_id"] = proj.get(r["project_id"], r["project_id"])
|
|
699
|
+
v["prompt_id"] = pmap.get(r["prompt_id"])
|
|
700
|
+
v["day"], v["hour"] = localtime.day(r["ts"]), localtime.hour(r["ts"])
|
|
701
|
+
priced = r["priced_as"] or r["model"]
|
|
702
|
+
cost = self.pricing.estimate(priced, r["input_tokens"] or 0, r["output_tokens"] or 0,
|
|
703
|
+
r["cache_read_tokens"] or 0, r["cache_write_5m"] or 0,
|
|
704
|
+
r["cache_write_1h"] or 0)
|
|
705
|
+
no_cache, with_cache = self.pricing.uncached_baseline(
|
|
706
|
+
priced, r["cache_read_tokens"] or 0, r["cache_write_5m"] or 0, r["cache_write_1h"] or 0)
|
|
707
|
+
v["est_cost_usd"], v["est_cost_no_cache_usd"] = cost, cost - with_cache + no_cache
|
|
708
|
+
v["model_known"] = 1 if self.pricing.is_known(r["model"]) else 0
|
|
709
|
+
cur = d.execute(f"INSERT INTO main.requests ({','.join(c)}) VALUES ({','.join('?' * len(c))})",
|
|
710
|
+
[v[k] for k in c])
|
|
711
|
+
rmap[r["id"]] = cur.lastrowid
|
|
712
|
+
if rid:
|
|
713
|
+
self.seen_request_ids.add(rid)
|
|
714
|
+
|
|
715
|
+
c = common("tool_calls")
|
|
716
|
+
for r in d.execute(f"SELECT * FROM old.tool_calls WHERE session_id IN ({ph})", sids).fetchall():
|
|
717
|
+
if r["request_pk"] not in rmap:
|
|
718
|
+
continue
|
|
719
|
+
v = {k: r[k] for k in c}
|
|
720
|
+
v.update(request_pk=rmap[r["request_pk"]], prompt_id=pmap.get(r["prompt_id"]),
|
|
721
|
+
project_id=proj.get(r["project_id"], r["project_id"]), day=localtime.day(r["ts"]))
|
|
722
|
+
d.execute(f"INSERT INTO main.tool_calls ({','.join(c)}) VALUES ({','.join('?' * len(c))})",
|
|
723
|
+
[v[k] for k in c])
|
|
724
|
+
|
|
725
|
+
c = common("files_touched")
|
|
726
|
+
for r in d.execute(f"SELECT * FROM old.files_touched WHERE session_id IN ({ph})", sids).fetchall():
|
|
727
|
+
v = {k: r[k] for k in c}
|
|
728
|
+
v.update(prompt_id=pmap.get(r["prompt_id"]), project_id=proj.get(r["project_id"], r["project_id"]))
|
|
729
|
+
d.execute(f"INSERT INTO main.files_touched ({','.join(c)}) VALUES ({','.join('?' * len(c))})",
|
|
730
|
+
[v[k] for k in c])
|
|
731
|
+
d.commit()
|
|
732
|
+
return len(gone)
|
|
733
|
+
finally:
|
|
734
|
+
d.row_factory = None
|
|
735
|
+
d.execute("DETACH DATABASE old")
|
|
736
|
+
|
|
573
737
|
def rollup(self):
|
|
574
738
|
d = self.db
|
|
575
739
|
d.execute("""
|
package/finops/guard.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Session guard: a PreToolUse hook that keeps a human in the loop on token-heavy sessions.
|
|
2
|
+
|
|
3
|
+
claude-finops --guard invoked by Claude Code before every tool call
|
|
4
|
+
claude-finops --install-guard wire it into ~/.claude/settings.json (see integrate.py)
|
|
5
|
+
|
|
6
|
+
It counts the session's billable tokens (input + output + cache read + cache
|
|
7
|
+
write, the same figure as the dashboard's Tokens column) straight from the
|
|
8
|
+
transcript, reading only what was appended since the last call. Below the
|
|
9
|
+
budget it warns once at each configured percentage; at the budget it asks the
|
|
10
|
+
user to approve the next tool call, then again every step_pct (or never, in
|
|
11
|
+
"once" mode).
|
|
12
|
+
|
|
13
|
+
A hook cannot end a session, and this one never blocks on its own failure:
|
|
14
|
+
anything unexpected means exit 0 with no output, so Claude Code carries on as
|
|
15
|
+
if the guard were not installed.
|
|
16
|
+
"""
|
|
17
|
+
import glob
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from .paths import DATA_DIR, SETTINGS_PATH, LOCAL_SETTINGS_PATH
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import fcntl
|
|
28
|
+
except ImportError: # Windows: no advisory locks, concurrent calls are rare there
|
|
29
|
+
fcntl = None
|
|
30
|
+
|
|
31
|
+
STATE_DIR = os.path.join(DATA_DIR, "guard")
|
|
32
|
+
MAX_READ = 64 * 1024 * 1024 # new bytes read per call; the rest waits for the next call
|
|
33
|
+
STALE_S = 30 * 86400
|
|
34
|
+
SWEEP_S = 3600
|
|
35
|
+
SESSION_KEEP_S = 30 * 86400 # a per-session limit is forgotten after this long
|
|
36
|
+
SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
37
|
+
REJECTED = ("User rejected tool use", "The user doesn't want to proceed with this tool use")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------- settings ----
|
|
41
|
+
|
|
42
|
+
def _num(v):
|
|
43
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_guard(g, default):
|
|
47
|
+
"""Clean a guard block. Returns (cleaned, [bad paths]); bad leaves use the default."""
|
|
48
|
+
default = default or {}
|
|
49
|
+
if not isinstance(g, dict):
|
|
50
|
+
return json.loads(json.dumps(default)), (["guard"] if g is not None else [])
|
|
51
|
+
out, bad = dict(default), []
|
|
52
|
+
for k, v in g.items():
|
|
53
|
+
if k.startswith("_"):
|
|
54
|
+
out[k] = v
|
|
55
|
+
elif k == "session_tokens":
|
|
56
|
+
if v is None or (_num(v) and v > 0):
|
|
57
|
+
out[k] = v
|
|
58
|
+
else:
|
|
59
|
+
bad.append(f"guard.{k}")
|
|
60
|
+
elif k == "warn_pct":
|
|
61
|
+
if isinstance(v, list) and all(_num(x) and 0 < x < 100 for x in v):
|
|
62
|
+
out[k] = sorted(set(v))
|
|
63
|
+
else:
|
|
64
|
+
bad.append(f"guard.{k}")
|
|
65
|
+
elif k == "after_approval":
|
|
66
|
+
if v in ("step", "once"):
|
|
67
|
+
out[k] = v
|
|
68
|
+
else:
|
|
69
|
+
bad.append(f"guard.{k}")
|
|
70
|
+
elif k == "step_pct":
|
|
71
|
+
if _num(v) and 1 <= v <= 1000:
|
|
72
|
+
out[k] = v
|
|
73
|
+
else:
|
|
74
|
+
bad.append(f"guard.{k}")
|
|
75
|
+
elif k == "sessions":
|
|
76
|
+
if not isinstance(v, dict):
|
|
77
|
+
bad.append(f"guard.{k}")
|
|
78
|
+
continue
|
|
79
|
+
sessions = {}
|
|
80
|
+
for sid, o in v.items():
|
|
81
|
+
keep = {"set_at": o["set_at"]} if isinstance(o, dict) and isinstance(o.get("set_at"), str) else {}
|
|
82
|
+
if not SAFE_ID.match(str(sid)):
|
|
83
|
+
bad.append(f"guard.sessions.{sid}")
|
|
84
|
+
elif isinstance(o, dict) and o.get("off") is True:
|
|
85
|
+
sessions[sid] = dict(keep, off=True)
|
|
86
|
+
elif isinstance(o, dict) and _num(o.get("session_tokens")) and o["session_tokens"] > 0:
|
|
87
|
+
sessions[sid] = dict(keep, session_tokens=o["session_tokens"])
|
|
88
|
+
else:
|
|
89
|
+
bad.append(f"guard.sessions.{sid}")
|
|
90
|
+
out[k] = sessions
|
|
91
|
+
elif k == "projects":
|
|
92
|
+
if not isinstance(v, dict):
|
|
93
|
+
bad.append(f"guard.{k}")
|
|
94
|
+
continue
|
|
95
|
+
projects = {}
|
|
96
|
+
for path, o in v.items():
|
|
97
|
+
if isinstance(o, dict) and o.get("off") is True:
|
|
98
|
+
projects[path] = {"off": True}
|
|
99
|
+
elif isinstance(o, dict) and _num(o.get("session_tokens")) and o["session_tokens"] > 0:
|
|
100
|
+
projects[path] = {"session_tokens": o["session_tokens"]}
|
|
101
|
+
else:
|
|
102
|
+
bad.append(f"guard.projects.{path}")
|
|
103
|
+
out[k] = projects
|
|
104
|
+
else:
|
|
105
|
+
out[k] = v
|
|
106
|
+
return out, bad
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_guard_settings():
|
|
110
|
+
"""The guard block only: shipped default + settings.local.json, validated.
|
|
111
|
+
|
|
112
|
+
Deliberately not analytics.load_settings(): that also detects the account
|
|
113
|
+
from ~/.claude.json, which the hook has no use for on every tool call.
|
|
114
|
+
"""
|
|
115
|
+
with open(SETTINGS_PATH) as fh:
|
|
116
|
+
default = json.load(fh).get("guard") or {}
|
|
117
|
+
local = {}
|
|
118
|
+
try:
|
|
119
|
+
with open(LOCAL_SETTINGS_PATH) as fh:
|
|
120
|
+
local = json.load(fh).get("guard") or {}
|
|
121
|
+
except (OSError, ValueError, AttributeError):
|
|
122
|
+
pass
|
|
123
|
+
merged = dict(default)
|
|
124
|
+
if isinstance(local, dict):
|
|
125
|
+
merged.update(local)
|
|
126
|
+
return validate_guard(merged, default)[0]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _norm(p):
|
|
130
|
+
return os.path.normcase(os.path.realpath(os.path.expanduser(p)))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def prune_sessions(sessions, now=None):
|
|
134
|
+
"""Drop per-session limits set more than 30 days ago (entries without a date stay)."""
|
|
135
|
+
now = now or time.time()
|
|
136
|
+
out = {}
|
|
137
|
+
for sid, o in (sessions or {}).items():
|
|
138
|
+
try:
|
|
139
|
+
age = now - time.mktime(time.strptime(o["set_at"], "%Y-%m-%dT%H:%M:%S"))
|
|
140
|
+
except (KeyError, TypeError, ValueError):
|
|
141
|
+
age = 0
|
|
142
|
+
if age <= SESSION_KEEP_S:
|
|
143
|
+
out[sid] = o
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def has_any_budget(cfg):
|
|
148
|
+
"""Is any per-session budget set: global, a project override, or one session's own?"""
|
|
149
|
+
return bool(cfg.get("session_tokens")) or any(
|
|
150
|
+
o.get("session_tokens") for sect in ("projects", "sessions")
|
|
151
|
+
for o in (cfg.get(sect) or {}).values())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def budget_for(cfg, cwd, session_id=None):
|
|
155
|
+
"""The budget that applies: this session's own limit, else the most specific
|
|
156
|
+
project override, else the global one."""
|
|
157
|
+
own = (cfg.get("sessions") or {}).get(session_id) if session_id else None
|
|
158
|
+
if own is not None:
|
|
159
|
+
return None if own.get("off") else own.get("session_tokens")
|
|
160
|
+
best, best_len = None, -1
|
|
161
|
+
if cwd:
|
|
162
|
+
here = _norm(cwd)
|
|
163
|
+
for path, o in (cfg.get("projects") or {}).items():
|
|
164
|
+
p = _norm(path)
|
|
165
|
+
if (here == p or here.startswith(p.rstrip(os.sep) + os.sep)) and len(p) > best_len:
|
|
166
|
+
best, best_len = o, len(p)
|
|
167
|
+
if best is not None:
|
|
168
|
+
return None if best.get("off") else best.get("session_tokens")
|
|
169
|
+
return cfg.get("session_tokens")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------- counting ----
|
|
173
|
+
|
|
174
|
+
def billable(u):
|
|
175
|
+
cc = u.get("cache_creation") or {}
|
|
176
|
+
cw = int(u.get("cache_creation_input_tokens") or 0) or (
|
|
177
|
+
int(cc.get("ephemeral_5m_input_tokens") or 0) + int(cc.get("ephemeral_1h_input_tokens") or 0))
|
|
178
|
+
return (int(u.get("input_tokens") or 0) + int(u.get("output_tokens") or 0)
|
|
179
|
+
+ int(u.get("cache_read_input_tokens") or 0) + cw)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def session_files(transcript_path, session_id):
|
|
183
|
+
"""The transcript plus its subagents' transcripts, which the dashboard counts too."""
|
|
184
|
+
files = [transcript_path]
|
|
185
|
+
sub = os.path.join(os.path.dirname(transcript_path), session_id, "subagents")
|
|
186
|
+
files += sorted(glob.glob(os.path.join(sub, "*.jsonl")))
|
|
187
|
+
return files
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _read_new(path, fs, budget_left, on_row):
|
|
191
|
+
"""Feed complete lines appended since fs["offset"] to on_row. Returns bytes read."""
|
|
192
|
+
try:
|
|
193
|
+
size = os.path.getsize(path)
|
|
194
|
+
except OSError:
|
|
195
|
+
return 0
|
|
196
|
+
if size < fs.get("offset", 0): # rewritten: start over
|
|
197
|
+
fs["offset"] = 0
|
|
198
|
+
fs.setdefault("requests", {}).clear()
|
|
199
|
+
if size == fs.get("offset", 0) or budget_left <= 0:
|
|
200
|
+
return 0
|
|
201
|
+
with open(path, "rb") as fh:
|
|
202
|
+
fh.seek(fs.get("offset", 0))
|
|
203
|
+
chunk = fh.read(min(size - fs.get("offset", 0), budget_left))
|
|
204
|
+
end = chunk.rfind(b"\n")
|
|
205
|
+
if end < 0:
|
|
206
|
+
return 0 # a partial line: wait for the rest
|
|
207
|
+
for raw in chunk[:end].split(b"\n"):
|
|
208
|
+
raw = raw.strip()
|
|
209
|
+
if not raw:
|
|
210
|
+
continue
|
|
211
|
+
try:
|
|
212
|
+
row = json.loads(raw)
|
|
213
|
+
except ValueError:
|
|
214
|
+
continue
|
|
215
|
+
if isinstance(row, dict):
|
|
216
|
+
on_row(row)
|
|
217
|
+
fs["offset"] = fs.get("offset", 0) + end + 1
|
|
218
|
+
return end + 1
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def count_session(transcript_path, session_id, state, pending_tool=None):
|
|
222
|
+
"""Update state["files"] with newly appended lines; return (total billable, tool outcome).
|
|
223
|
+
|
|
224
|
+
Each API request is counted once, from the last line carrying its usage (the
|
|
225
|
+
ETL keys requests the same way: requestId, else message.id, else uuid). The
|
|
226
|
+
outcome is "approved"/"rejected" once the result of pending_tool appears.
|
|
227
|
+
"""
|
|
228
|
+
files = state.setdefault("files", {})
|
|
229
|
+
outcome = []
|
|
230
|
+
left = MAX_READ
|
|
231
|
+
for path in session_files(transcript_path, session_id):
|
|
232
|
+
fs = files.setdefault(path, {"offset": 0, "requests": {}})
|
|
233
|
+
reqs = fs.setdefault("requests", {})
|
|
234
|
+
|
|
235
|
+
def on_row(r):
|
|
236
|
+
msg = r.get("message") if isinstance(r.get("message"), dict) else {}
|
|
237
|
+
if r.get("type") == "assistant" and isinstance(msg.get("usage"), dict):
|
|
238
|
+
key = r.get("requestId") or msg.get("id") or r.get("uuid")
|
|
239
|
+
if key:
|
|
240
|
+
reqs[key] = billable(msg["usage"])
|
|
241
|
+
elif pending_tool and r.get("type") == "user":
|
|
242
|
+
for c in msg.get("content") or []:
|
|
243
|
+
if isinstance(c, dict) and c.get("type") == "tool_result" \
|
|
244
|
+
and c.get("tool_use_id") == pending_tool:
|
|
245
|
+
text = json.dumps(c.get("content")) + json.dumps(r.get("toolUseResult"))
|
|
246
|
+
outcome.append("rejected" if any(s in text for s in REJECTED) else "approved")
|
|
247
|
+
|
|
248
|
+
left -= _read_new(path, fs, left, on_row)
|
|
249
|
+
total = sum(sum(f.get("requests", {}).values()) for f in files.values())
|
|
250
|
+
return total, (outcome[-1] if outcome else None)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def session_usage(transcript_path, session_id):
|
|
254
|
+
"""The session's billable tokens, counted like the hook does, without saving state."""
|
|
255
|
+
state = {}
|
|
256
|
+
while True:
|
|
257
|
+
before = {p: f.get("offset", 0) for p, f in state.get("files", {}).items()}
|
|
258
|
+
total, _ = count_session(transcript_path, session_id, state)
|
|
259
|
+
after = {p: f.get("offset", 0) for p, f in state.get("files", {}).items()}
|
|
260
|
+
if after == before: # nothing new read: the whole session is counted
|
|
261
|
+
return total
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def find_transcript(session_id, roots):
|
|
265
|
+
"""<root>/<project>/<session_id>.jsonl for a Claude Code session id, or None."""
|
|
266
|
+
if not SAFE_ID.match(str(session_id or "")):
|
|
267
|
+
return None
|
|
268
|
+
for root in roots:
|
|
269
|
+
hits = glob.glob(os.path.join(root, "*", f"{session_id}.jsonl"))
|
|
270
|
+
if hits:
|
|
271
|
+
return hits[0]
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------- deciding ----
|
|
276
|
+
|
|
277
|
+
def fmt_tokens(n):
|
|
278
|
+
for div, unit in ((1e9, "B"), (1e6, "M"), (1e3, "k")):
|
|
279
|
+
if n >= div:
|
|
280
|
+
s = f"{n / div:.1f}".rstrip("0").rstrip(".")
|
|
281
|
+
return f"{s}{unit}"
|
|
282
|
+
return str(int(n))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def next_ask(cfg, state):
|
|
286
|
+
approved = state.get("approved_pct")
|
|
287
|
+
if approved is None:
|
|
288
|
+
return 100
|
|
289
|
+
if cfg.get("after_approval") == "once":
|
|
290
|
+
return None
|
|
291
|
+
return approved + (cfg.get("step_pct") or 25)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def decide(total, budget, cfg, state, outcome=None, tool_use_id=None):
|
|
295
|
+
"""The hook's answer for this tool call (a dict to print), or None to stay silent."""
|
|
296
|
+
if not budget:
|
|
297
|
+
return None
|
|
298
|
+
if state.get("asked_at") is not None and outcome == "approved":
|
|
299
|
+
state["approved_pct"], state["asked_at"], state["asked_tool"] = state["asked_at"], None, None
|
|
300
|
+
elif outcome == "rejected":
|
|
301
|
+
state["asked_at"], state["asked_tool"] = None, None # declined: ask again next time
|
|
302
|
+
pct = 100.0 * total / budget
|
|
303
|
+
level = next_ask(cfg, state)
|
|
304
|
+
if level is not None and pct >= level:
|
|
305
|
+
state["asked_at"], state["asked_tool"] = level, tool_use_id
|
|
306
|
+
# the ask supersedes any warning it jumped past; don't replay them after approval
|
|
307
|
+
state["warned"] = sorted(set(state.get("warned") or [])
|
|
308
|
+
| {w for w in (cfg.get("warn_pct") or []) if pct >= w})
|
|
309
|
+
after = next_ask(cfg, dict(state, approved_pct=level))
|
|
310
|
+
hint = (f"Approving continues until {after:g}%." if after is not None
|
|
311
|
+
else "Approving turns the guard off for the rest of this session.")
|
|
312
|
+
return {"hookSpecificOutput": {
|
|
313
|
+
"hookEventName": "PreToolUse",
|
|
314
|
+
"permissionDecision": "ask",
|
|
315
|
+
"permissionDecisionReason":
|
|
316
|
+
f"Session guard: this session has used {fmt_tokens(total)} tokens, "
|
|
317
|
+
f"{pct:.0f}% of its {fmt_tokens(budget)} budget. Allow this tool call? {hint}"}}
|
|
318
|
+
warned = set(state.get("warned") or [])
|
|
319
|
+
crossed = [w for w in (cfg.get("warn_pct") or []) if pct >= w and w not in warned]
|
|
320
|
+
if crossed:
|
|
321
|
+
state["warned"] = sorted(warned | set(crossed))
|
|
322
|
+
return {"systemMessage": f"Session guard: {fmt_tokens(total)} tokens, {pct:.0f}% of "
|
|
323
|
+
f"this session's {fmt_tokens(budget)} budget.",
|
|
324
|
+
"hookSpecificOutput": {
|
|
325
|
+
"hookEventName": "PreToolUse",
|
|
326
|
+
"additionalContext":
|
|
327
|
+
f"This session has used {pct:.0f}% of its token budget. At a natural "
|
|
328
|
+
"break, suggest the user run /compact or start a fresh session."}}
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ------------------------------------------------------------------- state ----
|
|
333
|
+
|
|
334
|
+
def _state_path(session_id):
|
|
335
|
+
safe = "".join(ch for ch in session_id if ch.isalnum() or ch in "-_")
|
|
336
|
+
return os.path.join(STATE_DIR, f"{safe or 'unknown'}.json")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _load_state(path):
|
|
340
|
+
try:
|
|
341
|
+
with open(path) as fh:
|
|
342
|
+
s = json.load(fh)
|
|
343
|
+
return s if isinstance(s, dict) else {}
|
|
344
|
+
except (OSError, ValueError):
|
|
345
|
+
return {}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _save_state(path, state):
|
|
349
|
+
state["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
350
|
+
tmp = f"{path}.{os.getpid()}.tmp"
|
|
351
|
+
with open(tmp, "w") as fh:
|
|
352
|
+
json.dump(state, fh)
|
|
353
|
+
os.replace(tmp, path)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _sweep():
|
|
357
|
+
"""Drop state for sessions untouched for 30 days; at most once an hour."""
|
|
358
|
+
marker = os.path.join(STATE_DIR, ".swept")
|
|
359
|
+
now = time.time()
|
|
360
|
+
try:
|
|
361
|
+
if now - os.path.getmtime(marker) < SWEEP_S:
|
|
362
|
+
return
|
|
363
|
+
except OSError:
|
|
364
|
+
pass
|
|
365
|
+
for p in glob.glob(os.path.join(STATE_DIR, "*.json")) + glob.glob(os.path.join(STATE_DIR, "*.lock")):
|
|
366
|
+
try:
|
|
367
|
+
if now - os.path.getmtime(p) > STALE_S:
|
|
368
|
+
os.remove(p)
|
|
369
|
+
except OSError:
|
|
370
|
+
pass
|
|
371
|
+
with open(marker, "w"):
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# -------------------------------------------------------------------- hook ----
|
|
376
|
+
|
|
377
|
+
def run(payload, cfg=None):
|
|
378
|
+
"""Decide for one PreToolUse payload. Returns the dict to print, or None."""
|
|
379
|
+
session_id = payload.get("session_id")
|
|
380
|
+
transcript = payload.get("transcript_path")
|
|
381
|
+
if not session_id or not transcript:
|
|
382
|
+
return None
|
|
383
|
+
cfg = cfg if cfg is not None else load_guard_settings()
|
|
384
|
+
budget = budget_for(cfg, payload.get("cwd"), session_id)
|
|
385
|
+
if not budget:
|
|
386
|
+
return None
|
|
387
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
388
|
+
path = _state_path(session_id)
|
|
389
|
+
lock = open(path + ".lock", "w")
|
|
390
|
+
try:
|
|
391
|
+
if fcntl:
|
|
392
|
+
fcntl.flock(lock, fcntl.LOCK_EX) # parallel tool calls share one state file
|
|
393
|
+
state = _load_state(path)
|
|
394
|
+
total, outcome = count_session(transcript, session_id, state, state.get("asked_tool"))
|
|
395
|
+
out = decide(total, budget, cfg, state, outcome, payload.get("tool_use_id"))
|
|
396
|
+
_save_state(path, state)
|
|
397
|
+
finally:
|
|
398
|
+
lock.close()
|
|
399
|
+
_sweep()
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def main():
|
|
404
|
+
try:
|
|
405
|
+
raw = sys.stdin.read()
|
|
406
|
+
payload = json.loads(raw) if raw.strip() else {}
|
|
407
|
+
out = run(payload) if isinstance(payload, dict) else None
|
|
408
|
+
if out:
|
|
409
|
+
print(json.dumps(out))
|
|
410
|
+
except Exception:
|
|
411
|
+
pass # fail open: never stand between the user and their session
|
|
412
|
+
return 0
|