claude-finops 0.8.0 → 0.10.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 +40 -5
- package/config/pricing.json +124 -2
- package/finops/act_now.py +92 -0
- package/finops/agents.py +2 -1
- package/finops/analytics.py +51 -1
- package/finops/api.py +11 -0
- package/finops/etl.py +20 -5
- package/finops/integrate.py +31 -16
- package/finops/paths.py +12 -0
- package/finops/plan_history.py +80 -0
- package/package.json +1 -1
- package/run.py +4 -2
- package/web/app.js +211 -30
- package/web/charts.js +34 -1
- package/web/styles.css +17 -1
package/README.md
CHANGED
|
@@ -63,6 +63,13 @@ npm install -g claude-finops # then `claude-finops` from anywhere
|
|
|
63
63
|
|
|
64
64
|
## What you actually get
|
|
65
65
|
|
|
66
|
+
**It acts, not just reports.** The overview opens with **⚡ Act now**: one-click
|
|
67
|
+
actions that apply to you right now. Compact a running session that is carrying too
|
|
68
|
+
much context (it types `/compact` into that session's terminal), hand it over to a
|
|
69
|
+
fresh session, turn a shell command you keep re-running into a skill, or move an
|
|
70
|
+
instruction you keep re-typing into `CLAUDE.md`. Every session row has **Resume**
|
|
71
|
+
(copies `claude --resume <id>`), and running ones have **Compact**.
|
|
72
|
+
|
|
66
73
|
**Where the money goes, per project.** Every project ranked by cost, drilling down
|
|
67
74
|
Project → Session → Prompt.
|
|
68
75
|
|
|
@@ -140,17 +147,21 @@ labelled as your own configured figures.
|
|
|
140
147
|
|
|
141
148
|
## What is in it
|
|
142
149
|
|
|
143
|
-
**Command center** — Executive overview (spend, tokens, usage %, remaining, forecast
|
|
144
|
-
|
|
150
|
+
**Command center** — Executive overview (spend, tokens, usage %, remaining, forecast,
|
|
151
|
+
today vs yesterday) opening with the ⚡ Act now strip, running sessions you can
|
|
152
|
+
interrupt, compact, hand over or close, an AI FinOps Advisor that answers "what should I do today?" from live data, and a
|
|
145
153
|
0–100 FinOps scorecard with per-dimension reasoning.
|
|
146
154
|
|
|
147
155
|
**Usage** — Interactive timeline across 10 metrics and 6 time ranges with day
|
|
148
|
-
drill-down
|
|
156
|
+
drill-down, plus a weekday × hour peak-hours heatmap in your local time; burn rate and
|
|
157
|
+
limits with a gauge, days-until-limit and projected overage, and your 5-hour and weekly
|
|
158
|
+
plan-limit history from the Claude desktop app;
|
|
149
159
|
per-model FinOps table with superlatives (most expensive, most used, most
|
|
150
160
|
token-efficient, best cost-per-output); context-size distribution and cache
|
|
151
161
|
with-vs-without analysis.
|
|
152
162
|
|
|
153
|
-
**Drill-down** — Project → Session → Prompt, everywhere
|
|
163
|
+
**Drill-down** — Project → Session → Prompt, everywhere, with Resume (and Compact for
|
|
164
|
+
running sessions) on every session row. A prompt explorer over every
|
|
154
165
|
prompt with full text, category, tokens, cache split, tool calls, files touched,
|
|
155
166
|
latency and efficiency; five leaderboards; prompt intelligence (spend by activity);
|
|
156
167
|
and a Claude Code view (tools, files, branches, cost per repository).
|
|
@@ -212,12 +223,14 @@ depends entirely on how agentic your workload is.
|
|
|
212
223
|
|
|
213
224
|
```
|
|
214
225
|
~/.claude/projects/**/*.jsonl
|
|
226
|
+
<Claude desktop app>/local-agent-mode-sessions/**/.claude/projects/**/*.jsonl (Cowork)
|
|
215
227
|
│
|
|
216
228
|
▼ finops/etl.py stream-parse, normalize, roll up
|
|
217
229
|
~/.claude-finops/data/finops.db SQLite warehouse
|
|
218
230
|
│
|
|
219
231
|
▼ finops/analytics.py KPIs · burn · forecast · waste · anomalies · scorecard
|
|
220
232
|
finops/api.py stdlib HTTP: JSON API + static files (127.0.0.1 only)
|
|
233
|
+
(+ <Claude desktop app>/plan-usage-history.json, read on request)
|
|
221
234
|
│
|
|
222
235
|
▼ web/ vanilla JS, inline-SVG charts, no build step
|
|
223
236
|
```
|
|
@@ -262,6 +275,8 @@ claude-finops --stop stop it
|
|
|
262
275
|
claude-finops --where where your data, settings and keys live
|
|
263
276
|
claude-finops --set-key store a provider API key (hidden prompt, 0600)
|
|
264
277
|
claude-finops --keys which provider keys are configured
|
|
278
|
+
claude-finops --install-statusline model, context % and 5-hour limit under every prompt
|
|
279
|
+
claude-finops --uninstall-statusline remove it again
|
|
265
280
|
claude-finops --help everything
|
|
266
281
|
```
|
|
267
282
|
|
|
@@ -293,6 +308,12 @@ place for you to delete once you are happy.
|
|
|
293
308
|
|
|
294
309
|
## Privacy
|
|
295
310
|
|
|
311
|
+
**What it reads, all read-only:** Claude Code transcripts in `~/.claude/projects`, and,
|
|
312
|
+
if the Claude desktop app is installed, its Cowork transcripts and
|
|
313
|
+
`plan-usage-history.json` (`~/Library/Application Support/Claude` on macOS,
|
|
314
|
+
`%APPDATA%\Claude` on Windows, `~/.config/Claude` on Linux), plus the other coding
|
|
315
|
+
agents listed under Multi-agent.
|
|
316
|
+
|
|
296
317
|
`~/.claude-finops/data/finops.db` and the prompt/CSV exports contain **your full prompt text**. The
|
|
297
318
|
server binds to `127.0.0.1` only, but treat the database and any export you
|
|
298
319
|
generate as sensitive.
|
|
@@ -310,7 +331,8 @@ configure a key with `--set-key`.
|
|
|
310
331
|
|
|
311
332
|
The app has nothing tied to one person. Whoever runs it sees **their own** Claude usage:
|
|
312
333
|
|
|
313
|
-
- It reads `~/.claude/projects` on the machine it runs on (override with `CLAUDE_PROJECTS=/path`)
|
|
334
|
+
- It reads `~/.claude/projects` on the machine it runs on (override with `CLAUDE_PROJECTS=/path`),
|
|
335
|
+
plus the Claude desktop app's Cowork sessions when the default location is used.
|
|
314
336
|
- The account label comes from that machine's `~/.claude.json`.
|
|
315
337
|
- Budgets and limits you set in the UI are saved to `~/.claude-finops/settings.local.json`. The committed `config/settings.json` holds only shared defaults.
|
|
316
338
|
|
|
@@ -344,6 +366,19 @@ files on Windows.
|
|
|
344
366
|
`config/free_models.json`.
|
|
345
367
|
- **Skills & MCP** suggests MCP servers and skills from work you repeat, with the evidence.
|
|
346
368
|
"Add" runs `claude mcp add -s user …`; "Create skill" writes `~/.claude/skills/<name>/SKILL.md`.
|
|
369
|
+
The strongest skill suggestions also appear in **⚡ Act now** on the overview.
|
|
370
|
+
- **Compact** (Running sessions, Act now, session rows) types `/compact` into that session's
|
|
371
|
+
terminal: tmux, Terminal.app, iTerm2 or the Windows console. Anywhere else it copies the
|
|
372
|
+
command for you to paste. It asks you to click twice.
|
|
373
|
+
- **Hand over / split** (Running sessions) writes a brief of the session to
|
|
374
|
+
`~/.claude/finops-handoffs/` and opens fresh session(s) in new terminals, one per sub-task if
|
|
375
|
+
you list them; it can close the old session (resumable).
|
|
376
|
+
- **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal.
|
|
377
|
+
Each asks you to click twice. Interrupt is macOS/Linux only: on Windows there is no way to
|
|
378
|
+
interrupt one console process without hitting the others in its window.
|
|
379
|
+
- **Install statusline** (Act now, or `--install-statusline`) adds a `statusLine` entry to
|
|
380
|
+
`~/.claude/settings.json`, keeping a `.finops-backup` copy. It won't replace a statusline you
|
|
381
|
+
already have.
|
|
347
382
|
|
|
348
383
|
---
|
|
349
384
|
|
package/config/pricing.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"_comment": "Model pricing in USD per 1,000,000 tokens. Kept SEPARATE from usage data so it can be updated without touching the dashboard. Any cost derived from these numbers is ESTIMATED, never actual billed cost.",
|
|
3
3
|
"currency": "USD",
|
|
4
4
|
"unit": "per_million_tokens",
|
|
5
|
-
"updated": "2026-09-
|
|
6
|
-
"source": "Anthropic list prices (
|
|
5
|
+
"updated": "2026-09-28",
|
|
6
|
+
"source": "Anthropic list prices (platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-28)",
|
|
7
7
|
"models": {
|
|
8
8
|
"claude-opus-5": {
|
|
9
9
|
"display_name": "Claude Opus 5",
|
|
@@ -84,6 +84,128 @@
|
|
|
84
84
|
"context_window": 1000000,
|
|
85
85
|
"provider": "anthropic"
|
|
86
86
|
},
|
|
87
|
+
"claude-opus-4-8": {
|
|
88
|
+
"display_name": "Claude Opus 4.8",
|
|
89
|
+
"tier": "frontier",
|
|
90
|
+
"input": 5.0,
|
|
91
|
+
"output": 25.0,
|
|
92
|
+
"cache_write_5m": 6.25,
|
|
93
|
+
"cache_write_1h": 10.0,
|
|
94
|
+
"cache_read": 0.5,
|
|
95
|
+
"context_window": 1000000,
|
|
96
|
+
"provider": "anthropic"
|
|
97
|
+
},
|
|
98
|
+
"claude-opus-4-8[fast]": {
|
|
99
|
+
"display_name": "Claude Opus 4.8 (fast mode)",
|
|
100
|
+
"tier": "frontier",
|
|
101
|
+
"input": 10.0,
|
|
102
|
+
"output": 50.0,
|
|
103
|
+
"cache_write_5m": 12.5,
|
|
104
|
+
"cache_write_1h": 20.0,
|
|
105
|
+
"cache_read": 1.0,
|
|
106
|
+
"context_window": 1000000,
|
|
107
|
+
"provider": "anthropic",
|
|
108
|
+
"_note": "Research-preview fast mode, 2x standard. Selected when usage.speed == 'fast'."
|
|
109
|
+
},
|
|
110
|
+
"claude-opus-4-7": {
|
|
111
|
+
"display_name": "Claude Opus 4.7",
|
|
112
|
+
"tier": "frontier",
|
|
113
|
+
"input": 5.0,
|
|
114
|
+
"output": 25.0,
|
|
115
|
+
"cache_write_5m": 6.25,
|
|
116
|
+
"cache_write_1h": 10.0,
|
|
117
|
+
"cache_read": 0.5,
|
|
118
|
+
"context_window": 1000000,
|
|
119
|
+
"provider": "anthropic"
|
|
120
|
+
},
|
|
121
|
+
"claude-opus-4-6": {
|
|
122
|
+
"display_name": "Claude Opus 4.6",
|
|
123
|
+
"tier": "frontier",
|
|
124
|
+
"input": 5.0,
|
|
125
|
+
"output": 25.0,
|
|
126
|
+
"cache_write_5m": 6.25,
|
|
127
|
+
"cache_write_1h": 10.0,
|
|
128
|
+
"cache_read": 0.5,
|
|
129
|
+
"context_window": 1000000,
|
|
130
|
+
"provider": "anthropic"
|
|
131
|
+
},
|
|
132
|
+
"claude-opus-4-5": {
|
|
133
|
+
"display_name": "Claude Opus 4.5",
|
|
134
|
+
"tier": "frontier",
|
|
135
|
+
"input": 5.0,
|
|
136
|
+
"output": 25.0,
|
|
137
|
+
"cache_write_5m": 6.25,
|
|
138
|
+
"cache_write_1h": 10.0,
|
|
139
|
+
"cache_read": 0.5,
|
|
140
|
+
"context_window": 200000,
|
|
141
|
+
"provider": "anthropic"
|
|
142
|
+
},
|
|
143
|
+
"claude-opus-4-1": {
|
|
144
|
+
"display_name": "Claude Opus 4.1",
|
|
145
|
+
"tier": "frontier",
|
|
146
|
+
"input": 15.0,
|
|
147
|
+
"output": 75.0,
|
|
148
|
+
"cache_write_5m": 18.75,
|
|
149
|
+
"cache_write_1h": 30.0,
|
|
150
|
+
"cache_read": 1.5,
|
|
151
|
+
"context_window": 200000,
|
|
152
|
+
"provider": "anthropic"
|
|
153
|
+
},
|
|
154
|
+
"claude-opus-4": {
|
|
155
|
+
"display_name": "Claude Opus 4",
|
|
156
|
+
"tier": "frontier",
|
|
157
|
+
"input": 15.0,
|
|
158
|
+
"output": 75.0,
|
|
159
|
+
"cache_write_5m": 18.75,
|
|
160
|
+
"cache_write_1h": 30.0,
|
|
161
|
+
"cache_read": 1.5,
|
|
162
|
+
"context_window": 200000,
|
|
163
|
+
"provider": "anthropic"
|
|
164
|
+
},
|
|
165
|
+
"claude-sonnet-4-6": {
|
|
166
|
+
"display_name": "Claude Sonnet 4.6",
|
|
167
|
+
"tier": "balanced",
|
|
168
|
+
"input": 3.0,
|
|
169
|
+
"output": 15.0,
|
|
170
|
+
"cache_write_5m": 3.75,
|
|
171
|
+
"cache_write_1h": 6.0,
|
|
172
|
+
"cache_read": 0.3,
|
|
173
|
+
"context_window": 1000000,
|
|
174
|
+
"provider": "anthropic"
|
|
175
|
+
},
|
|
176
|
+
"claude-sonnet-4-5": {
|
|
177
|
+
"display_name": "Claude Sonnet 4.5",
|
|
178
|
+
"tier": "balanced",
|
|
179
|
+
"input": 3.0,
|
|
180
|
+
"output": 15.0,
|
|
181
|
+
"cache_write_5m": 3.75,
|
|
182
|
+
"cache_write_1h": 6.0,
|
|
183
|
+
"cache_read": 0.3,
|
|
184
|
+
"context_window": 200000,
|
|
185
|
+
"provider": "anthropic"
|
|
186
|
+
},
|
|
187
|
+
"claude-sonnet-4": {
|
|
188
|
+
"display_name": "Claude Sonnet 4",
|
|
189
|
+
"tier": "balanced",
|
|
190
|
+
"input": 3.0,
|
|
191
|
+
"output": 15.0,
|
|
192
|
+
"cache_write_5m": 3.75,
|
|
193
|
+
"cache_write_1h": 6.0,
|
|
194
|
+
"cache_read": 0.3,
|
|
195
|
+
"context_window": 200000,
|
|
196
|
+
"provider": "anthropic"
|
|
197
|
+
},
|
|
198
|
+
"claude-3-5-haiku": {
|
|
199
|
+
"display_name": "Claude Haiku 3.5",
|
|
200
|
+
"tier": "economy",
|
|
201
|
+
"input": 0.8,
|
|
202
|
+
"output": 4.0,
|
|
203
|
+
"cache_write_5m": 1.0,
|
|
204
|
+
"cache_write_1h": 1.6,
|
|
205
|
+
"cache_read": 0.08,
|
|
206
|
+
"context_window": 200000,
|
|
207
|
+
"provider": "anthropic"
|
|
208
|
+
},
|
|
87
209
|
"claude-haiku-4-5-20251001": {
|
|
88
210
|
"display_name": "Claude Haiku 4.5",
|
|
89
211
|
"tier": "economy",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""What to do right now: the few one-click actions that apply to this machine today.
|
|
2
|
+
|
|
3
|
+
Everything here already exists deeper in the dashboard (Running sessions, Skills &
|
|
4
|
+
MCP, Why so many tokens?). This picks the high-signal items only, so the overview
|
|
5
|
+
can act instead of just reporting:
|
|
6
|
+
|
|
7
|
+
session a running session carrying heavy context -> Compact / Hand over
|
|
8
|
+
skill a shell command you keep re-running -> Create skill
|
|
9
|
+
memory an instruction you keep re-typing -> Add to CLAUDE.md
|
|
10
|
+
statusline no statusline configured -> Install
|
|
11
|
+
|
|
12
|
+
Prompt-based skill suggestions and one-off memory lines are left to their own pages:
|
|
13
|
+
they are noisy enough that a one-click card would mostly be wrong.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
MAX_SESSIONS = 3
|
|
17
|
+
MAX_SKILLS = 2
|
|
18
|
+
MAX_MEMORY = 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _k(n):
|
|
22
|
+
return f"{n / 1000:.0f}K" if n < 1_000_000 else f"{n / 1_000_000:.1f}M"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build(live, skills, memory, statusline):
|
|
26
|
+
"""live: procs.list_sessions rows; skills: actions.suggestions()["skills"];
|
|
27
|
+
memory: Diagnoser.memory_suggestions(); statusline: integrate.statusline_state()."""
|
|
28
|
+
items = []
|
|
29
|
+
heavy = sorted((x for x in live if x.get("severity") in ("medium", "high") and x.get("context")),
|
|
30
|
+
key=lambda x: -x["context"])[:MAX_SESSIONS]
|
|
31
|
+
for x in heavy:
|
|
32
|
+
items.append({
|
|
33
|
+
"kind": "session", "severity": x["severity"], "pid": x.get("pid"),
|
|
34
|
+
"session_id": x.get("session_id"), "hosts_dashboard": bool(x.get("hosts_dashboard")),
|
|
35
|
+
"title": f"{x.get('name') or 'A session'} is carrying {_k(x['context'])} tokens of context",
|
|
36
|
+
"detail": f"{x.get('project') or ''} · every message re-reads all of it. "
|
|
37
|
+
+ ("Hand it over to a fresh session." if x["severity"] == "high"
|
|
38
|
+
else "Compact it at the next break."),
|
|
39
|
+
})
|
|
40
|
+
cmds = sorted((s for s in skills if s.get("kind") == "command" and not s.get("installed")),
|
|
41
|
+
key=lambda s: -s.get("runs", 0))[:MAX_SKILLS]
|
|
42
|
+
for s in cmds:
|
|
43
|
+
items.append({
|
|
44
|
+
"kind": "skill", "skill": s,
|
|
45
|
+
"title": f"You ran `{s['trigger']}` {s['runs']} times in {s['sessions']} sessions",
|
|
46
|
+
"detail": "Make it a skill so Claude runs it the way this project expects, "
|
|
47
|
+
"without re-explaining each time.",
|
|
48
|
+
})
|
|
49
|
+
themes = sorted((m for m in memory if m.get("kind") == "theme" and not m.get("already_saved")),
|
|
50
|
+
key=lambda m: -m.get("sessions", 0))[:MAX_MEMORY]
|
|
51
|
+
for m in themes:
|
|
52
|
+
items.append({
|
|
53
|
+
"kind": "memory",
|
|
54
|
+
"title": f"You keep re-typing instructions about: {m['text']}",
|
|
55
|
+
"detail": f"Seen in {m['sessions']} sessions. Put it in {m['target']} once instead.",
|
|
56
|
+
})
|
|
57
|
+
if statusline is None:
|
|
58
|
+
items.append({
|
|
59
|
+
"kind": "statusline",
|
|
60
|
+
"title": "See model, context % and 5-hour limit under every Claude Code prompt",
|
|
61
|
+
"detail": "Adds a statusline to ~/.claude/settings.json (a backup is kept). "
|
|
62
|
+
"Undo any time with claude-finops --uninstall-statusline.",
|
|
63
|
+
})
|
|
64
|
+
return {"items": items}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def act_now(a):
|
|
68
|
+
"""Gather the live inputs and build the strip. Each source fails soft."""
|
|
69
|
+
from .procs import list_sessions
|
|
70
|
+
from .actions import suggestions
|
|
71
|
+
from .diagnose import Diagnoser
|
|
72
|
+
from .integrate import statusline_state
|
|
73
|
+
|
|
74
|
+
def safe(fn, default):
|
|
75
|
+
try:
|
|
76
|
+
return fn()
|
|
77
|
+
except Exception:
|
|
78
|
+
return default
|
|
79
|
+
|
|
80
|
+
live = safe(lambda: list_sessions(a.pricing), [])
|
|
81
|
+
# the process registry names sessions after their folder; the transcript title reads better
|
|
82
|
+
ids = [x["session_id"] for x in live if x.get("session_id")]
|
|
83
|
+
titles = safe(lambda: {r["id"]: r["title"] for r in a.q(
|
|
84
|
+
"SELECT id, title FROM sessions WHERE title IS NOT NULL AND id IN (%s)" % ",".join("?" * len(ids)),
|
|
85
|
+
ids)} if ids else {}, {})
|
|
86
|
+
for x in live:
|
|
87
|
+
if titles.get(x.get("session_id")):
|
|
88
|
+
x["name"] = titles[x["session_id"]]
|
|
89
|
+
return build(live,
|
|
90
|
+
safe(lambda: suggestions(a)["skills"], []),
|
|
91
|
+
safe(lambda: Diagnoser(a).memory_suggestions({}), []),
|
|
92
|
+
safe(statusline_state, "other"))
|
package/finops/agents.py
CHANGED
|
@@ -33,7 +33,8 @@ def _cursor_state_db():
|
|
|
33
33
|
|
|
34
34
|
AGENTS = {
|
|
35
35
|
"claude": {"name": "Claude Code", "data": "full",
|
|
36
|
-
"note": "Tokens, model, cost, prompts and tool calls per request."
|
|
36
|
+
"note": "Tokens, model, cost, prompts and tool calls per request. "
|
|
37
|
+
"Includes Claude desktop app (Cowork) sessions when present."},
|
|
37
38
|
"codex": {"name": "Codex", "data": "tokens",
|
|
38
39
|
"paths": [os.path.join(HOME, ".codex", "sessions")],
|
|
39
40
|
"note": "Tokens and model per turn. Cost estimated at OpenAI API list prices; "
|
package/finops/analytics.py
CHANGED
|
@@ -12,6 +12,7 @@ import sqlite3
|
|
|
12
12
|
import statistics
|
|
13
13
|
import sys
|
|
14
14
|
import threading
|
|
15
|
+
import time
|
|
15
16
|
from collections import defaultdict
|
|
16
17
|
from datetime import date, datetime, timedelta, timezone
|
|
17
18
|
|
|
@@ -167,6 +168,16 @@ def _cumsum(values):
|
|
|
167
168
|
yield total
|
|
168
169
|
|
|
169
170
|
|
|
171
|
+
def resume_command(sid, agent, source_file):
|
|
172
|
+
"""`claude --resume <id>` for a Claude Code session; None where it would not work.
|
|
173
|
+
|
|
174
|
+
Cowork transcripts live under the desktop app's own config dir, where
|
|
175
|
+
`claude --resume` would not find them.
|
|
176
|
+
"""
|
|
177
|
+
cowork = "local-agent-mode-sessions" in (source_file or "")
|
|
178
|
+
return f"claude --resume {sid}" if (agent or "claude") == "claude" and not cowork else None
|
|
179
|
+
|
|
180
|
+
|
|
170
181
|
class Analytics:
|
|
171
182
|
def __init__(self, db_path=DB_PATH):
|
|
172
183
|
# One connection per thread. The HTTP server is threaded, and a single sqlite
|
|
@@ -334,6 +345,7 @@ class Analytics:
|
|
|
334
345
|
f" FROM requests r WHERE {w} AND {extra}", p + ep)
|
|
335
346
|
|
|
336
347
|
tot["cost_today"] = spend("r.day = ?", [today])
|
|
348
|
+
tot["cost_yesterday"] = spend("r.day = ?", [(_d(today) - timedelta(days=1)).isoformat()])
|
|
337
349
|
tot["cost_week"] = spend("r.day >= ?", [wk])
|
|
338
350
|
tot["cost_period"] = spend("r.day >= ? AND r.day <= ?", [bp["start"], bp["end"]])
|
|
339
351
|
tot["billing_period"] = bp
|
|
@@ -443,6 +455,30 @@ class Analytics:
|
|
|
443
455
|
AVG(r.context_tokens) avg_context
|
|
444
456
|
FROM requests r WHERE {w} AND r.day <> '' GROUP BY 1 ORDER BY 1""", p)
|
|
445
457
|
|
|
458
|
+
def heatmap(self, f=None):
|
|
459
|
+
"""Spend by weekday x hour, in this machine's local time (Monday first).
|
|
460
|
+
|
|
461
|
+
Transcripts stamp UTC; bucketing on that would put a 10am IST session at
|
|
462
|
+
4am. SQLite's 'localtime' modifier applies the local offset, half-hour
|
|
463
|
+
zones included. Date filters still apply to UTC days like every view.
|
|
464
|
+
"""
|
|
465
|
+
w, p = self.where(f)
|
|
466
|
+
rows = self.q(f"""
|
|
467
|
+
SELECT CAST(strftime('%w', r.ts, 'localtime') AS INTEGER) wd,
|
|
468
|
+
CAST(strftime('%H', r.ts, 'localtime') AS INTEGER) hr,
|
|
469
|
+
COALESCE(SUM(r.est_cost_usd),0) cost, COALESCE(SUM(r.billable_tokens),0) tokens,
|
|
470
|
+
COUNT(*) requests
|
|
471
|
+
FROM requests r WHERE {w} AND r.ts <> '' GROUP BY 1, 2""", p)
|
|
472
|
+
grid = {(d, h): {"dow": d, "hour": h, "cost": 0.0, "tokens": 0, "requests": 0}
|
|
473
|
+
for d in range(7) for h in range(24)}
|
|
474
|
+
for r in rows:
|
|
475
|
+
if r["wd"] is None or r["hr"] is None:
|
|
476
|
+
continue
|
|
477
|
+
c = grid[((r["wd"] + 6) % 7, r["hr"])]
|
|
478
|
+
c["cost"], c["tokens"], c["requests"] = r["cost"], r["tokens"], r["requests"]
|
|
479
|
+
return {"cells": [grid[(d, h)] for d in range(7) for h in range(24)],
|
|
480
|
+
"tz": time.strftime("%Z"), "cost_basis": "estimated"}
|
|
481
|
+
|
|
446
482
|
# ---------------- models ----------------
|
|
447
483
|
def models(self, f=None):
|
|
448
484
|
w, p = self.where(f)
|
|
@@ -535,6 +571,7 @@ class Analytics:
|
|
|
535
571
|
rows = self.q(f"""
|
|
536
572
|
SELECT s.id session_id, s.title, s.git_branch, s.cli_version, s.started_at,
|
|
537
573
|
s.ended_at, s.duration_s, s.files_touched, pr.name project, pr.id project_id,
|
|
574
|
+
s.agent, s.source_file,
|
|
538
575
|
COUNT(DISTINCT r.prompt_id) prompts, COUNT(*) requests,
|
|
539
576
|
SUM(r.tool_call_count) tool_calls,
|
|
540
577
|
SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
|
|
@@ -553,6 +590,7 @@ class Analytics:
|
|
|
553
590
|
r["output_ratio"] = (r["output_tokens"] or 0) / r["tokens"] if r["tokens"] else 0
|
|
554
591
|
cr, cw = r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0
|
|
555
592
|
r["cache_hit_ratio"] = (cr / (cr + cw)) if (cr + cw) else None
|
|
593
|
+
r["resume"] = resume_command(r["session_id"], r.pop("agent"), r.pop("source_file"))
|
|
556
594
|
return rows
|
|
557
595
|
|
|
558
596
|
def sessions_total(self, f=None):
|
|
@@ -673,6 +711,7 @@ class Analytics:
|
|
|
673
711
|
s["files"] = self.q(
|
|
674
712
|
"SELECT path, GROUP_CONCAT(DISTINCT op) ops, COUNT(*) n FROM files_touched"
|
|
675
713
|
" WHERE session_id=? GROUP BY path ORDER BY n DESC LIMIT 100", (sid,))
|
|
714
|
+
s["resume"] = resume_command(sid, s.get("agent"), s.get("source_file"))
|
|
676
715
|
return s
|
|
677
716
|
|
|
678
717
|
# ---------------- categories ----------------
|
|
@@ -694,7 +733,7 @@ class Analytics:
|
|
|
694
733
|
|
|
695
734
|
# ---------------- leaderboards ----------------
|
|
696
735
|
def leaderboards(self, f=None, n=20):
|
|
697
|
-
|
|
736
|
+
out = {
|
|
698
737
|
"most_expensive": self.prompts(f, limit=n, order="cost"),
|
|
699
738
|
"most_token_heavy": self.prompts(f, limit=n, order="tokens"),
|
|
700
739
|
"cheapest": self.prompts(f, limit=n, order="cheapest"),
|
|
@@ -702,6 +741,17 @@ class Analytics:
|
|
|
702
741
|
"longest_sessions": self.sessions(f, limit=n, order="duration"),
|
|
703
742
|
"basis": "estimated",
|
|
704
743
|
}
|
|
744
|
+
prompt_rows = [r for k in ("most_expensive", "most_token_heavy", "cheapest", "most_efficient")
|
|
745
|
+
for r in out[k]]
|
|
746
|
+
sids = sorted({r["session_id"] for r in prompt_rows if r.get("session_id")})
|
|
747
|
+
src = {r["id"]: r for r in self.q(
|
|
748
|
+
"SELECT id, agent, source_file FROM sessions WHERE id IN (%s)" % ",".join("?" * len(sids)),
|
|
749
|
+
sids)} if sids else {}
|
|
750
|
+
for r in prompt_rows:
|
|
751
|
+
s = src.get(r.get("session_id")) or {}
|
|
752
|
+
r["resume"] = (resume_command(r["session_id"], s.get("agent"), s.get("source_file"))
|
|
753
|
+
if s else None)
|
|
754
|
+
return out
|
|
705
755
|
|
|
706
756
|
# ---------------- efficiency ----------------
|
|
707
757
|
def hygiene(self, f=None, top=12, trajectory_points=80):
|
package/finops/api.py
CHANGED
|
@@ -270,6 +270,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
270
270
|
return self.send_json({"job": j["id"]})
|
|
271
271
|
if parts == ["skill"]:
|
|
272
272
|
return self.send_json(X.create_skill(payload))
|
|
273
|
+
if parts == ["statusline"]:
|
|
274
|
+
from .integrate import install_statusline
|
|
275
|
+
return self.send_json(install_statusline())
|
|
273
276
|
if len(parts) == 2 and parts[0] == "mcp":
|
|
274
277
|
return self.send_json(X.add_mcp(parts[1], payload))
|
|
275
278
|
return self.send_json({"error": "unknown action"}, 404)
|
|
@@ -366,6 +369,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
366
369
|
if route == "usage":
|
|
367
370
|
from .limits import usage
|
|
368
371
|
return self.send_json(usage(force=qs.get("refresh", [""])[0] == "1"))
|
|
372
|
+
if route == "plan_history":
|
|
373
|
+
from .plan_history import history
|
|
374
|
+
return self.send_json(history())
|
|
369
375
|
if route.startswith(("free_models", "suggestions", "job/", "sync", "compare")):
|
|
370
376
|
return self.actions_get(route)
|
|
371
377
|
f = filters_from(qs)
|
|
@@ -385,6 +391,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
385
391
|
return self.send_json(a.burn(f))
|
|
386
392
|
if route == "timeline":
|
|
387
393
|
return self.send_json(a.timeline(f, g("grain", "day")))
|
|
394
|
+
if route == "heatmap":
|
|
395
|
+
return self.send_json(a.heatmap(f))
|
|
396
|
+
if route == "act_now":
|
|
397
|
+
from .act_now import act_now
|
|
398
|
+
return self.send_json(act_now(a))
|
|
388
399
|
if route == "models":
|
|
389
400
|
return self.send_json(a.models(f))
|
|
390
401
|
if route == "projects":
|
package/finops/etl.py
CHANGED
|
@@ -16,7 +16,7 @@ from datetime import datetime, timezone
|
|
|
16
16
|
from .classify import classify
|
|
17
17
|
from .pricing import Pricing
|
|
18
18
|
|
|
19
|
-
from .paths import ROOT, DB_PATH
|
|
19
|
+
from .paths import ROOT, DB_PATH, DESKTOP_SESSIONS
|
|
20
20
|
DEFAULT_SOURCE = os.path.expanduser("~/.claude/projects")
|
|
21
21
|
|
|
22
22
|
SCHEMA_VERSION = 3 # 3: cross-session request_id dedup (resumed sessions copy history)
|
|
@@ -203,8 +203,14 @@ def _slug_to_name(slug):
|
|
|
203
203
|
|
|
204
204
|
|
|
205
205
|
class Loader:
|
|
206
|
-
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):
|
|
207
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
|
|
208
214
|
self.db_path = db_path
|
|
209
215
|
self.source = source
|
|
210
216
|
self.pricing = pricing or Pricing()
|
|
@@ -232,13 +238,19 @@ class Loader:
|
|
|
232
238
|
if n.endswith(".jsonl"):
|
|
233
239
|
files.append(os.path.join(dirpath, n))
|
|
234
240
|
files.sort()
|
|
235
|
-
|
|
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):
|
|
236
246
|
if verbose and i % 20 == 0:
|
|
237
|
-
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)
|
|
238
249
|
try:
|
|
239
250
|
self.load_file(fp)
|
|
240
251
|
except Exception as exc: # a corrupt transcript must not kill the load
|
|
241
252
|
print(f" ! skipped {os.path.basename(fp)}: {exc}", file=sys.stderr)
|
|
253
|
+
self.cowork = False
|
|
242
254
|
if self.other_agents:
|
|
243
255
|
from .agents import AgentLoader
|
|
244
256
|
counts = AgentLoader(self, log=lambda m: print(m, file=sys.stderr)).run()
|
|
@@ -253,6 +265,7 @@ class Loader:
|
|
|
253
265
|
for k, v in (
|
|
254
266
|
("source_dir", self.source),
|
|
255
267
|
("transcript_files", str(len(files))),
|
|
268
|
+
("desktop_transcript_files", str(len(desktop))),
|
|
256
269
|
("pricing_updated", str(self.pricing.updated)),
|
|
257
270
|
("pricing_source", str(self.pricing.source)),
|
|
258
271
|
("cost_basis", "estimated"),
|
|
@@ -260,7 +273,7 @@ class Loader:
|
|
|
260
273
|
):
|
|
261
274
|
self.db.execute("INSERT INTO meta VALUES (?,?)", (k, v))
|
|
262
275
|
self.db.commit()
|
|
263
|
-
return len(files)
|
|
276
|
+
return len(files) + len(desktop)
|
|
264
277
|
|
|
265
278
|
def project_id(self, slug, cwd):
|
|
266
279
|
if slug in self.projects:
|
|
@@ -271,6 +284,8 @@ class Loader:
|
|
|
271
284
|
return pid
|
|
272
285
|
is_sandbox = 1 if ("sandbox" in slug or slug.startswith("-private")) else 0
|
|
273
286
|
name = os.path.basename(cwd) if cwd else _slug_to_name(slug)
|
|
287
|
+
if self.cowork:
|
|
288
|
+
name = f"Cowork · {name or 'session'}"
|
|
274
289
|
cur = self.db.execute(
|
|
275
290
|
"INSERT INTO projects (slug, path, name, is_sandbox) VALUES (?,?,?,?)",
|
|
276
291
|
(slug, cwd, name or slug, is_sandbox))
|
package/finops/integrate.py
CHANGED
|
@@ -12,6 +12,7 @@ fail silent, never block, never take long.
|
|
|
12
12
|
"""
|
|
13
13
|
import json
|
|
14
14
|
import os
|
|
15
|
+
import shlex
|
|
15
16
|
import shutil
|
|
16
17
|
import sys
|
|
17
18
|
|
|
@@ -59,14 +60,17 @@ def hook():
|
|
|
59
60
|
# -------------------------------------------------------------- statusline ----
|
|
60
61
|
|
|
61
62
|
def statusline():
|
|
62
|
-
"""One line, refreshed constantly: model and
|
|
63
|
+
"""One line, refreshed constantly: model, context usage and the 5-hour limit."""
|
|
63
64
|
try:
|
|
64
65
|
d = _stdin_json()
|
|
65
|
-
pct = _dig(d, "context.percentUsed", "context.percent_used")
|
|
66
|
+
pct = _dig(d, "context_window.used_percentage", "context.percentUsed", "context.percent_used")
|
|
67
|
+
five = _dig(d, "rate_limits.five_hour.used_percentage")
|
|
66
68
|
name = _dig(d, "model.display_name", "session.model", "model") or "claude"
|
|
67
69
|
bits = [str(name)]
|
|
68
70
|
if isinstance(pct, (int, float)):
|
|
69
71
|
bits.append(f"{pct:.0f}% ctx")
|
|
72
|
+
if isinstance(five, (int, float)):
|
|
73
|
+
bits.append(f"{five:.0f}% 5h")
|
|
70
74
|
print(" · ".join(bits))
|
|
71
75
|
except Exception:
|
|
72
76
|
print("") # an empty statusline beats a stack trace under the prompt
|
|
@@ -81,7 +85,8 @@ def _command():
|
|
|
81
85
|
if exe:
|
|
82
86
|
return exe
|
|
83
87
|
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
84
|
-
|
|
88
|
+
# settings run this through a shell: an install path with a space would split
|
|
89
|
+
return f"{shlex.quote(sys.executable)} {shlex.quote(os.path.join(root, 'run.py'))}"
|
|
85
90
|
|
|
86
91
|
|
|
87
92
|
def _load_settings():
|
|
@@ -113,21 +118,31 @@ def install_hook(remove=False):
|
|
|
113
118
|
print("The statusline (claude-finops --install-statusline) still shows model and context %.")
|
|
114
119
|
|
|
115
120
|
|
|
121
|
+
def statusline_state():
|
|
122
|
+
""""ours" when our statusline is installed, "other" for someone else's, else None."""
|
|
123
|
+
cur = _load_settings().get("statusLine")
|
|
124
|
+
if not cur:
|
|
125
|
+
return None
|
|
126
|
+
return "ours" if "finops" in json.dumps(cur) else "other"
|
|
127
|
+
|
|
128
|
+
|
|
116
129
|
def install_statusline(remove=False):
|
|
117
|
-
|
|
130
|
+
"""Wire the statusline into ~/.claude/settings.json. Returns {"ok", "message"}.
|
|
131
|
+
|
|
132
|
+
Claude Code wants {"type": "command", "command": ...}; an existing statusline that
|
|
133
|
+
isn't ours is left alone.
|
|
134
|
+
"""
|
|
118
135
|
s = _load_settings()
|
|
119
136
|
if remove:
|
|
120
|
-
if
|
|
137
|
+
if statusline_state() == "ours":
|
|
121
138
|
s.pop("statusLine", None)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
s["statusLine"] = cmd
|
|
139
|
+
_save_settings(s)
|
|
140
|
+
return {"ok": True, "message": f"Removed the statusline from {SETTINGS}"}
|
|
141
|
+
if statusline_state() == "other":
|
|
142
|
+
return {"ok": False, "message": "You already have a statusline configured "
|
|
143
|
+
f"({json.dumps(s['statusLine'])}). Left it alone: remove it first if you want ours."}
|
|
144
|
+
s["statusLine"] = {"type": "command", "command": f"{_command()} --statusline"}
|
|
129
145
|
_save_settings(s)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
print(" Undo: claude-finops --uninstall-statusline")
|
|
146
|
+
return {"ok": True, "message": f"Installed the statusline in {SETTINGS}. It shows the model, "
|
|
147
|
+
"context % and 5-hour limit % under every prompt. "
|
|
148
|
+
"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"]}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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
|
@@ -153,7 +153,7 @@ HELP = """Claude FinOps Command Center
|
|
|
153
153
|
claude-finops --version print the installed version, and whether a newer one is out
|
|
154
154
|
claude-finops --no-update-check skip the once-a-day npm version check
|
|
155
155
|
claude-finops --install-hook retired no-op (prints a message, changes nothing)
|
|
156
|
-
claude-finops --install-statusline show model
|
|
156
|
+
claude-finops --install-statusline show model, context % and 5-hour limit in your statusline
|
|
157
157
|
claude-finops --help this message
|
|
158
158
|
|
|
159
159
|
Environment:
|
|
@@ -296,7 +296,9 @@ def main():
|
|
|
296
296
|
return install_hook(remove="--uninstall-hook" in args)
|
|
297
297
|
if "--install-statusline" in args or "--uninstall-statusline" in args:
|
|
298
298
|
from finops.integrate import install_statusline
|
|
299
|
-
|
|
299
|
+
r = install_statusline(remove="--uninstall-statusline" in args)
|
|
300
|
+
print(r["message"])
|
|
301
|
+
return sys.exit(0 if r["ok"] else 1)
|
|
300
302
|
if "--where" in args:
|
|
301
303
|
return where()
|
|
302
304
|
if "--keys" in args:
|
package/web/app.js
CHANGED
|
@@ -73,6 +73,13 @@ const dur = s => s == null ? '—' : s < 60 ? `${Math.round(s)}s`
|
|
|
73
73
|
const shortDay = d => (d || '').slice(5);
|
|
74
74
|
const shortId = s => (s || '').slice(0, 8);
|
|
75
75
|
|
|
76
|
+
// "(▲12% vs yesterday)"; nothing when yesterday had no spend to compare against.
|
|
77
|
+
const vsYesterday = o => {
|
|
78
|
+
const y = o.cost_yesterday?.c, t = o.cost_today?.c;
|
|
79
|
+
if (!y || t == null) return '';
|
|
80
|
+
const d = (t - y) / y * 100;
|
|
81
|
+
return ` (${d >= 0 ? '▲' : '▼'}${Math.abs(d).toFixed(0)}% vs yesterday)`;
|
|
82
|
+
};
|
|
76
83
|
function kpi(label, value, detail, opts = {}) {
|
|
77
84
|
const na = value == null;
|
|
78
85
|
return `<div class="kpi${na ? ' na' : ''}">
|
|
@@ -452,6 +459,8 @@ async function openSession(id) {
|
|
|
452
459
|
: '<div class="na">None</div>')}
|
|
453
460
|
</div>
|
|
454
461
|
${card('Session metadata', `<dl class="kv">
|
|
462
|
+
${s.resume ? `<dt>Resume</dt><dd><span class="mono">${esc(s.resume)}</span>
|
|
463
|
+
<button class="btn pb-copy" id="sess-resume" data-copy="${esc(s.resume)}">Copy</button></dd>` : ''}
|
|
455
464
|
<dt>Session ID</dt><dd class="mono">${esc(s.id)}</dd>
|
|
456
465
|
<dt>Title</dt><dd>${s.title ? esc(s.title) : NA()}</dd>
|
|
457
466
|
<dt>Project</dt><dd>${esc(s.project)}</dd>
|
|
@@ -463,6 +472,10 @@ async function openSession(id) {
|
|
|
463
472
|
<dt>Commits / PRs</dt><dd>${NA()}</dd>
|
|
464
473
|
</dl>`)}`;
|
|
465
474
|
wireTable(cont, s.prompts, r => openPrompt(r.prompt_id));
|
|
475
|
+
const rb = cont.querySelector('#sess-resume');
|
|
476
|
+
if (rb) rb.onclick = async () => {
|
|
477
|
+
try { await navigator.clipboard.writeText(rb.dataset.copy); rb.textContent = 'Copied'; }
|
|
478
|
+
catch { rb.textContent = 'Copy failed'; } };
|
|
466
479
|
const tl = s.timeline.map((r, i) => ({i, ...r}));
|
|
467
480
|
C.timeSeries($('#sesschart', cont), {rows: tl, x: 'i', type: 'area',
|
|
468
481
|
series: [{key: 'context_tokens', label: 'Context tokens', color: seriesVar(0)}],
|
|
@@ -500,7 +513,7 @@ VIEWS.overview = async (page) => {
|
|
|
500
513
|
page.innerHTML = `
|
|
501
514
|
<div class="grid g5">
|
|
502
515
|
${kpi('Estimated spend', fmtUSD(o.est_cost_usd),
|
|
503
|
-
`${fmtUSD(o.cost_today.c)} today · ${fmtUSD(o.cost_week.c)} last 7d`, {badge: BADGE.estimated})}
|
|
516
|
+
`${fmtUSD(o.cost_today.c)} today${vsYesterday(o)} · ${fmtUSD(o.cost_week.c)} last 7d`, {badge: BADGE.estimated})}
|
|
504
517
|
${kpi('Billable tokens', fmtNum(o.billable_tokens),
|
|
505
518
|
`${fmtNum(o.output_tokens)} output · ${fmtNum(o.cache_read_tokens)} cache read`,
|
|
506
519
|
{badge: BADGE.actual})}
|
|
@@ -672,10 +685,110 @@ VIEWS.overview = async (page) => {
|
|
|
672
685
|
api('diagnose').then(d => {
|
|
673
686
|
if (!page.isConnected) return;
|
|
674
687
|
const el = h(focusStrip(d) || '<div></div>');
|
|
675
|
-
page.insertBefore(el, page.firstChild);
|
|
688
|
+
page.insertBefore(el, page.querySelector('.actnow')?.nextSibling || page.firstChild);
|
|
689
|
+
page._focusData = d;
|
|
676
690
|
wireFocus(page);
|
|
677
691
|
}).catch(() => {});
|
|
692
|
+
if (hasClaude()) fetch('/api/act_now').then(r => r.json()).then(d => {
|
|
693
|
+
if (!page.isConnected || !(d.items || []).length) return;
|
|
694
|
+
const el = h(actNowStrip(d.items));
|
|
695
|
+
page.insertBefore(el, page.firstChild);
|
|
696
|
+
wireActNow(el, d.items);
|
|
697
|
+
}).catch(() => {});
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
/* ---------- act now: one-click actions, on the page everyone opens first ---------- */
|
|
701
|
+
const ACT_ICON = {session: '🗜', skill: '⚙', memory: '📝', statusline: '▭'};
|
|
702
|
+
const codeTicks = t => esc(t).replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
703
|
+
function actNowStrip(items) {
|
|
704
|
+
const btn = it => it.kind === 'session'
|
|
705
|
+
? '<button class="act" data-do="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>'
|
|
706
|
+
+ '<button class="act ghost" data-do="handover">⇢ Hand over</button>'
|
|
707
|
+
: it.kind === 'skill' ? '<button class="act" data-do="skill">+ Create skill</button>'
|
|
708
|
+
: it.kind === 'memory' ? '<button class="act ghost" data-do="memory">Show examples</button>'
|
|
709
|
+
: '<button class="act" data-do="statusline">Install statusline</button>';
|
|
710
|
+
return `<section class="actnow"><div class="focus-hd">⚡ Act now
|
|
711
|
+
<span class="note">one click each · nothing changes until you click</span></div>
|
|
712
|
+
${items.map((it, i) => `<div class="actnow-it" data-i="${i}">
|
|
713
|
+
<span class="ai">${ACT_ICON[it.kind] || '•'}</span>
|
|
714
|
+
<div class="ft"><b>${codeTicks(it.title)}</b><div class="note">${esc(it.detail)}</div></div>
|
|
715
|
+
<div class="live-actions">${btn(it)}<span class="live-msg"></span></div></div>`).join('')}</section>`;
|
|
716
|
+
}
|
|
717
|
+
function wireActNow(root, items) {
|
|
718
|
+
root.querySelectorAll('.actnow-it').forEach(row => {
|
|
719
|
+
const it = items[+row.dataset.i], msg = row.querySelector('.live-msg');
|
|
720
|
+
const say = (t, ok) => { msg.textContent = t; msg.className = 'live-msg ' + (ok ? 'ok' : 'err'); };
|
|
721
|
+
row.querySelectorAll('[data-do]').forEach(b => b.onclick = async () => {
|
|
722
|
+
const what = b.dataset.do;
|
|
723
|
+
if (what === 'handover') return go('live');
|
|
724
|
+
if (what === 'memory') return jumpTo('diagnose', 'dx-mem');
|
|
725
|
+
if (what === 'compact' && !armed(b)) return;
|
|
726
|
+
b.disabled = true;
|
|
727
|
+
try {
|
|
728
|
+
if (what === 'compact') {
|
|
729
|
+
const r = await sessionAction(it.pid, 'compact', {agent: 'claude'});
|
|
730
|
+
if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
|
|
731
|
+
say(r.ok ? r.message : `Failed: ${r.error}`, r.ok);
|
|
732
|
+
b.disabled = false; return;
|
|
733
|
+
}
|
|
734
|
+
const r = what === 'skill' ? await doAction('skill', it.skill) : await doAction('statusline');
|
|
735
|
+
say(what === 'skill' ? `Created ${r.path}. Use it with ${r.use}.` : r.message, r.ok !== false);
|
|
736
|
+
if (r.ok !== false) b.remove(); else b.disabled = false;
|
|
737
|
+
} catch (e) { say('Failed: ' + e.message, false); b.disabled = false; }
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
// Two-click confirm, inline (no browser dialogs): the first click arms, the second runs.
|
|
742
|
+
function armed(b) {
|
|
743
|
+
if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = b.dataset.label; return true; }
|
|
744
|
+
b.dataset.label = b.textContent; b.classList.add('armed'); b.textContent = 'Click again to confirm';
|
|
745
|
+
setTimeout(() => { if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = b.dataset.label; } }, 4000);
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
// Open a view and scroll to an element that appears once the (possibly slow) view renders.
|
|
749
|
+
async function jumpTo(view, anchor) {
|
|
750
|
+
if (S.view !== view) go(view);
|
|
751
|
+
for (let i = 0; i < 60; i++) {
|
|
752
|
+
const el = document.getElementById(anchor);
|
|
753
|
+
if (el) { el.scrollIntoView({behavior: 'smooth', block: 'start'}); el.classList.add('flash');
|
|
754
|
+
setTimeout(() => el.classList.remove('flash'), 1600); return; }
|
|
755
|
+
await new Promise(r => setTimeout(r, 250));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/* ---------- row actions: Resume (copy) and, for a running session, Compact ---------- */
|
|
760
|
+
async function liveSessions() {
|
|
761
|
+
try {
|
|
762
|
+
const d = await fetch('/api/live?agents=claude').then(r => r.json());
|
|
763
|
+
return new Map((d.sessions || []).filter(x => x.signalable && x.pid).map(x => [x.session_id, x.pid]));
|
|
764
|
+
} catch { return new Map(); }
|
|
765
|
+
}
|
|
766
|
+
const rowActs = (r, live) => {
|
|
767
|
+
const pid = live && live.get(r.session_id);
|
|
768
|
+
return `<span class="row-acts">${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}"
|
|
769
|
+
title="Copy: ${esc(r.resume)}">⧉ Resume</button>` : ''}${pid ? ` <button class="act" data-compact="${pid}"
|
|
770
|
+
title="Running now: types /compact into its terminal">🗜 Compact</button>` : ''}</span>`;
|
|
678
771
|
};
|
|
772
|
+
function wireRowActs(host) {
|
|
773
|
+
if (!host) return;
|
|
774
|
+
host.querySelectorAll('[data-resume]').forEach(b => b.onclick = async e => {
|
|
775
|
+
e.stopPropagation();
|
|
776
|
+
try { await navigator.clipboard.writeText(b.dataset.resume); b.textContent = '✓ Copied'; }
|
|
777
|
+
catch { b.textContent = b.dataset.resume; }
|
|
778
|
+
setTimeout(() => { b.textContent = '⧉ Resume'; }, 1800);
|
|
779
|
+
});
|
|
780
|
+
host.querySelectorAll('[data-compact]').forEach(b => b.onclick = async e => {
|
|
781
|
+
e.stopPropagation();
|
|
782
|
+
if (!armed(b)) return;
|
|
783
|
+
b.disabled = true;
|
|
784
|
+
try {
|
|
785
|
+
const r = await sessionAction(+b.dataset.compact, 'compact', {agent: 'claude'});
|
|
786
|
+
if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
|
|
787
|
+
b.textContent = r.ok ? '✓ Sent' : 'Failed'; b.title = r.ok ? r.message : r.error;
|
|
788
|
+
} catch (err) { b.textContent = 'Failed'; b.title = err.message; }
|
|
789
|
+
setTimeout(() => { b.disabled = false; b.textContent = '🗜 Compact'; }, 2500);
|
|
790
|
+
});
|
|
791
|
+
}
|
|
679
792
|
|
|
680
793
|
function renderAdvisorHero(page, advisor) {
|
|
681
794
|
if (!page || !advisor || !Array.isArray(advisor.actions)) return;
|
|
@@ -763,9 +876,10 @@ const METRICS = [
|
|
|
763
876
|
['avg_context', 'Avg context', fmtNum],
|
|
764
877
|
];
|
|
765
878
|
VIEWS.usage = async (page) => {
|
|
766
|
-
const [tl, models, ov] = await Promise.all([api('timeline', `&grain=${S.grain}`),
|
|
767
|
-
api('models'), api('overview')]);
|
|
879
|
+
const [tl, models, ov, hm] = await Promise.all([api('timeline', `&grain=${S.grain}`),
|
|
880
|
+
api('models'), api('overview'), api('heatmap')]);
|
|
768
881
|
const [, mlabel, mfmt] = METRICS.find(m => m[0] === S.metric) || METRICS[0];
|
|
882
|
+
const hmMetric = ['cost', 'tokens', 'requests'].includes(S.metric) ? S.metric : 'cost';
|
|
769
883
|
page.innerHTML = `
|
|
770
884
|
<div class="grid g5">
|
|
771
885
|
${kpi('Total tokens', fmtNum(ov.billable_tokens), null, {badge: BADGE.actual})}
|
|
@@ -793,6 +907,9 @@ VIEWS.usage = async (page) => {
|
|
|
793
907
|
footer: 'Days are bucketed in UTC, as Claude Code timestamps its transcripts. '
|
|
794
908
|
+ 'If you work late in a timezone ahead of UTC, that work lands on the previous day here.',
|
|
795
909
|
flush: 0})}
|
|
910
|
+
${card('Peak hours', '<div class="chart" id="heat"></div>',
|
|
911
|
+
{badge: hmMetric === 'cost' ? BADGE.estimated : BADGE.actual,
|
|
912
|
+
hint: `your local time (${hm.tz || 'local'}) · ${hmMetric === 'cost' ? 'estimated cost' : hmMetric}`})}
|
|
796
913
|
${card('Model mix over time', '<div class="legend" id="mixleg"></div>' +
|
|
797
914
|
'<div class="chart" id="mix"></div>', {badge: BADGE.estimated,
|
|
798
915
|
hint: 'stacked estimated cost per model'})}
|
|
@@ -810,6 +927,7 @@ VIEWS.usage = async (page) => {
|
|
|
810
927
|
xLabel: v => S.grain === 'day' ? shortDay(v) : v.slice(8).replace('T', ' ') + ':00',
|
|
811
928
|
onClick: r => { if (S.grain === 'day') { S.filter.start = r.bucket; S.filter.end = r.bucket;
|
|
812
929
|
S.range = 'custom'; bust(); render(); } }});
|
|
930
|
+
C.heatmap($('#heat', page), {cells: hm.cells, metric: hmMetric});
|
|
813
931
|
|
|
814
932
|
// per-model stacked mix
|
|
815
933
|
const byDay = new Map();
|
|
@@ -849,7 +967,8 @@ VIEWS.usage = async (page) => {
|
|
|
849
967
|
|
|
850
968
|
/* ---------- burn & limits ---------- */
|
|
851
969
|
VIEWS.burn = async (page) => {
|
|
852
|
-
const burn = await api('burn')
|
|
970
|
+
const [burn, plan] = await Promise.all([api('burn'), fetch('/api/plan_history')
|
|
971
|
+
.then(r => r.json()).catch(e => ({ok: false, reason: e.message}))]);
|
|
853
972
|
const bp = burn.period;
|
|
854
973
|
const rows = Object.entries(burn.allowances);
|
|
855
974
|
page.innerHTML = `
|
|
@@ -901,10 +1020,31 @@ VIEWS.burn = async (page) => {
|
|
|
901
1020
|
<span class="mono">config/settings.json → limits</span> to enable usage-vs-limit,
|
|
902
1021
|
days-until-limit and limit-date projections.</p></div>`,
|
|
903
1022
|
{badge: a.configured ? BADGE.estimated : ''})).join('')}</div>
|
|
1023
|
+
${card('Plan limits over time', plan.ok ? `
|
|
1024
|
+
<div class="grid g3">
|
|
1025
|
+
${kpi('5-hour peak', fmtPct(plan.summary.five_hour_peak ?? 0, 0), null, {badge: BADGE.actual})}
|
|
1026
|
+
${kpi('Times at 90%+ (5-hour)', fmtInt(plan.summary.five_hour_ge90),
|
|
1027
|
+
`${fmtInt(plan.summary.five_hour_hit100)} reached 100%`)}
|
|
1028
|
+
${kpi('Weekly peak', fmtPct(plan.summary.weekly_peak ?? 0, 0),
|
|
1029
|
+
`${fmtInt(plan.summary.weekly_ge90)} times at 90%+`)}
|
|
1030
|
+
</div>
|
|
1031
|
+
<div class="legend" id="planleg"></div><div class="chart" id="plan"></div>`
|
|
1032
|
+
: `<div class="empty">${esc(plan.reason || 'Unavailable')}</div>`,
|
|
1033
|
+
{badge: BADGE.actual, hint: '5-hour and weekly plan usage, % used',
|
|
1034
|
+
footer: 'Read from the Claude desktop app\'s local plan-usage-history.json. '
|
|
1035
|
+
+ 'Its format is undocumented, so this card may go blank after an app update.'})}
|
|
904
1036
|
${card('Daily consumption within the billing period', '<div class="chart" id="bs"></div>',
|
|
905
1037
|
{badge: BADGE.estimated})}`;
|
|
906
1038
|
for (const [k, a] of rows) if (a.configured)
|
|
907
1039
|
C.gauge($(`#g-${k}`, page), {pct: a.used_pct, status: a.status, label: 'of allowance'});
|
|
1040
|
+
if (plan.ok) {
|
|
1041
|
+
const ps = [{key: 'five_hour', label: '5-hour window', color: seriesVar(0), fmt: v => fmtPct(v, 0)},
|
|
1042
|
+
{key: 'weekly', label: 'Weekly window', color: seriesVar(1), fmt: v => fmtPct(v, 0)}];
|
|
1043
|
+
C.legend($('#planleg', page), ps.map(s => ({label: s.label, color: s.color})));
|
|
1044
|
+
C.timeSeries($('#plan', page), {rows: plan.series, x: 't', type: 'line', series: ps,
|
|
1045
|
+
fmt: v => fmtPct(v, 0), max: 100, height: 220,
|
|
1046
|
+
xLabel: t => new Date(t).toLocaleString([], {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'})});
|
|
1047
|
+
}
|
|
908
1048
|
C.timeSeries($('#bs', page), {rows: burn.series, x: 'day', type: 'bar',
|
|
909
1049
|
series: [{key: 'cost', label: 'Estimated cost', color: seriesVar(0), fmt: fmtUSD}],
|
|
910
1050
|
fmt: fmtUSD, height: 220, xLabel: shortDay});
|
|
@@ -1036,7 +1176,7 @@ VIEWS.sessions = async (page) => {
|
|
|
1036
1176
|
const sp = S.sessPage;
|
|
1037
1177
|
const res = await api('sessions', `&limit=${sp.offset + sp.limit}&order=${order}`);
|
|
1038
1178
|
const rows = res.rows, total = res.total;
|
|
1039
|
-
const eff = await api('efficiency');
|
|
1179
|
+
const [eff, live] = await Promise.all([api('efficiency'), liveSessions()]);
|
|
1040
1180
|
const avgTok = rows.length ? rows.reduce((a, r) => a + r.tokens, 0) / rows.length : 0;
|
|
1041
1181
|
page.innerHTML = `
|
|
1042
1182
|
<div class="grid g4">
|
|
@@ -1081,6 +1221,7 @@ VIEWS.sessions = async (page) => {
|
|
|
1081
1221
|
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
1082
1222
|
f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(r.title || shortId(r.session_id))}
|
|
1083
1223
|
<div class="sub mono">${esc(shortId(r.session_id))}</div>`},
|
|
1224
|
+
{h: '', f: r => rowActs(r, live)},
|
|
1084
1225
|
{h: 'Project', f: r => esc(r.project)},
|
|
1085
1226
|
{h: 'Branch', f: r => r.git_branch ? `<span class="mono">${esc(r.git_branch)}</span>` : '—'},
|
|
1086
1227
|
{h: 'Start', f: r => `<span class="mono">${esc((r.started_at || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
@@ -1101,6 +1242,7 @@ VIEWS.sessions = async (page) => {
|
|
|
1101
1242
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1102
1243
|
], rows, {onRow: 1});
|
|
1103
1244
|
wireTable($('#st', page), rows, r => openSession(r.session_id));
|
|
1245
|
+
wireRowActs($('#st', page));
|
|
1104
1246
|
const effCards = [...page.querySelectorAll('.card')].filter(c =>
|
|
1105
1247
|
/output yield/.test(c.querySelector('h3')?.textContent || ''));
|
|
1106
1248
|
wireTable(effCards[0], eff.low_efficiency_sessions, r => openSession(r.session_id));
|
|
@@ -1179,7 +1321,7 @@ const RANK_TABS = [
|
|
|
1179
1321
|
['cheapest', 'Top 20 cheapest'], ['most_efficient', 'Top 20 most efficient'],
|
|
1180
1322
|
['longest_sessions', 'Top 20 longest sessions']];
|
|
1181
1323
|
VIEWS.rankings = async (page) => {
|
|
1182
|
-
const lb = await api('leaderboards', '&n=20');
|
|
1324
|
+
const [lb, live] = await Promise.all([api('leaderboards', '&n=20'), liveSessions()]);
|
|
1183
1325
|
const tab = S.rankTab || 'most_expensive';
|
|
1184
1326
|
const isSess = tab === 'longest_sessions';
|
|
1185
1327
|
const rows = lb[tab];
|
|
@@ -1193,6 +1335,7 @@ VIEWS.rankings = async (page) => {
|
|
|
1193
1335
|
const cols = isSess ? [
|
|
1194
1336
|
{h: '#', num: 1, f: r => rows.indexOf(r) + 1},
|
|
1195
1337
|
{h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
|
|
1338
|
+
{h: '', f: r => rowActs(r, live)},
|
|
1196
1339
|
{h: 'Project', f: r => esc(r.project)},
|
|
1197
1340
|
{h: 'Duration', num: 1, f: r => dur(r.duration_s)},
|
|
1198
1341
|
{h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
|
|
@@ -1211,10 +1354,12 @@ VIEWS.rankings = async (page) => {
|
|
|
1211
1354
|
{h: 'Tok eff', num: 1, f: r => fmtPct(r.efficiency * 100, 2)},
|
|
1212
1355
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
|
|
1213
1356
|
{h: 'Session', trunc: 1, f: r => esc(r.session_title || shortId(r.session_id))},
|
|
1357
|
+
{h: '', f: r => rowActs(r, live)},
|
|
1214
1358
|
{h: 'Date', f: r => esc(r.day)},
|
|
1215
1359
|
];
|
|
1216
1360
|
$('#rt', page).innerHTML = table(cols, rows, {onRow: 1});
|
|
1217
1361
|
wireTable($('#rt', page), rows, r => isSess ? openSession(r.session_id) : openPrompt(r.prompt_id));
|
|
1362
|
+
wireRowActs($('#rt', page));
|
|
1218
1363
|
addChart(page, 'Top 12 prompts by estimated cost', el => C.barsH(el, {
|
|
1219
1364
|
rows: (lb.most_expensive || []).slice(0, 12), label: r => clip(r.preview, 46), value: r => r.pcost,
|
|
1220
1365
|
sub: r => `<div class="row"><span class="k">Project</span><span class="v">${esc(r.project)}</span></div>
|
|
@@ -2257,45 +2402,81 @@ VIEWS.exports = async (page) => {
|
|
|
2257
2402
|
/* ============================ focus cues ============================ */
|
|
2258
2403
|
// "What needs me first": one ranked list built from the diagnosis, used for the nav
|
|
2259
2404
|
// badges and the Focus strip at the top of the overview and diagnosis pages.
|
|
2405
|
+
// "What to change": things to fix once and habits to change, each with its reason.
|
|
2406
|
+
// Running sessions are left to ⚡ Act now, which can act on them. Items are keyed so a
|
|
2407
|
+
// dismissed one comes back only if its evidence changes (e.g. more sessions affected).
|
|
2408
|
+
const firstSentence = t => { const m = String(t || '').match(/^.*?[.!?](\s|$)/); return (m ? m[0] : String(t || '')).trim(); };
|
|
2409
|
+
// "29.7% of X." + "35.6% of X." -> "29.7–35.6% of X." plus the shared advice; null when they differ.
|
|
2410
|
+
function sameFix(fixes) {
|
|
2411
|
+
const pct = /\d+(?:\.\d+)?%/, heads = fixes.map(firstSentence);
|
|
2412
|
+
const shape = heads.map(t => t.replace(pct, '#'));
|
|
2413
|
+
if (!shape.every(t => t === shape[0]) || !pct.test(heads[0])) return null;
|
|
2414
|
+
const nums = heads.map(t => parseFloat(t.match(pct)[0]));
|
|
2415
|
+
const range = `${Math.min(...nums)}–${Math.max(...nums)}%`;
|
|
2416
|
+
const rest = fixes[0].slice(heads[0].length).trim().replace(/\bthis repo\b/g, 'each repo');
|
|
2417
|
+
return `${shape[0].replace('#', range).replace(' here ', ' in these repos ')} ${rest}`.trim();
|
|
2418
|
+
}
|
|
2260
2419
|
function focusItems(d) {
|
|
2261
2420
|
const out = [];
|
|
2262
|
-
for (const x of d.live_sessions || []) if (x.severity !== 'ok')
|
|
2263
|
-
out.push({lvl: x.severity === 'high' ? 1 : 2, text: `Running session at ${fmtNum(x.context)} context: ${x.title || shortId(x.session_id)}`, view: 'diagnose', anchor: 'dx-live'});
|
|
2264
2421
|
for (const m of d.memory_suggestions || []) if (m.kind === 'security')
|
|
2265
|
-
out.push({lvl: 1,
|
|
2422
|
+
out.push({lvl: 1, kind: 'fix', key: `sec:${m.sessions}`, view: 'diagnose', anchor: 'dx-mem',
|
|
2423
|
+
text: `Rotate the credentials you pasted into prompts (${m.sessions} session${m.sessions === 1 ? '' : 's'})`,
|
|
2424
|
+
why: 'Closing the sessions does not help: the secrets stay in plain text in those transcripts on disk.'});
|
|
2425
|
+
const byIssue = (sev, lvl) => {
|
|
2426
|
+
const groups = new Map();
|
|
2427
|
+
for (const p of d.projects || []) for (const i of p.issues) if (i.severity === sev) {
|
|
2428
|
+
if (!groups.has(i.title)) groups.set(i.title, []);
|
|
2429
|
+
groups.get(i.title).push({project: p.name, fix: i.fix});
|
|
2430
|
+
}
|
|
2431
|
+
for (const [title, ps] of groups) out.push({lvl, kind: 'fix', view: 'diagnose', anchor: 'dx-issues',
|
|
2432
|
+
key: `proj:${title}:${ps.map(x => x.project).sort().join(',')}`,
|
|
2433
|
+
text: ps.length === 1 ? `${ps[0].project}: ${title}` : `${title} in ${ps.length} projects: ${ps.map(x => x.project).join(', ')}`,
|
|
2434
|
+
why: ps.length === 1 ? ps[0].fix : sameFix(ps.map(x => x.fix))
|
|
2435
|
+
|| ps.slice(0, 3).map(x => `${x.project}: ${firstSentence(x.fix)}`).join(' · ')});
|
|
2436
|
+
};
|
|
2437
|
+
byIssue('high', 1);
|
|
2266
2438
|
for (const r of d.recommendations || []) if (r.priority === 1)
|
|
2267
|
-
out.push({lvl:
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
const s0 = (d.session_health || [])[0];
|
|
2271
|
-
if (s0) out.push({lvl: 2, text: `Heaviest session: ${s0.title || shortId(s0.session_id)} (peak ${fmtNum(s0.peak)})`, view: 'diagnose', anchor: 'dx-past'});
|
|
2272
|
-
for (const p of d.projects || []) for (const i of p.issues) if (i.severity === 'medium')
|
|
2273
|
-
out.push({lvl: 2, text: `${p.name}: ${i.title}`, view: 'diagnose', anchor: 'dx-issues'});
|
|
2439
|
+
out.push({lvl: 2, kind: 'habit', key: `rec:${r.title}`, view: 'diagnose', anchor: 'dx-recs',
|
|
2440
|
+
text: r.title, why: `${firstSentence(r.why)} ${firstSentence(r.how)}`});
|
|
2441
|
+
byIssue('medium', 3);
|
|
2274
2442
|
return out.sort((a, b) => a.lvl - b.lvl);
|
|
2275
2443
|
}
|
|
2444
|
+
const FOCUS_KEY = 'finops.focus.hidden';
|
|
2445
|
+
const focusHidden = () => { try { return new Set(JSON.parse(localStorage.getItem(FOCUS_KEY) || '[]')); } catch { return new Set(); } };
|
|
2446
|
+
const setFocusHidden = set => { try { localStorage.setItem(FOCUS_KEY, JSON.stringify([...set])); } catch {} };
|
|
2276
2447
|
function focusStrip(d, n = 5) {
|
|
2277
|
-
const
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
<span class="
|
|
2448
|
+
const all = focusItems(d), hidden = focusHidden();
|
|
2449
|
+
const items = all.filter(i => !hidden.has(i.key));
|
|
2450
|
+
const nHidden = all.length - items.length;
|
|
2451
|
+
if (!all.length) return '';
|
|
2452
|
+
const fixes = items.filter(i => i.kind === 'fix').length, habits = items.filter(i => i.kind === 'habit').length;
|
|
2453
|
+
return `<section class="focus"><div class="focus-hd">🎯 What to change
|
|
2454
|
+
<span class="note">${fixes} to fix once · ${habits} habit${habits === 1 ? '' : 's'} from your past sessions</span>
|
|
2455
|
+
<span class="spacer"></span>${nHidden ? `<button class="act ghost" data-unhide="1">Show ${nHidden} hidden</button>` : ''}</div>
|
|
2456
|
+
${items.slice(0, n).map((it, k) => `<div class="focus-it l${it.lvl === 1 ? 1 : 2}" data-go="${it.view}" data-anchor="${it.anchor}" data-key="${esc(it.key)}">
|
|
2457
|
+
<span class="fn">${k + 1}</span>
|
|
2458
|
+
<span class="ft"><span class="fx">${esc(it.text)}</span><span class="fw">${esc(it.why || '')}</span></span>
|
|
2459
|
+
<span class="fl">${it.kind === 'habit' ? 'Habit' : 'Fix once'}</span>
|
|
2460
|
+
<button class="fd" title="Hide this until its evidence changes" aria-label="Hide">✕</button></div>`).join('')
|
|
2461
|
+
|| '<div class="note">Everything here is hidden. Nice.</div>'}</section>`;
|
|
2284
2462
|
}
|
|
2285
2463
|
function wireFocus(root) {
|
|
2286
|
-
root.
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2464
|
+
const redraw = () => { const old = root.querySelector('.focus'); if (!old || !root._focusData) return;
|
|
2465
|
+
const el = h(focusStrip(root._focusData) || '<div></div>'); old.replaceWith(el); wireFocus(root); };
|
|
2466
|
+
root.querySelectorAll('.focus-it').forEach(a => {
|
|
2467
|
+
a.onclick = () => jumpTo(a.dataset.go, a.dataset.anchor);
|
|
2468
|
+
a.querySelector('.fd').onclick = e => { e.stopPropagation();
|
|
2469
|
+
const hid = focusHidden(); hid.add(a.dataset.key); setFocusHidden(hid); redraw(); };
|
|
2291
2470
|
});
|
|
2471
|
+
const un = root.querySelector('[data-unhide]');
|
|
2472
|
+
if (un) un.onclick = () => { setFocusHidden(new Set()); redraw(); };
|
|
2292
2473
|
}
|
|
2293
2474
|
async function updateNavBadges() {
|
|
2294
2475
|
const set = (id, n, lvl) => { const el = document.querySelector(`[data-nb="${id}"]`);
|
|
2295
2476
|
if (el) { el.textContent = n ? n : ''; el.className = 'nb' + (n ? ' l' + lvl : ''); } };
|
|
2296
2477
|
try {
|
|
2297
2478
|
const [d, w, a] = await Promise.all([hasPriced() ? api('diagnose') : null, api('waste'), api('anomalies')]);
|
|
2298
|
-
if (d) { const f = focusItems(d), urgent = f.filter(i => i.lvl === 1).length;
|
|
2479
|
+
if (d) { const hid = focusHidden(), f = focusItems(d).filter(i => !hid.has(i.key)), urgent = f.filter(i => i.lvl === 1).length;
|
|
2299
2480
|
set('diagnose', urgent || f.length, urgent ? 1 : 2); }
|
|
2300
2481
|
const hw = w.findings.filter(x => x.severity === 'high').length;
|
|
2301
2482
|
set('waste', hw || w.findings.length, hw ? 1 : 2);
|
package/web/charts.js
CHANGED
|
@@ -70,7 +70,7 @@ export function timeSeries(host, opts) {
|
|
|
70
70
|
const totals = rows.map(r => stacked
|
|
71
71
|
? active.reduce((a, s) => a + (+r[s.key] || 0), 0)
|
|
72
72
|
: Math.max(...active.map(s => +r[s.key] || 0), 0));
|
|
73
|
-
const max = nice(Math.max(...totals, 0) || 1);
|
|
73
|
+
const max = Math.max(opts.max || 0, nice(Math.max(...totals, 0) || 1));
|
|
74
74
|
const y = v => m.t + ih - (v / max) * ih;
|
|
75
75
|
const n = rows.length;
|
|
76
76
|
const bw = Math.max(2, Math.min(34, iw / n * 0.72));
|
|
@@ -349,3 +349,36 @@ export function legend(host, items, onToggle) {
|
|
|
349
349
|
host.appendChild(s);
|
|
350
350
|
});
|
|
351
351
|
}
|
|
352
|
+
|
|
353
|
+
/* ---------- heatmap: weekday x hour ---------- */
|
|
354
|
+
const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
355
|
+
export function heatmap(host, {cells, metric = 'cost'}) {
|
|
356
|
+
host.innerHTML = '';
|
|
357
|
+
const max = Math.max(0, ...cells.map(c => +c[metric] || 0));
|
|
358
|
+
if (!max) { host.innerHTML = '<div class="empty">No activity in range</div>'; return; }
|
|
359
|
+
const W = Math.max(host.clientWidth || 640, 320);
|
|
360
|
+
const m = {t: 4, r: 4, b: 18, l: 34};
|
|
361
|
+
const cw = (W - m.l - m.r) / 24, ch = Math.min(22, Math.max(12, cw * 0.8));
|
|
362
|
+
const H = m.t + ch * 7 + m.b;
|
|
363
|
+
const svg = el('svg', {viewBox: `0 0 ${W} ${H}`, height: H, role: 'img'});
|
|
364
|
+
DOW.forEach((d, i) => svg.appendChild(el('text', {x: m.l - 6, y: m.t + i * ch + ch * 0.68,
|
|
365
|
+
'text-anchor': 'end'}, d)));
|
|
366
|
+
for (let h = 0; h < 24; h += 3) svg.appendChild(el('text', {x: m.l + h * cw + cw / 2,
|
|
367
|
+
y: H - 5, 'text-anchor': 'middle'}, String(h).padStart(2, '0')));
|
|
368
|
+
cells.forEach(c => {
|
|
369
|
+
const v = +c[metric] || 0;
|
|
370
|
+
const r = el('rect', {x: m.l + c.hour * cw + 1, y: m.t + c.dow * ch + 1,
|
|
371
|
+
width: Math.max(1, cw - 2), height: Math.max(1, ch - 2), rx: 2,
|
|
372
|
+
fill: v ? 'var(--s1)' : 'var(--grid)',
|
|
373
|
+
'fill-opacity': v ? (0.15 + 0.85 * Math.sqrt(v / max)).toFixed(3) : 1});
|
|
374
|
+
r.addEventListener('mousemove', ev => showTip(
|
|
375
|
+
`<div class="t">${DOW[c.dow]} ${String(c.hour).padStart(2, '0')}:00</div>
|
|
376
|
+
<div class="row"><span class="k">Est. cost</span><span class="v">${fmtUSD(c.cost)}</span></div>
|
|
377
|
+
<div class="row"><span class="k">Tokens</span><span class="v">${fmtNum(c.tokens)}</span></div>
|
|
378
|
+
<div class="row"><span class="k">Requests</span><span class="v">${fmtInt(c.requests)}</span></div>`,
|
|
379
|
+
ev.clientX, ev.clientY));
|
|
380
|
+
r.addEventListener('mouseleave', hideTip);
|
|
381
|
+
svg.appendChild(r);
|
|
382
|
+
});
|
|
383
|
+
host.appendChild(svg);
|
|
384
|
+
}
|
package/web/styles.css
CHANGED
|
@@ -331,6 +331,17 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
331
331
|
.nav a .nb.l1 { background: var(--critical); color: #fff; }
|
|
332
332
|
.nav a .nb.l2 { background: var(--warning); color: #3a2a00; }
|
|
333
333
|
|
|
334
|
+
.actnow { border: 1px solid color-mix(in srgb, var(--s1) 40%, var(--border));
|
|
335
|
+
background: color-mix(in srgb, var(--s1) 5%, var(--surface)); border-radius: var(--radius);
|
|
336
|
+
padding: 10px 12px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 6px; }
|
|
337
|
+
.actnow-it { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 7px 8px;
|
|
338
|
+
border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
|
|
339
|
+
.actnow-it .ai { width: 22px; text-align: center; font-size: 15px; }
|
|
340
|
+
.actnow-it .ft { flex: 1; min-width: 220px; }
|
|
341
|
+
.actnow-it .live-actions { margin-top: 0; }
|
|
342
|
+
.row-acts { white-space: nowrap; }
|
|
343
|
+
.row-acts .act { padding: 2px 8px; font-size: 11px; }
|
|
344
|
+
|
|
334
345
|
.focus { border: 1px solid color-mix(in srgb, var(--critical) 35%, var(--border));
|
|
335
346
|
background: color-mix(in srgb, var(--critical) 5%, var(--surface)); border-radius: var(--radius);
|
|
336
347
|
padding: 10px 12px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 5px; }
|
|
@@ -341,7 +352,12 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
341
352
|
.focus-it .fn { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center;
|
|
342
353
|
font-size: 11px; font-weight: 700; color: #fff; flex: none; }
|
|
343
354
|
.focus-it.l1 .fn { background: var(--critical); } .focus-it.l2 .fn { background: var(--warning); }
|
|
344
|
-
.focus-it .ft { flex: 1; min-width: 0; }
|
|
355
|
+
.focus-it .ft { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
|
356
|
+
.focus-it .fx { color: var(--s1); }
|
|
357
|
+
.focus-it .fw { font-size: 11.5px; color: var(--muted); }
|
|
358
|
+
.focus-it .fd { border: 0; background: none; color: var(--muted); cursor: pointer; font-size: 12px;
|
|
359
|
+
padding: 2px 6px; border-radius: 6px; }
|
|
360
|
+
.focus-it .fd:hover { background: var(--surface-2); color: var(--text); }
|
|
345
361
|
.focus-it .fl { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
|
346
362
|
.focus-it.l1 .fl { color: var(--critical-ink); } .focus-it.l2 .fl { color: var(--warning-ink); }
|
|
347
363
|
|