claude-finops 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -287,6 +287,28 @@ place for you to delete once you are happy.
287
287
 
288
288
  ---
289
289
 
290
+ ## Live model advice
291
+
292
+ The back-test tells you what to use next time. These tell you mid-session, while
293
+ you can still act on it:
294
+
295
+ ```
296
+ claude-finops --advise what every running session should switch to
297
+ claude-finops --install-hook suggest a cheaper model as you send each prompt
298
+ claude-finops --install-statusline model, context pressure and advice in your statusline
299
+ ```
300
+
301
+ The hook and statusline read the same evidence as the dashboard, cached for an
302
+ hour, and stay quiet unless your own history shows a cheaper model doing that
303
+ category of work without taking more turns. Neither can block or slow a prompt:
304
+ they fail silent and always exit 0. Undo with `--uninstall-hook` /
305
+ `--uninstall-statusline`; your `~/.claude/settings.json` is backed up first.
306
+
307
+ The Running sessions view shows the same line per live session, with the
308
+ `/model` command ready to copy.
309
+
310
+ ---
311
+
290
312
  ## Privacy
291
313
 
292
314
  `~/.claude-finops/data/finops.db` and the prompt/CSV exports contain **your full prompt text**. The
@@ -0,0 +1,188 @@
1
+ """Live model advice: what to switch to, while the session is still running.
2
+
3
+ The back-test in analytics.model_evidence() answers "what should I have used?",
4
+ which is the wrong tense once a session is under way. This module answers it in
5
+ the present: given the prompts a session has actually sent and the model it is
6
+ on, say whether your own history already shows a cheaper model doing this kind
7
+ of work without taking more turns.
8
+
9
+ It is built to run three ways, so the advice is the same wherever you meet it:
10
+
11
+ * the dashboard's Running sessions view (per live session)
12
+ * a UserPromptSubmit hook (per prompt, before the turn runs)
13
+ * a statusline command (continuously, in a few characters)
14
+
15
+ The hook and statusline run on every prompt, so the evidence is computed once
16
+ and cached; a warehouse query per keystroke would be felt. Everything here fails
17
+ soft and silent — advice that breaks your terminal is worse than no advice.
18
+ """
19
+ import json
20
+ import os
21
+ import time
22
+
23
+ from .classify import classify
24
+ from .paths import DATA_DIR, DB_PATH, ensure_dirs
25
+
26
+ CACHE = os.path.join(DATA_DIR, "advice_cache.json")
27
+ TTL_S = 3600
28
+ RECENT_PROMPTS = 12 # how much of the session counts as "what it is doing now"
29
+ MIN_SAVING_PCT = 25 # below this, interrupting someone is not worth it
30
+
31
+ # `/model <name>` takes a family name, not the pricing table's id.
32
+ ALIASES = ("opus", "sonnet", "haiku", "fable")
33
+
34
+
35
+ def model_alias(model_id):
36
+ """'claude-fable-5-1' -> 'fable'. Falls back to the full id we were given."""
37
+ low = str(model_id or "").lower()
38
+ for a in ALIASES:
39
+ if a in low:
40
+ return a
41
+ return model_id
42
+
43
+
44
+ # ---------------------------------------------------------------- evidence ----
45
+
46
+ def _build_evidence():
47
+ """Pull the back-test into the small shape the live surfaces need."""
48
+ from .analytics import Analytics
49
+ a = Analytics(DB_PATH)
50
+ ev = a.model_evidence({})
51
+ out = {}
52
+ for c in ev["categories"]:
53
+ cands = [{"model": x["model"], "name": x["name"], "verdict": x["verdict"],
54
+ "cost_per_prompt": round(x["cost_per_prompt"], 3),
55
+ "savings_pct": x["savings_pct"], "turn_ratio": x["turn_ratio"],
56
+ "prompts": x["prompts"], "why": x["why"]}
57
+ for x in c["candidates"]]
58
+ out[c["category"]] = {"current": c["current"]["model"],
59
+ "current_name": c["current"]["name"],
60
+ "current_cost_per_prompt": round(c["current"]["cost_per_prompt"], 3),
61
+ "agent": c.get("agent", "claude"),
62
+ "candidates": cands}
63
+ return out
64
+
65
+
66
+ def evidence(force=False):
67
+ """Cached evidence table, keyed by category. Never raises."""
68
+ try:
69
+ with open(CACHE) as fh:
70
+ c = json.load(fh)
71
+ if not force and time.time() - c.get("built_at", 0) < TTL_S:
72
+ return c.get("categories") or {}
73
+ except (OSError, ValueError):
74
+ pass
75
+ try:
76
+ cats = _build_evidence()
77
+ except Exception:
78
+ return {}
79
+ try:
80
+ ensure_dirs()
81
+ tmp = CACHE + ".tmp"
82
+ with open(tmp, "w") as fh:
83
+ json.dump({"built_at": int(time.time()), "categories": cats}, fh)
84
+ os.replace(tmp, CACHE)
85
+ except OSError:
86
+ pass
87
+ return cats
88
+
89
+
90
+ # ---------------------------------------------------------------- session ----
91
+
92
+ def recent_prompts(transcript, n=RECENT_PROMPTS):
93
+ """The last n human prompts in a live transcript, newest last.
94
+
95
+ Reads the tail only: an active session's JSONL runs to tens of megabytes and
96
+ this is on the path of every prompt you type.
97
+ """
98
+ try:
99
+ size = os.path.getsize(transcript)
100
+ with open(transcript, "rb") as fh:
101
+ fh.seek(max(0, size - 400_000))
102
+ lines = fh.read().decode("utf-8", "replace").splitlines()[1:]
103
+ except OSError:
104
+ return []
105
+ out = []
106
+ for line in reversed(lines):
107
+ if '"type":"user"' not in line and '"type": "user"' not in line:
108
+ continue
109
+ try:
110
+ d = json.loads(line)
111
+ except ValueError:
112
+ continue
113
+ if d.get("isMeta") or d.get("isSidechain"):
114
+ continue
115
+ msg = d.get("message") or {}
116
+ content = msg.get("content")
117
+ if isinstance(content, list):
118
+ content = " ".join(b.get("text", "") for b in content
119
+ if isinstance(b, dict) and b.get("type") == "text")
120
+ if not isinstance(content, str) or not content.strip():
121
+ continue
122
+ if content.lstrip().startswith(("<", "[Request interrupted")):
123
+ continue # tool results and interrupt markers are not prompts
124
+ out.append(content)
125
+ if len(out) >= n:
126
+ break
127
+ return list(reversed(out))
128
+
129
+
130
+ def category_of(texts):
131
+ """The category this stretch of work is in, by weight of confidence."""
132
+ scores = {}
133
+ for t in texts:
134
+ cat, conf, _ = classify(t)
135
+ scores[cat] = scores.get(cat, 0) + max(conf, 0.1)
136
+ if not scores:
137
+ return "other", 0.0
138
+ cat = max(scores, key=scores.get)
139
+ return cat, round(scores[cat] / sum(scores.values()), 2)
140
+
141
+
142
+ def advise(model=None, transcript=None, prompt=None, ev=None):
143
+ """What to say about a session that is running right now.
144
+
145
+ model the model the session is on (pricing-table id)
146
+ transcript path to the live JSONL, for reading what it has been doing
147
+ prompt the prompt about to be sent, when we are called from a hook
148
+ Returns None when there is nothing worth saying.
149
+ """
150
+ ev = evidence() if ev is None else ev
151
+ if not ev:
152
+ return None
153
+ texts = list(recent_prompts(transcript)) if transcript else []
154
+ if prompt:
155
+ # The prompt in hand is what the next turn will cost, so it leads.
156
+ texts = texts[-4:] + [prompt] * 3
157
+ if not texts:
158
+ return None
159
+ cat, conf = category_of(texts)
160
+ row = ev.get(cat)
161
+ if not row:
162
+ return None
163
+ # Only speak when they are on the model the evidence is about. Advising a
164
+ # switch away from a model you already left would be noise.
165
+ if model and row["current"] and model_alias(model) != model_alias(row["current"]):
166
+ return None
167
+ usable = [c for c in row["candidates"]
168
+ if c["verdict"] in ("supported", "caution") and c["savings_pct"] >= MIN_SAVING_PCT]
169
+ if not usable:
170
+ return None
171
+ best = usable[0]
172
+ trial = best["verdict"] == "caution"
173
+ return {
174
+ "category": cat, "confidence": conf,
175
+ "current_model": row["current"], "current_name": row["current_name"],
176
+ "current_cost_per_prompt": row["current_cost_per_prompt"],
177
+ "model": best["model"], "name": best["name"], "alias": model_alias(best["model"]),
178
+ "cost_per_prompt": best["cost_per_prompt"], "savings_pct": best["savings_pct"],
179
+ "turn_ratio": best["turn_ratio"], "prompts": best["prompts"],
180
+ "verdict": best["verdict"], "trial": trial, "why": best["why"],
181
+ "command": f"/model {model_alias(best['model'])}",
182
+ "line": (f"{cat.replace('_', ' ')} on {row['current_name']} "
183
+ f"(${row['current_cost_per_prompt']:.2f}/prompt). Your last {best['prompts']} "
184
+ f"ran on {best['name']} at ${best['cost_per_prompt']:.2f} in "
185
+ f"{best['turn_ratio']}x the turns"
186
+ + (" — worth a trial here." if trial else ".")),
187
+ "short": f"try {model_alias(best['model'])} (-{best['savings_pct']:.0f}%)",
188
+ }
package/finops/api.py CHANGED
@@ -323,6 +323,17 @@ class Handler(BaseHTTPRequestHandler):
323
323
  others = list_agent_sessions(a.pricing, [x for x in want if x != "claude"] or None) \
324
324
  if not want or any(x != "claude" for x in want) else []
325
325
  rows = sorted(claude + others, key=lambda x: -(x.get("context") or 0))
326
+ # Live model advice, while the session can still act on it. One cached
327
+ # evidence read for the whole list, and never fatal to the view.
328
+ try:
329
+ from .advisor import advise, evidence
330
+ ev = evidence()
331
+ for r in rows:
332
+ r["advice"] = advise(model=r.get("model"), transcript=r.get("transcript"),
333
+ ev=ev) if ev else None
334
+ except Exception:
335
+ for r in rows:
336
+ r.setdefault("advice", None)
326
337
  return self.send_json({"sessions": rows})
327
338
  if route == "cloud":
328
339
  from .cloud import report
@@ -0,0 +1,168 @@
1
+ """Live advice inside Claude Code itself: a prompt hook and a statusline.
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.
5
+
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.
14
+ """
15
+ import json
16
+ import os
17
+ import shutil
18
+ import sys
19
+
20
+ SETTINGS = os.path.join(os.path.expanduser("~"), ".claude", "settings.json")
21
+
22
+
23
+ def _stdin_json():
24
+ try:
25
+ raw = sys.stdin.read()
26
+ return json.loads(raw) if raw.strip() else {}
27
+ except (ValueError, OSError):
28
+ return {}
29
+
30
+
31
+ def _dig(d, *paths, default=None):
32
+ """First present value among dotted paths.
33
+
34
+ The payload shapes are not identical across Claude Code versions, and a
35
+ statusline that breaks on upgrade is worse than one that misses a field, so
36
+ every read tries the spellings we know of.
37
+ """
38
+ for path in paths:
39
+ cur = d
40
+ for part in path.split("."):
41
+ if not isinstance(cur, dict) or part not in cur:
42
+ cur = None
43
+ break
44
+ cur = cur[part]
45
+ if cur not in (None, ""):
46
+ return cur
47
+ return default
48
+
49
+
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
+ # -------------------------------------------------------------------- hook ----
56
+
57
+ 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
+ """
64
+ 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']}"}))
72
+ except Exception:
73
+ pass # never let advice interfere with the prompt
74
+ return 0
75
+
76
+
77
+ # -------------------------------------------------------------- statusline ----
78
+
79
+ def statusline():
80
+ """One line, refreshed constantly, so: what you are on, and what to try."""
81
+ try:
82
+ d = _stdin_json()
83
+ model, transcript = _payload_common(d)
84
+ pct = _dig(d, "context.percentUsed", "context.percent_used")
85
+ name = _dig(d, "model.display_name", "session.model", "model") or "claude"
86
+ bits = [str(name)]
87
+ if isinstance(pct, (int, float)):
88
+ 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
+ print(" · ".join(bits))
94
+ except Exception:
95
+ print("") # an empty statusline beats a stack trace under the prompt
96
+ return 0
97
+
98
+
99
+ # --------------------------------------------------------------- installing ----
100
+
101
+ def _command():
102
+ """How to invoke us from settings: the installed CLI if it is on PATH."""
103
+ exe = shutil.which("claude-finops")
104
+ if exe:
105
+ return exe
106
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
107
+ return f"{sys.executable} {os.path.join(root, 'run.py')}"
108
+
109
+
110
+ def _load_settings():
111
+ try:
112
+ with open(SETTINGS) as fh:
113
+ return json.load(fh)
114
+ except (OSError, ValueError):
115
+ return {}
116
+
117
+
118
+ def _save_settings(data):
119
+ os.makedirs(os.path.dirname(SETTINGS), exist_ok=True)
120
+ if os.path.exists(SETTINGS):
121
+ # Their settings file is not ours to lose.
122
+ shutil.copy2(SETTINGS, SETTINGS + ".finops-backup")
123
+ with open(SETTINGS, "w") as fh:
124
+ json.dump(data, fh, indent=2)
125
+ fh.write("\n")
126
+
127
+
128
+ 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")
149
+
150
+
151
+ def install_statusline(remove=False):
152
+ cmd = f"{_command()} --statusline"
153
+ s = _load_settings()
154
+ if remove:
155
+ if "finops" in str(s.get("statusLine", "")):
156
+ s.pop("statusLine", None)
157
+ else:
158
+ prev = s.get("statusLine")
159
+ if prev and "finops" not in str(prev):
160
+ print(f"You already have a statusLine configured:\n {prev}")
161
+ print("Leaving it alone. Remove it first if you want ours.")
162
+ return
163
+ s["statusLine"] = cmd
164
+ _save_settings(s)
165
+ print(("Removed" if remove else "Installed") + f" the statusline in {SETTINGS}")
166
+ if not remove:
167
+ print(" Shows the model, context pressure, and a cheaper model when one is warranted.")
168
+ print(" Undo: claude-finops --uninstall-statusline")
package/finops/procs.py CHANGED
@@ -85,6 +85,7 @@ def _transcript_stats(session_id, pricing):
85
85
  steps = tokens = 0
86
86
  ctx = None
87
87
  cost = 0.0
88
+ model = None # the model of the most recent assistant turn: what you are on now
88
89
  with open(fp, errors="replace") as fh:
89
90
  for line in fh:
90
91
  if '"usage"' not in line:
@@ -108,7 +109,8 @@ def _transcript_stats(session_id, pricing):
108
109
  if not (c5 or c1):
109
110
  c5 = cw
110
111
  cost += pricing.estimate(m.get("model"), inp, out, cr, c5, c1)
111
- return {"transcript": fp, "steps": steps, "tokens": tokens, "context": ctx,
112
+ model = m.get("model") or model
113
+ return {"transcript": fp, "steps": steps, "tokens": tokens, "context": ctx, "model": model,
112
114
  "est_cost_usd": cost, "last_write_s": time.time() - os.path.getmtime(fp)}
113
115
 
114
116
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
5
5
  "bin": {
6
6
  "claude-finops": "bin/claude-finops.js"
package/run.py CHANGED
@@ -171,6 +171,9 @@ HELP = """Claude FinOps Command Center
171
171
  claude-finops --keys list which provider keys are configured
172
172
  claude-finops --share write ../claude-finops.zip (code only, never your data)
173
173
  claude-finops --version print the installed version, and whether a newer one is out
174
+ claude-finops --advise what to switch to in the sessions running right now
175
+ claude-finops --install-hook suggest a cheaper model in Claude Code, as you send each prompt
176
+ claude-finops --install-statusline show model, context and advice in your statusline
174
177
  claude-finops --help this message
175
178
 
176
179
  Environment:
@@ -182,6 +185,28 @@ Environment:
182
185
  """
183
186
 
184
187
 
188
+ def advise_now():
189
+ """What every session running right now should consider switching to."""
190
+ from finops.advisor import advise, evidence
191
+ from finops.procs import list_sessions
192
+ from finops.analytics import Analytics
193
+ ev = evidence()
194
+ if not ev:
195
+ return print("No model evidence yet — you need two models run on the same kind of "
196
+ "work before there is anything to compare.")
197
+ rows = list_sessions(Analytics(DB_PATH).pricing)
198
+ if not rows:
199
+ return print("No Claude Code sessions are running.")
200
+ for r in rows:
201
+ a = advise(model=r.get("model"), transcript=r.get("transcript"), ev=ev)
202
+ head = f" {r.get('project') or r.get('session_id') or 'session'}"
203
+ if a:
204
+ print(f"{head}: {a['line']}")
205
+ print(f"{' ' * len(head)} run {a['command']}")
206
+ else:
207
+ print(f"{head}: nothing to change.")
208
+
209
+
185
210
  def _version():
186
211
  from finops.update import _installed
187
212
  return _installed()
@@ -292,6 +317,23 @@ def main():
292
317
  return print(HELP)
293
318
  if "--version" in args or "-v" in args or "-V" in args:
294
319
  return version()
320
+ # Integration entry points. --hook and --statusline are invoked by Claude Code
321
+ # with a JSON payload on stdin, many times a session; they print one line and
322
+ # never fail loudly.
323
+ if "--hook" in args:
324
+ from finops.integrate import hook
325
+ return sys.exit(hook())
326
+ if "--statusline" in args:
327
+ from finops.integrate import statusline
328
+ return sys.exit(statusline())
329
+ if "--install-hook" in args or "--uninstall-hook" in args:
330
+ from finops.integrate import install_hook
331
+ return install_hook(remove="--uninstall-hook" in args)
332
+ if "--install-statusline" in args or "--uninstall-statusline" in args:
333
+ from finops.integrate import install_statusline
334
+ return install_statusline(remove="--uninstall-statusline" in args)
335
+ if "--advise" in args:
336
+ return advise_now()
295
337
  if "--where" in args:
296
338
  return where()
297
339
  if "--keys" in args:
package/web/app.js CHANGED
@@ -1644,6 +1644,10 @@ VIEWS.live = async (page) => {
1644
1644
  <div class="dt">${x.status === 'busy' ? '<b>Working now</b>' : 'Idle'}${x.uptime ? ` · up ${esc(x.uptime)}` : ''} ·
1645
1645
  last activity ${x.last_write_s == null ? '—' : dur(x.last_write_s)} ago${x.memory_mb == null ? '' : ` · ${fmtInt(x.memory_mb)} MB`} ·
1646
1646
  <span class="note">${esc(x.cwd)}</span></div>
1647
+ ${x.advice ? `<div class="dt switch-tip"><b>${x.advice.trial ? 'Worth trying' : 'Cheaper model'}:</b>
1648
+ ${esc(x.advice.line)}
1649
+ <button class="act ghost" data-copy="${esc(x.advice.command)}"
1650
+ title="${esc(x.advice.why)}">Copy ${esc(x.advice.command)}</button></div>` : ''}
1647
1651
  ${x.severity !== 'ok' ? `<div class="dt"><b>Advice:</b> ${x.severity === 'high'
1648
1652
  ? 'Very large context. Use <b>Hand over</b> to continue in a fresh session, or split the remaining work into sub-sessions.'
1649
1653
  : 'Getting heavy. Hit <b>Compact</b> at the next break, or close it if the task is done.'}</div>` : ''}
package/web/styles.css CHANGED
@@ -367,6 +367,9 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
367
367
  .live-msg.ok { color: var(--good-ink); } .live-msg.err { color: var(--critical-ink); }
368
368
  .item.gone { opacity: .45; }
369
369
 
370
+ /* An explicit display beats the hidden attribute, so the panel has to opt back
371
+ out or it is permanently open. */
372
+ .handover[hidden] { display: none; }
370
373
  .handover { margin-top: 8px; padding: 10px; border: 1px dashed var(--border); border-radius: 8px; display: grid; gap: 8px; }
371
374
  .handover textarea { width: 100%; box-sizing: border-box; font: inherit; font-size: 12.5px; padding: 8px; border-radius: 6px;
372
375
  border: 1px solid var(--border); background: transparent; color: inherit; resize: vertical; }
@@ -417,3 +420,8 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
417
420
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
418
421
  .who .em, .who .pl { font-size:10px; color:var(--muted); line-height:1.35;
419
422
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
423
+
424
+ /* Live model advice on a running session: it is an opportunity, not a problem,
425
+ so it reads as a note with an accent edge rather than a warning. */
426
+ .switch-tip { border-left: 2px solid var(--accent, #eb6834); padding-left: 9px; }
427
+ .switch-tip .act { margin-left: 8px; }