claude-finops 0.1.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/LICENSE +21 -0
- package/README.md +361 -0
- package/bin/claude-finops.js +74 -0
- package/config/free_models.json +49 -0
- package/config/model_compare.json +13 -0
- package/config/pricing.json +232 -0
- package/config/settings.json +57 -0
- package/finops/__init__.py +0 -0
- package/finops/actions.py +671 -0
- package/finops/agents.py +296 -0
- package/finops/analytics.py +1579 -0
- package/finops/api.py +426 -0
- package/finops/classify.py +48 -0
- package/finops/cloud.py +336 -0
- package/finops/diagnose.py +1002 -0
- package/finops/etl.py +533 -0
- package/finops/paths.py +111 -0
- package/finops/playbook.py +229 -0
- package/finops/pricing.py +74 -0
- package/finops/procs.py +499 -0
- package/finops/report.py +169 -0
- package/package.json +52 -0
- package/run.cmd +4 -0
- package/run.py +210 -0
- package/run.sh +3 -0
- package/web/app.js +2923 -0
- package/web/charts.js +340 -0
- package/web/index.html +14 -0
- package/web/styles.css +387 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
"""Token diagnosis: why consumption is high, what to change, and which projects'
|
|
2
|
+
Claude config files (CLAUDE.md, memory, .claude/settings.json, MCP servers) need work.
|
|
3
|
+
|
|
4
|
+
Token counts are actual. Dollar figures are estimated from config/pricing.json.
|
|
5
|
+
Config checks read the files on disk right now, so they reflect today's state,
|
|
6
|
+
not the state at the time the tokens were spent.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from collections import Counter, defaultdict
|
|
12
|
+
from datetime import timedelta
|
|
13
|
+
|
|
14
|
+
from .analytics import _d
|
|
15
|
+
|
|
16
|
+
HOME = os.path.expanduser("~")
|
|
17
|
+
CLAUDE_DIR = os.path.join(HOME, ".claude")
|
|
18
|
+
CHARS_PER_TOKEN = 4 # rough, stated wherever it is used
|
|
19
|
+
CLAUDE_MD_WARN_TOKENS = 2500
|
|
20
|
+
MEMORY_WARN_TOKENS = 1500
|
|
21
|
+
EXPLORE_BASH = re.compile(r"^\s*(cd [^;&]+[;&]+\s*)?(ls|find|grep|rg|cat|head|tail|sed -n|tree|wc)\b")
|
|
22
|
+
# What each agent calls the same things, so advice names the right file and command.
|
|
23
|
+
VOCAB = {
|
|
24
|
+
"claude": {"name": "Claude Code", "md": "CLAUDE.md", "md_paths": ("CLAUDE.md", ".claude/CLAUDE.md"),
|
|
25
|
+
"clear": "/clear", "compact": "/compact", "shell": ("Bash",),
|
|
26
|
+
"explore": ("Read", "Grep", "Glob"),
|
|
27
|
+
"model_how": "Run /model sonnet for routine edits, tests, docs and lookups (or set "
|
|
28
|
+
"\"model\" in ~/.claude/settings.json). Give subagents a cheaper model in "
|
|
29
|
+
"their agent definition.",
|
|
30
|
+
"cheaper": "Default to Sonnet; switch to Opus only for hard problems"},
|
|
31
|
+
"codex": {"name": "Codex", "md": "AGENTS.md", "md_paths": ("AGENTS.md",),
|
|
32
|
+
"clear": "/new", "compact": "/compact", "shell": ("exec_command", "shell", "local_shell"),
|
|
33
|
+
"explore": (),
|
|
34
|
+
"model_how": "Use /model to pick a smaller GPT-5 model (or lower reasoning effort) for "
|
|
35
|
+
"routine edits, tests and docs; set model = \"...\" in ~/.codex/config.toml.",
|
|
36
|
+
"cheaper": "Use a cheaper GPT model for routine work"},
|
|
37
|
+
"gemini": {"name": "Gemini CLI", "md": "GEMINI.md", "md_paths": ("GEMINI.md", ".gemini/GEMINI.md"),
|
|
38
|
+
"clear": "/clear", "compact": "/compress", "shell": ("run_shell_command",),
|
|
39
|
+
"explore": ("read_file", "read_many_files", "glob", "search_file_content", "list_directory"),
|
|
40
|
+
"model_how": "Run gemini -m gemini-2.5-flash (or /model) for routine edits, tests "
|
|
41
|
+
"and docs; set \"model\" in ~/.gemini/settings.json.",
|
|
42
|
+
"cheaper": "Default to Flash; switch to Pro only for hard problems"},
|
|
43
|
+
}
|
|
44
|
+
NOISY_PATH = re.compile(r"(node_modules/|/dist/|/build/|/\.next/|/coverage/|package-lock\.json|"
|
|
45
|
+
r"yarn\.lock|pnpm-lock\.yaml|\.min\.js|\.map$|/data/.*\.(json|csv)$)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _tok(path):
|
|
49
|
+
try:
|
|
50
|
+
with open(path, encoding="utf-8", errors="ignore") as fh:
|
|
51
|
+
return len(fh.read()) // CHARS_PER_TOKEN
|
|
52
|
+
except OSError:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load_json(path):
|
|
57
|
+
try:
|
|
58
|
+
with open(path) as fh:
|
|
59
|
+
return json.load(fh)
|
|
60
|
+
except (OSError, ValueError):
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _pct(a, b):
|
|
65
|
+
return round(100.0 * a / b, 1) if b else 0.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Diagnoser:
|
|
69
|
+
def __init__(self, analytics):
|
|
70
|
+
self.a = analytics
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------ drivers
|
|
73
|
+
def drivers(self, f):
|
|
74
|
+
a = self.a
|
|
75
|
+
w, p = a.where(f)
|
|
76
|
+
t = a.one(f"""SELECT COUNT(*) n, COUNT(DISTINCT r.session_id) sessions,
|
|
77
|
+
SUM(r.input_tokens) i, SUM(r.output_tokens) o, SUM(r.cache_read_tokens) cr,
|
|
78
|
+
SUM(r.cache_write_tokens) cw, SUM(r.billable_tokens) b, SUM(r.est_cost_usd) c,
|
|
79
|
+
AVG(r.context_tokens) ctx, SUM(r.is_sidechain) side,
|
|
80
|
+
SUM(CASE WHEN r.is_sidechain=1 THEN r.billable_tokens ELSE 0 END) side_tok
|
|
81
|
+
FROM requests r WHERE {w}""", p)
|
|
82
|
+
if not t.get("n"):
|
|
83
|
+
return {"total": t, "drivers": []}
|
|
84
|
+
b = t["b"] or 1
|
|
85
|
+
out = []
|
|
86
|
+
|
|
87
|
+
# 1. context re-reading: every request re-sends the whole conversation
|
|
88
|
+
out.append({
|
|
89
|
+
"key": "context_reread", "share_pct": _pct(t["cr"], b),
|
|
90
|
+
"title": "Conversation history re-read on every request",
|
|
91
|
+
"detail": f"{_pct(t['cr'], b)}% of tokens are cache reads — the whole context "
|
|
92
|
+
f"(avg {int(t['ctx'] or 0):,} tokens) is re-sent each time Claude takes a "
|
|
93
|
+
f"step. {t['n']:,} requests × that context is where the volume comes from.",
|
|
94
|
+
"basis": "actual"})
|
|
95
|
+
|
|
96
|
+
# 2. long sessions
|
|
97
|
+
thr = a.settings["waste_rules"].get("large_context_tokens", 100000)
|
|
98
|
+
ls = a.one(f"""SELECT COUNT(*) n, SUM(b) b FROM (SELECT r.session_id, COUNT(*) k,
|
|
99
|
+
SUM(r.billable_tokens) b, MAX(r.context_tokens) mx FROM requests r WHERE {w}
|
|
100
|
+
GROUP BY r.session_id HAVING k >= 150 OR mx >= ?)""", p + [thr])
|
|
101
|
+
out.append({
|
|
102
|
+
"key": "long_sessions", "share_pct": _pct(ls["b"] or 0, b),
|
|
103
|
+
"title": "Long-running sessions",
|
|
104
|
+
"detail": f"{ls['n'] or 0} sessions ran 150+ steps or passed {thr // 1000}K context; "
|
|
105
|
+
f"they hold {_pct(ls['b'] or 0, b)}% of all tokens. Each step late in a "
|
|
106
|
+
f"session costs several times an early one.",
|
|
107
|
+
"basis": "actual"})
|
|
108
|
+
|
|
109
|
+
# 3. frontier model share
|
|
110
|
+
frontier = [m for m, v in a.pricing.models.items() if v.get("tier") == "frontier"]
|
|
111
|
+
if frontier:
|
|
112
|
+
ph = ",".join("?" * len(frontier))
|
|
113
|
+
fr = a.one(f"SELECT SUM(r.billable_tokens) b, SUM(r.est_cost_usd) c FROM requests r "
|
|
114
|
+
f"WHERE {w} AND r.model IN ({ph})", p + frontier)
|
|
115
|
+
out.append({
|
|
116
|
+
"key": "frontier_model", "share_pct": _pct(fr["b"] or 0, b),
|
|
117
|
+
"title": "Frontier-tier (Opus) usage",
|
|
118
|
+
"detail": f"{_pct(fr['b'] or 0, b)}% of tokens and {_pct(fr['c'] or 0, t['c'])}% "
|
|
119
|
+
f"of estimated cost ran on frontier models. Tokens are the same "
|
|
120
|
+
f"count on Sonnet/Haiku, but cheaper.",
|
|
121
|
+
"basis": "actual tokens, estimated cost"})
|
|
122
|
+
|
|
123
|
+
# 4. tool output flooding context
|
|
124
|
+
tools = a.q(f"""SELECT tc.name, COUNT(*) calls, SUM(r.billable_tokens) b
|
|
125
|
+
FROM tool_calls tc JOIN requests r ON r.id=tc.request_pk WHERE {w}
|
|
126
|
+
GROUP BY tc.name ORDER BY b DESC LIMIT 6""", p)
|
|
127
|
+
if tools:
|
|
128
|
+
top = tools[0]
|
|
129
|
+
out.append({
|
|
130
|
+
"key": "tool_heavy", "share_pct": _pct(top["b"], b),
|
|
131
|
+
"title": f"Tool-driven steps ({top['name']} leads)",
|
|
132
|
+
"detail": "Requests that issued a tool call, by tool: " + ", ".join(
|
|
133
|
+
f"{x['name']} {x['calls']:,}× ({_pct(x['b'], b)}%)" for x in tools[:5])
|
|
134
|
+
+ ". Every tool call adds a round trip that re-reads the context, and "
|
|
135
|
+
"its output stays in context for the rest of the session.",
|
|
136
|
+
"evidence": tools, "basis": "actual"})
|
|
137
|
+
|
|
138
|
+
# 5. subagents
|
|
139
|
+
if t["side"]:
|
|
140
|
+
out.append({
|
|
141
|
+
"key": "subagents", "share_pct": _pct(t["side_tok"] or 0, b),
|
|
142
|
+
"title": "Subagents",
|
|
143
|
+
"detail": f"{t['side']:,} requests ({_pct(t['side_tok'] or 0, b)}% of tokens) came "
|
|
144
|
+
f"from subagents. Each one starts with its own system prompt and "
|
|
145
|
+
f"CLAUDE.md load.",
|
|
146
|
+
"basis": "actual"})
|
|
147
|
+
|
|
148
|
+
out.sort(key=lambda d: -d["share_pct"])
|
|
149
|
+
return {"total": t, "drivers": out}
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------ trend
|
|
152
|
+
def trend(self, f):
|
|
153
|
+
"""Last 7 days vs the 7 before, split into volume vs size-per-step."""
|
|
154
|
+
a = self.a
|
|
155
|
+
if not a.last_day:
|
|
156
|
+
return None
|
|
157
|
+
end = _d(f.get("end") or a.last_day)
|
|
158
|
+
ranges = {"current": (end - timedelta(days=6), end),
|
|
159
|
+
"previous": (end - timedelta(days=13), end - timedelta(days=7))}
|
|
160
|
+
res = {}
|
|
161
|
+
for k, (s, e) in ranges.items():
|
|
162
|
+
ff = dict(f, start=s.isoformat(), end=e.isoformat())
|
|
163
|
+
w, p = a.where(ff)
|
|
164
|
+
r = a.one(f"""SELECT COUNT(*) n, COALESCE(SUM(r.billable_tokens),0) b,
|
|
165
|
+
COALESCE(SUM(r.est_cost_usd),0) c, AVG(r.context_tokens) ctx,
|
|
166
|
+
COUNT(DISTINCT r.session_id) sessions FROM requests r WHERE {w}""", p)
|
|
167
|
+
r["per_request"] = (r["b"] / r["n"]) if r["n"] else 0
|
|
168
|
+
r["start"], r["end"] = s.isoformat(), e.isoformat()
|
|
169
|
+
res[k] = r
|
|
170
|
+
c, pv = res["current"], res["previous"]
|
|
171
|
+
if pv["b"] and c["n"] and pv["n"]:
|
|
172
|
+
vol = c["n"] / pv["n"]
|
|
173
|
+
size = c["per_request"] / pv["per_request"] if pv["per_request"] else 1
|
|
174
|
+
res["change_pct"] = round(100.0 * (c["b"] / pv["b"] - 1), 1)
|
|
175
|
+
res["volume_factor"] = round(vol, 2)
|
|
176
|
+
res["size_factor"] = round(size, 2)
|
|
177
|
+
res["explanation"] = (
|
|
178
|
+
f"Tokens {'up' if c['b'] >= pv['b'] else 'down'} {abs(res['change_pct'])}% week "
|
|
179
|
+
f"over week: {vol:.2f}× as many requests, each {size:.2f}× the size "
|
|
180
|
+
f"({int(pv['per_request']):,} → {int(c['per_request']):,} tokens/request).")
|
|
181
|
+
return res
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------ recommendations
|
|
184
|
+
def recommendations(self, f, drv, projects):
|
|
185
|
+
a = self.a
|
|
186
|
+
v = getattr(self, "v", VOCAB["claude"])
|
|
187
|
+
recs = []
|
|
188
|
+
d = {x["key"]: x for x in drv["drivers"]}
|
|
189
|
+
t = drv["total"]
|
|
190
|
+
if not t.get("n"):
|
|
191
|
+
return recs
|
|
192
|
+
cr_rate_cost = a.pricing.estimate(self._main_model(f), cache_read=t["cr"] or 0)
|
|
193
|
+
|
|
194
|
+
if d.get("context_reread", {}).get("share_pct", 0) > 60:
|
|
195
|
+
recs.append({
|
|
196
|
+
"priority": 1, "title": "Clear or compact between tasks",
|
|
197
|
+
"why": d["context_reread"]["detail"],
|
|
198
|
+
"how": f"Run {v['clear']} when you switch to an unrelated task, and {v['compact']} once a "
|
|
199
|
+
"session passes ~100K context. Start one session per ticket rather than "
|
|
200
|
+
"one per day.",
|
|
201
|
+
"est_savings_usd": round(cr_rate_cost * 0.3, 2),
|
|
202
|
+
"savings_basis": "30% fewer cache-read tokens, priced at your main model's rate"})
|
|
203
|
+
if d.get("long_sessions", {}).get("share_pct", 0) > 30:
|
|
204
|
+
recs.append({
|
|
205
|
+
"priority": 1, "title": "Break up marathon sessions",
|
|
206
|
+
"why": d["long_sessions"]["detail"],
|
|
207
|
+
"how": f"Finish a unit of work, write the state to a file or memory, then {v['clear']}. "
|
|
208
|
+
"Use the Sessions view sorted by cost to find the worst ones.",
|
|
209
|
+
"est_savings_usd": None, "savings_basis": None})
|
|
210
|
+
fm = d.get("frontier_model")
|
|
211
|
+
if fm and fm["share_pct"] > 70:
|
|
212
|
+
mr = a.recommendations(f)
|
|
213
|
+
save = sum(r["estimated_savings_usd"] for r in mr["recommendations"]
|
|
214
|
+
if r["type"] == "model_downgrade")
|
|
215
|
+
recs.append({
|
|
216
|
+
"priority": 2, "title": v["cheaper"],
|
|
217
|
+
"why": fm["detail"],
|
|
218
|
+
"how": v["model_how"],
|
|
219
|
+
"est_savings_usd": round(save, 2) if save else None,
|
|
220
|
+
"savings_basis": "Model-downgrade estimate from the Recommendations view; "
|
|
221
|
+
"quality not modelled"})
|
|
222
|
+
th = d.get("tool_heavy")
|
|
223
|
+
if th and th.get("evidence"):
|
|
224
|
+
bash = next((x for x in th["evidence"] if x["name"] in v["shell"]), None)
|
|
225
|
+
if bash and bash["calls"] > 500:
|
|
226
|
+
recs.append({
|
|
227
|
+
"priority": 2, "title": "Cap noisy shell output",
|
|
228
|
+
"why": f"{bash['name']} ran {bash['calls']:,} times; long command output sits in "
|
|
229
|
+
f"context for the rest of the session.",
|
|
230
|
+
"how": "Ask for `| head`/`| tail`, quiet flags (`-q`, `--silent`), and run "
|
|
231
|
+
"test suites with a reporter that prints failures only. Add these "
|
|
232
|
+
"habits to " + v["md"] + " so the agent does it unprompted.",
|
|
233
|
+
"est_savings_usd": None, "savings_basis": None})
|
|
234
|
+
mcp = [x for x in th["evidence"] if x["name"].startswith("mcp__")]
|
|
235
|
+
if mcp:
|
|
236
|
+
recs.append({
|
|
237
|
+
"priority": 3, "title": "Browser/MCP tools return large payloads",
|
|
238
|
+
"why": "Top MCP tools by tokens: " + ", ".join(
|
|
239
|
+
f"{x['name'].split('__')[-1]} {x['calls']}×" for x in mcp[:3]),
|
|
240
|
+
"how": "Prefer targeted reads (find / get_page_text) over screenshots, and "
|
|
241
|
+
"do browser checks in a subagent so the payload doesn't stay in the "
|
|
242
|
+
"main session.",
|
|
243
|
+
"est_savings_usd": None, "savings_basis": None})
|
|
244
|
+
sub = d.get("subagents")
|
|
245
|
+
if sub and sub["share_pct"] > 25:
|
|
246
|
+
recs.append({
|
|
247
|
+
"priority": 3, "title": "Subagents are a large share",
|
|
248
|
+
"why": sub["detail"],
|
|
249
|
+
"how": "Use subagents for wide searches only; for known files, read them "
|
|
250
|
+
"directly. Set `model: sonnet` or `haiku` in custom agent frontmatter.",
|
|
251
|
+
"est_savings_usd": None, "savings_basis": None})
|
|
252
|
+
n_fix = sum(1 for pj in projects if pj["issues"])
|
|
253
|
+
if n_fix:
|
|
254
|
+
recs.append({
|
|
255
|
+
"priority": 2, "title": f"{n_fix} project(s) need {v['md']} / config changes",
|
|
256
|
+
"why": "Missing or oversized CLAUDE.md, oversized memory, repeated file "
|
|
257
|
+
"re-reads or reads of generated files — see the project table below.",
|
|
258
|
+
"how": "Apply the per-project fixes listed below.",
|
|
259
|
+
"est_savings_usd": None, "savings_basis": None})
|
|
260
|
+
recs.sort(key=lambda r: (r["priority"], -(r["est_savings_usd"] or 0)))
|
|
261
|
+
return recs
|
|
262
|
+
|
|
263
|
+
def _main_model(self, f):
|
|
264
|
+
w, p = self.a.where(f)
|
|
265
|
+
r = self.a.one(f"SELECT r.model m FROM requests r WHERE {w} GROUP BY r.model "
|
|
266
|
+
f"ORDER BY SUM(r.billable_tokens) DESC LIMIT 1", p)
|
|
267
|
+
return r.get("m")
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------------------------ config audit
|
|
270
|
+
def _mcp_servers(self):
|
|
271
|
+
cfg = _load_json(os.path.join(HOME, ".claude.json"))
|
|
272
|
+
glob = list((cfg.get("mcpServers") or {}).keys())
|
|
273
|
+
per = {k: list((v.get("mcpServers") or {}).keys())
|
|
274
|
+
for k, v in (cfg.get("projects") or {}).items()}
|
|
275
|
+
return glob, per
|
|
276
|
+
|
|
277
|
+
def global_config(self):
|
|
278
|
+
files = []
|
|
279
|
+
for rel in ("CLAUDE.md", "settings.json", "settings.local.json"):
|
|
280
|
+
p = os.path.join(CLAUDE_DIR, rel)
|
|
281
|
+
if os.path.exists(p):
|
|
282
|
+
files.append({"path": p, "tokens": _tok(p)})
|
|
283
|
+
glob, _ = self._mcp_servers()
|
|
284
|
+
agents = os.path.join(CLAUDE_DIR, "agents")
|
|
285
|
+
issues = []
|
|
286
|
+
g = next((x for x in files if x["path"].endswith("CLAUDE.md")), None)
|
|
287
|
+
if g and g["tokens"] and g["tokens"] > CLAUDE_MD_WARN_TOKENS:
|
|
288
|
+
issues.append({"severity": "medium", "title": "Global CLAUDE.md is large",
|
|
289
|
+
"fix": f"~{g['tokens']:,} tokens load into every session in every "
|
|
290
|
+
f"project. Move project-specific parts into that project."})
|
|
291
|
+
st = _load_json(os.path.join(CLAUDE_DIR, "settings.json"))
|
|
292
|
+
if not st.get("model"):
|
|
293
|
+
issues.append({"severity": "low", "title": "No default model set",
|
|
294
|
+
"fix": "Add \"model\": \"sonnet\" to ~/.claude/settings.json and "
|
|
295
|
+
"switch to Opus with /model when a task needs it."})
|
|
296
|
+
return {"files": files, "mcp_servers": glob,
|
|
297
|
+
"custom_agents": sorted(os.listdir(agents)) if os.path.isdir(agents) else [],
|
|
298
|
+
"default_model": st.get("model"), "issues": issues}
|
|
299
|
+
|
|
300
|
+
def agent_global_config(self):
|
|
301
|
+
"""Codex / Gemini equivalent of global_config(): instructions file, default model, MCP."""
|
|
302
|
+
v, issues, files, model, mcp = self.v, [], [], None, []
|
|
303
|
+
base = os.path.join(HOME, ".codex" if self.agent == "codex" else ".gemini")
|
|
304
|
+
for rel in (v["md"], "config.toml", "settings.json"):
|
|
305
|
+
p = os.path.join(base, rel)
|
|
306
|
+
if os.path.exists(p):
|
|
307
|
+
files.append({"path": p, "tokens": _tok(p)})
|
|
308
|
+
if self.agent == "codex":
|
|
309
|
+
try:
|
|
310
|
+
txt = open(os.path.join(base, "config.toml"), errors="replace").read()
|
|
311
|
+
except OSError:
|
|
312
|
+
txt = ""
|
|
313
|
+
m = re.search(r'^\s*model\s*=\s*"([^"]+)"', txt, re.M)
|
|
314
|
+
model = m.group(1) if m else None
|
|
315
|
+
mcp = re.findall(r'^\s*\[mcp_servers\.([^\]]+)\]', txt, re.M)
|
|
316
|
+
where = "model = \"...\" in ~/.codex/config.toml"
|
|
317
|
+
else:
|
|
318
|
+
st = _load_json(os.path.join(base, "settings.json"))
|
|
319
|
+
model = (st.get("model") or {}).get("name") if isinstance(st.get("model"), dict) else st.get("model")
|
|
320
|
+
mcp = list((st.get("mcpServers") or {}).keys())
|
|
321
|
+
where = "\"model\": {\"name\": \"...\"} in ~/.gemini/settings.json"
|
|
322
|
+
g = next((x for x in files if x["path"].endswith(v["md"])), None)
|
|
323
|
+
if g and g["tokens"] and g["tokens"] > CLAUDE_MD_WARN_TOKENS:
|
|
324
|
+
issues.append({"severity": "medium", "title": f"Global {v['md']} is large",
|
|
325
|
+
"fix": f"~{g['tokens']:,} tokens load into every session in every "
|
|
326
|
+
f"project. Move project-specific parts into that project."})
|
|
327
|
+
if not model:
|
|
328
|
+
issues.append({"severity": "low", "title": "No default model set",
|
|
329
|
+
"fix": f"Set a cheaper everyday default with {where}; switch up with "
|
|
330
|
+
f"/model when a task needs it."})
|
|
331
|
+
return {"files": files, "mcp_servers": mcp, "custom_agents": [],
|
|
332
|
+
"default_model": model, "issues": issues}
|
|
333
|
+
|
|
334
|
+
def projects(self, f):
|
|
335
|
+
a = self.a
|
|
336
|
+
w, p = a.where(f)
|
|
337
|
+
rows = a.q(f"""SELECT pj.id, pj.name, pj.path, pj.slug, pj.is_sandbox,
|
|
338
|
+
COUNT(*) requests, SUM(r.billable_tokens) tokens, SUM(r.est_cost_usd) cost,
|
|
339
|
+
AVG(r.context_tokens) avg_ctx, COUNT(DISTINCT r.session_id) sessions
|
|
340
|
+
FROM requests r JOIN projects pj ON pj.id=r.project_id
|
|
341
|
+
WHERE {w} AND pj.is_sandbox=0 GROUP BY pj.path ORDER BY tokens DESC LIMIT 25""", p)
|
|
342
|
+
_, mcp_per = self._mcp_servers()
|
|
343
|
+
mcp_used = {r["name"].split("__")[1] for r in a.q(
|
|
344
|
+
"SELECT DISTINCT name FROM tool_calls WHERE name LIKE 'mcp__%'")}
|
|
345
|
+
out = []
|
|
346
|
+
for pj in rows:
|
|
347
|
+
path = pj["path"]
|
|
348
|
+
pids = [x["id"] for x in a.q("SELECT id FROM projects WHERE path=?", (path,))]
|
|
349
|
+
ph = ",".join("?" * len(pids))
|
|
350
|
+
exists = bool(path) and os.path.isdir(path)
|
|
351
|
+
v = getattr(self, "v", VOCAB["claude"])
|
|
352
|
+
claude = v is VOCAB["claude"]
|
|
353
|
+
cm = [os.path.join(path, x) for x in v["md_paths"]] if path else []
|
|
354
|
+
cm_found = [x for x in cm if os.path.exists(x)]
|
|
355
|
+
cm_tok = sum(_tok(x) or 0 for x in cm_found)
|
|
356
|
+
local = os.path.join(path, "CLAUDE.local.md") if path else None
|
|
357
|
+
mem = os.path.join(CLAUDE_DIR, "projects", pj["slug"] or "", "memory", "MEMORY.md")
|
|
358
|
+
mem_tok = _tok(mem) if os.path.exists(mem) else None
|
|
359
|
+
settings = os.path.join(path, ".claude", "settings.json") if path else None
|
|
360
|
+
s_json = _load_json(settings) if settings and os.path.exists(settings) else None
|
|
361
|
+
|
|
362
|
+
ex = ",".join("?" * len(v["explore"])) or "NULL"
|
|
363
|
+
sh = ",".join("?" * len(v["shell"]))
|
|
364
|
+
calls = a.one(f"""SELECT COUNT(*) n,
|
|
365
|
+
SUM(CASE WHEN name IN ({ex}) THEN 1 ELSE 0 END) explore
|
|
366
|
+
FROM tool_calls WHERE project_id IN ({ph})""", list(v["explore"]) + pids)
|
|
367
|
+
bash = a.q(f"SELECT target FROM tool_calls WHERE project_id IN ({ph}) AND name IN ({sh})",
|
|
368
|
+
pids + list(v["shell"]))
|
|
369
|
+
explore = (calls["explore"] or 0) + sum(1 for b in bash if EXPLORE_BASH.match(b["target"] or ""))
|
|
370
|
+
explore_pct = _pct(explore, calls["n"] or 0)
|
|
371
|
+
reread = a.q(f"""SELECT path, COUNT(*) n, COUNT(DISTINCT session_id) s
|
|
372
|
+
FROM files_touched WHERE project_id IN ({ph}) AND op='read'
|
|
373
|
+
GROUP BY path HAVING s >= 3 ORDER BY n DESC LIMIT 5""", pids)
|
|
374
|
+
noisy = a.q(f"""SELECT path, COUNT(*) n FROM files_touched
|
|
375
|
+
WHERE project_id IN ({ph}) AND op='read' GROUP BY path""", pids)
|
|
376
|
+
noisy = [x for x in noisy if NOISY_PATH.search(x["path"] or "")][:5]
|
|
377
|
+
|
|
378
|
+
issues = []
|
|
379
|
+
heavy = (pj["tokens"] or 0) > 20_000_000
|
|
380
|
+
if exists and not cm_found and heavy:
|
|
381
|
+
issues.append({"severity": "high", "title": f"No {v['md']}",
|
|
382
|
+
"fix": f"{explore_pct}% of tool calls here are exploration "
|
|
383
|
+
f"(reads/greps/ls/find). Run /init in this repo, then add "
|
|
384
|
+
f"build/test commands, folder map and conventions so the "
|
|
385
|
+
f"agent stops rediscovering them each session."})
|
|
386
|
+
elif exists and cm_found and explore_pct > 45 and heavy:
|
|
387
|
+
issues.append({"severity": "medium", "title": "CLAUDE.md isn't saving exploration",
|
|
388
|
+
"fix": f"{explore_pct}% of tool calls are still exploration. Add a "
|
|
389
|
+
f"folder map and where-things-live notes to CLAUDE.md."})
|
|
390
|
+
if cm_tok > CLAUDE_MD_WARN_TOKENS:
|
|
391
|
+
reload_cost = a.pricing.estimate(self._main_model(dict(f, projects=[str(i) for i in pids])),
|
|
392
|
+
cache_read=cm_tok * pj["requests"])
|
|
393
|
+
issues.append({"severity": "medium", "title": f"CLAUDE.md is ~{cm_tok:,} tokens",
|
|
394
|
+
"fix": f"It's re-read on each of {pj['requests']:,} requests "
|
|
395
|
+
f"(~${reload_cost:,.2f} est.). Trim to essentials (<"
|
|
396
|
+
f"{CLAUDE_MD_WARN_TOKENS:,} tokens); move rarely-needed "
|
|
397
|
+
f"detail to docs Claude reads on demand."})
|
|
398
|
+
if mem_tok and mem_tok > MEMORY_WARN_TOKENS:
|
|
399
|
+
issues.append({"severity": "low", "title": f"MEMORY.md is ~{mem_tok:,} tokens",
|
|
400
|
+
"fix": "Prune stale memories; keep the index to one line each."})
|
|
401
|
+
if reread and exists:
|
|
402
|
+
issues.append({"severity": "medium", "title": "Same files re-read across sessions",
|
|
403
|
+
"fix": "Summarise these in CLAUDE.md (purpose, key exports) so "
|
|
404
|
+
"Claude doesn't open them every session: "
|
|
405
|
+
+ ", ".join(f"{os.path.relpath(x['path'], path) if x['path'].startswith(path) else x['path']}"
|
|
406
|
+
f" ({x['s']} sessions)" for x in reread[:3]),
|
|
407
|
+
"evidence": reread})
|
|
408
|
+
if noisy and exists:
|
|
409
|
+
issues.append({"severity": "medium", "title": "Generated/large files were read",
|
|
410
|
+
"fix": "Add deny rules to .claude/settings.json, e.g. "
|
|
411
|
+
"\"permissions\": {\"deny\": [\"Read(./node_modules/**)\", "
|
|
412
|
+
"\"Read(./dist/**)\", \"Read(./**/*.lock)\"]}. Seen: "
|
|
413
|
+
+ ", ".join(os.path.basename(x["path"]) for x in noisy[:3]),
|
|
414
|
+
"evidence": noisy})
|
|
415
|
+
unused = [m for m in mcp_per.get(path, []) if m not in mcp_used] if claude else []
|
|
416
|
+
if unused:
|
|
417
|
+
issues.append({"severity": "low", "title": "Unused MCP servers enabled",
|
|
418
|
+
"fix": f"{', '.join(unused)} never called here but their tool "
|
|
419
|
+
f"definitions load every session. Remove with "
|
|
420
|
+
f"`claude mcp remove <name>`."})
|
|
421
|
+
if claude and exists and cm_found:
|
|
422
|
+
issues += self.claude_md_quality(path, cm_found)
|
|
423
|
+
if claude:
|
|
424
|
+
issues += self.memory_quality(pj["slug"], heavy)
|
|
425
|
+
else: # Claude-only files: don't flag them
|
|
426
|
+
issues = [i for i in issues if not i["title"].startswith(("MEMORY.md", "Generated/large"))]
|
|
427
|
+
if not exists:
|
|
428
|
+
issues = []
|
|
429
|
+
out.append({
|
|
430
|
+
"name": pj["name"], "path": path, "exists": exists,
|
|
431
|
+
"requests": pj["requests"], "sessions": pj["sessions"], "tokens": pj["tokens"],
|
|
432
|
+
"cost": pj["cost"], "avg_context": pj["avg_ctx"], "explore_pct": explore_pct,
|
|
433
|
+
"claude_md": {"paths": cm_found, "tokens": cm_tok},
|
|
434
|
+
"claude_local_md": bool(local) and os.path.exists(local),
|
|
435
|
+
"memory_tokens": mem_tok,
|
|
436
|
+
"settings": {"exists": s_json is not None,
|
|
437
|
+
"deny_rules": len(((s_json or {}).get("permissions") or {}).get("deny") or [])},
|
|
438
|
+
"mcp_servers": mcp_per.get(path, []),
|
|
439
|
+
"issues": issues,
|
|
440
|
+
"harness": self.harness(pj, path, exists, cm_found, s_json, explore_pct, pids, ph)
|
|
441
|
+
if claude else None,
|
|
442
|
+
})
|
|
443
|
+
return out
|
|
444
|
+
|
|
445
|
+
def harness(self, pj, path, exists, cm_found, s_json, explore_pct, pids, ph):
|
|
446
|
+
"""Does this project need a Claude Code harness, and which pieces are missing?
|
|
447
|
+
|
|
448
|
+
Harness = CLAUDE.md + .claude/settings.json (permissions, hooks) + skills/commands/agents.
|
|
449
|
+
Need is judged from actual usage here; presence is checked on disk now.
|
|
450
|
+
"""
|
|
451
|
+
if not exists:
|
|
452
|
+
return {"verdict": "unknown", "reasons": ["Project path is not on this machine"],
|
|
453
|
+
"have": [], "missing": []}
|
|
454
|
+
dot = os.path.join(path, ".claude")
|
|
455
|
+
s = s_json or {}
|
|
456
|
+
perms = s.get("permissions") or {}
|
|
457
|
+
|
|
458
|
+
def any_files(sub):
|
|
459
|
+
d = os.path.join(dot, sub)
|
|
460
|
+
return os.path.isdir(d) and any(not n.startswith(".") for n in os.listdir(d))
|
|
461
|
+
|
|
462
|
+
pieces = [
|
|
463
|
+
("CLAUDE.md", bool(cm_found),
|
|
464
|
+
"Run /init, then add build/test commands, a folder map and conventions."),
|
|
465
|
+
("Permissions", bool(perms.get("allow") or perms.get("deny")),
|
|
466
|
+
"Add allow rules for safe commands you approve repeatedly and deny rules for "
|
|
467
|
+
"node_modules/dist/lock files in .claude/settings.json."),
|
|
468
|
+
("Hooks", bool(s.get("hooks")),
|
|
469
|
+
"Add a PostToolUse hook that runs the formatter/linter after edits, so Claude "
|
|
470
|
+
"doesn't spend turns fixing style."),
|
|
471
|
+
("Skills / commands", any_files("skills") or any_files("commands"),
|
|
472
|
+
"Turn the commands you repeat into a skill in .claude/skills/."),
|
|
473
|
+
("Subagents", any_files("agents"),
|
|
474
|
+
"Add a subagent in .claude/agents/ (on a cheaper model) for exploration or review."),
|
|
475
|
+
]
|
|
476
|
+
|
|
477
|
+
# usage signals (actual)
|
|
478
|
+
sessions, tokens = pj["sessions"] or 0, pj["tokens"] or 0
|
|
479
|
+
repeated = self.a.q(f"""SELECT target, COUNT(*) n, COUNT(DISTINCT session_id) s
|
|
480
|
+
FROM tool_calls WHERE project_id IN ({ph}) AND name='Bash' AND target IS NOT NULL
|
|
481
|
+
GROUP BY target HAVING s >= 3 ORDER BY s DESC, n DESC LIMIT 5""", pids)
|
|
482
|
+
edits = self.a.one(f"""SELECT COUNT(*) n FROM tool_calls WHERE project_id IN ({ph})
|
|
483
|
+
AND name IN ('Edit','Write','MultiEdit')""", pids)["n"] or 0
|
|
484
|
+
|
|
485
|
+
reasons = []
|
|
486
|
+
if sessions >= 5:
|
|
487
|
+
reasons.append(f"{sessions} sessions: context is rebuilt from scratch every time")
|
|
488
|
+
if tokens > 20_000_000:
|
|
489
|
+
reasons.append(f"{tokens/1e6:,.0f}M tokens used here")
|
|
490
|
+
if explore_pct > 40:
|
|
491
|
+
reasons.append(f"{explore_pct}% of tool calls are exploration (Read/Grep/ls)")
|
|
492
|
+
if repeated:
|
|
493
|
+
reasons.append(f"{len(repeated)} shell commands repeated across 3+ sessions")
|
|
494
|
+
if edits >= 50:
|
|
495
|
+
reasons.append(f"{edits:,} edits: worth an auto-format/lint hook")
|
|
496
|
+
|
|
497
|
+
wanted = {"CLAUDE.md", "Permissions"}
|
|
498
|
+
if repeated:
|
|
499
|
+
wanted.add("Skills / commands")
|
|
500
|
+
if edits >= 50:
|
|
501
|
+
wanted.add("Hooks")
|
|
502
|
+
if explore_pct > 40 and tokens > 20_000_000:
|
|
503
|
+
wanted.add("Subagents")
|
|
504
|
+
|
|
505
|
+
have = [n for n, ok, _ in pieces if ok]
|
|
506
|
+
missing = [{"piece": n, "fix": fix} for n, ok, fix in pieces if not ok and n in wanted]
|
|
507
|
+
if repeated and any(m["piece"] == "Skills / commands" for m in missing):
|
|
508
|
+
missing[[m["piece"] for m in missing].index("Skills / commands")]["fix"] += \
|
|
509
|
+
" Repeated: " + ", ".join(f"`{r['target'][:60]}` ({r['s']} sessions)" for r in repeated[:3])
|
|
510
|
+
|
|
511
|
+
light = sessions < 3 and tokens < 5_000_000
|
|
512
|
+
if light:
|
|
513
|
+
verdict = "not_needed"
|
|
514
|
+
reasons = [f"Light use ({sessions} sessions, {tokens/1e6:.1f}M tokens): not worth setting up yet"]
|
|
515
|
+
missing = []
|
|
516
|
+
elif not missing:
|
|
517
|
+
verdict = "in_place"
|
|
518
|
+
elif len(missing) >= 2 or "CLAUDE.md" in [m["piece"] for m in missing]:
|
|
519
|
+
verdict = "needed"
|
|
520
|
+
else:
|
|
521
|
+
verdict = "partial"
|
|
522
|
+
return {"verdict": verdict, "reasons": reasons, "have": have, "missing": missing,
|
|
523
|
+
"repeated_commands": repeated}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ------------------------------------------------------------------ breakdowns
|
|
527
|
+
BUILTIN_CMDS = {"model", "compact", "clear", "doctor", "upgrade", "login", "logout", "config",
|
|
528
|
+
"help", "usage-credits", "rate-limit-options", "cost", "status", "resume",
|
|
529
|
+
"memory", "permissions", "mcp", "exit", "fast", "context", "agents", "hooks"}
|
|
530
|
+
|
|
531
|
+
def breakdown(self, f=None):
|
|
532
|
+
"""Session / subagent / skill / MCP / connector attribution.
|
|
533
|
+
|
|
534
|
+
injected tokens = size of what the tool/skill put into context (chars/4, actual size)
|
|
535
|
+
carried tokens = injected x later main-thread requests in that session that
|
|
536
|
+
re-read it (upper bound: /compact is not visible in transcripts)
|
|
537
|
+
"""
|
|
538
|
+
a = self.a
|
|
539
|
+
f = f or {}
|
|
540
|
+
w, p = a.where(f)
|
|
541
|
+
rate = a.pricing.rates(self._main_model(f)).get("cache_read", 0.3) / 1e6
|
|
542
|
+
tcw = f"tc.request_pk IN (SELECT r.id FROM requests r WHERE {w})"
|
|
543
|
+
|
|
544
|
+
sessions = a.q(f"""SELECT r.session_id, s.title, pj.name project,
|
|
545
|
+
SUM(CASE WHEN r.is_sidechain=0 THEN r.billable_tokens ELSE 0 END) main_tokens,
|
|
546
|
+
SUM(CASE WHEN r.is_sidechain=1 THEN r.billable_tokens ELSE 0 END) sub_tokens,
|
|
547
|
+
COUNT(DISTINCT r.agent_id) subagents, SUM(r.est_cost_usd) cost, COUNT(*) requests,
|
|
548
|
+
MAX(r.context_tokens) max_ctx
|
|
549
|
+
FROM requests r JOIN sessions s ON s.id=r.session_id JOIN projects pj ON pj.id=r.project_id
|
|
550
|
+
WHERE {w} GROUP BY r.session_id ORDER BY main_tokens+sub_tokens DESC LIMIT 30""", p)
|
|
551
|
+
if sessions:
|
|
552
|
+
ids = [x["session_id"] for x in sessions]
|
|
553
|
+
ph = ",".join("?" * len(ids))
|
|
554
|
+
ext = defaultdict(lambda: defaultdict(set))
|
|
555
|
+
for r in a.q(f"SELECT session_id, kind, server FROM tool_calls WHERE session_id IN ({ph})"
|
|
556
|
+
f" AND kind IN ('mcp','connector','skill')", ids):
|
|
557
|
+
ext[r["session_id"]][r["kind"]].add(r["server"])
|
|
558
|
+
for x in sessions:
|
|
559
|
+
e = ext.get(x["session_id"], {})
|
|
560
|
+
x["skills"] = sorted(e.get("skill", []))
|
|
561
|
+
x["mcp"] = sorted(e.get("mcp", set()) | e.get("connector", set()))
|
|
562
|
+
|
|
563
|
+
types = a.q(f"""SELECT r.agent_type, COUNT(DISTINCT r.agent_id) runs, COUNT(*) requests,
|
|
564
|
+
SUM(r.billable_tokens) tokens, SUM(r.est_cost_usd) cost
|
|
565
|
+
FROM requests r WHERE {w} AND r.is_sidechain=1 GROUP BY r.agent_type
|
|
566
|
+
ORDER BY tokens DESC""", p)
|
|
567
|
+
for t in types:
|
|
568
|
+
t["tokens_per_run"] = t["tokens"] / t["runs"] if t["runs"] else None
|
|
569
|
+
runs = a.q(f"""SELECT r.agent_id, r.agent_type, r.agent_desc, r.session_id,
|
|
570
|
+
pj.name project, COUNT(*) requests, SUM(r.billable_tokens) tokens,
|
|
571
|
+
SUM(r.est_cost_usd) cost, MIN(r.ts) ts
|
|
572
|
+
FROM requests r JOIN projects pj ON pj.id=r.project_id
|
|
573
|
+
WHERE {w} AND r.is_sidechain=1 AND r.agent_id IS NOT NULL
|
|
574
|
+
GROUP BY r.agent_id ORDER BY tokens DESC LIMIT 20""", p)
|
|
575
|
+
ret = {x["server"]: x for x in a.q(f"""SELECT server, SUM(result_chars)/{CHARS_PER_TOKEN} t
|
|
576
|
+
FROM tool_calls tc WHERE kind='agent' AND {tcw} GROUP BY server""", p)}
|
|
577
|
+
for t in types:
|
|
578
|
+
t["returned_tokens"] = (ret.get(t["agent_type"]) or {}).get("t")
|
|
579
|
+
|
|
580
|
+
def ext_rows(kinds):
|
|
581
|
+
ph = ",".join("?" * len(kinds))
|
|
582
|
+
rows = a.q(f"""SELECT tc.kind, tc.server, COUNT(*) calls,
|
|
583
|
+
COUNT(DISTINCT tc.session_id) sessions,
|
|
584
|
+
SUM(tc.result_chars)/{CHARS_PER_TOKEN} injected,
|
|
585
|
+
SUM(tc.result_chars*1.0*tc.carry_requests)/{CHARS_PER_TOKEN} carried,
|
|
586
|
+
MAX(tc.result_chars)/{CHARS_PER_TOKEN} largest
|
|
587
|
+
FROM tool_calls tc WHERE tc.kind IN ({ph}) AND {tcw}
|
|
588
|
+
GROUP BY tc.kind, tc.server ORDER BY carried DESC""", kinds + p)
|
|
589
|
+
for r in rows:
|
|
590
|
+
r["carried_cost"] = (r["carried"] or 0) * rate
|
|
591
|
+
r["tools"] = a.q(f"""SELECT tc.name, COUNT(*) calls,
|
|
592
|
+
SUM(tc.result_chars)/{CHARS_PER_TOKEN} injected,
|
|
593
|
+
SUM(tc.result_chars*1.0*tc.carry_requests)/{CHARS_PER_TOKEN} carried
|
|
594
|
+
FROM tool_calls tc WHERE tc.server=? AND tc.kind=? AND {tcw}
|
|
595
|
+
GROUP BY tc.name ORDER BY carried DESC LIMIT 8""", [r["server"], r["kind"]] + p)
|
|
596
|
+
return rows
|
|
597
|
+
|
|
598
|
+
skills = ext_rows(["skill"])
|
|
599
|
+
for s in skills:
|
|
600
|
+
s["invoked_by"] = "Claude"
|
|
601
|
+
# user-typed slash commands / skills: attribute the whole turn
|
|
602
|
+
slash = a.q(f"""SELECT substr(pr.source, 8) server, COUNT(DISTINCT pr.id) calls,
|
|
603
|
+
COUNT(DISTINCT pr.session_id) sessions, SUM(r.billable_tokens) turn_tokens,
|
|
604
|
+
SUM(r.est_cost_usd) turn_cost
|
|
605
|
+
FROM prompts pr JOIN requests r ON r.prompt_id=pr.id
|
|
606
|
+
WHERE {w} AND pr.source LIKE 'slash:/%' GROUP BY pr.source ORDER BY turn_tokens DESC""", p)
|
|
607
|
+
for s in slash:
|
|
608
|
+
s["builtin"] = s["server"] in self.BUILTIN_CMDS
|
|
609
|
+
|
|
610
|
+
mcp = ext_rows(["mcp", "connector"])
|
|
611
|
+
glob, per = self._mcp_servers()
|
|
612
|
+
configured = set(glob) | {m for v in per.values() for m in v}
|
|
613
|
+
used = {r["server"] for r in a.q("SELECT DISTINCT server FROM tool_calls WHERE kind IN ('mcp','connector')")}
|
|
614
|
+
return {
|
|
615
|
+
"sessions": sessions,
|
|
616
|
+
"subagents": {"types": types, "runs": runs},
|
|
617
|
+
"skills": skills, "slash_commands": slash,
|
|
618
|
+
"mcp": [r for r in mcp if r["kind"] == "mcp"],
|
|
619
|
+
"connectors": [r for r in mcp if r["kind"] == "connector"],
|
|
620
|
+
"configured_unused_mcp": sorted(configured - used)
|
|
621
|
+
if "claude" in ((f or {}).get("agents") or ["claude"]) else [],
|
|
622
|
+
"cache_read_rate_per_mtok": rate * 1e6,
|
|
623
|
+
"note": "Injected = size of what came back into context (actual size, ~4 chars/token). "
|
|
624
|
+
"Carried = injected × later requests in the same session that re-read it — an "
|
|
625
|
+
"upper bound, since /compact isn't visible. Carried cost prices those re-reads at "
|
|
626
|
+
"your main model's cache-read rate (estimated). Subagent tokens are actual. "
|
|
627
|
+
"Slash-command figures attribute the whole turn they started.",
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# ------------------------------------------------------------------ session health
|
|
632
|
+
LIVE_WINDOW_S = 20 * 60
|
|
633
|
+
CTX_WARN, CTX_CRIT = 150_000, 300_000
|
|
634
|
+
|
|
635
|
+
def live_sessions(self):
|
|
636
|
+
"""Transcripts written in the last 20 minutes, read straight from disk."""
|
|
637
|
+
import time
|
|
638
|
+
now, out = time.time(), []
|
|
639
|
+
root = self.a.meta.get("source_dir") or os.path.join(CLAUDE_DIR, "projects")
|
|
640
|
+
for dirpath, _, names in os.walk(root):
|
|
641
|
+
if os.path.basename(dirpath) == "subagents":
|
|
642
|
+
continue
|
|
643
|
+
for n in names:
|
|
644
|
+
fp = os.path.join(dirpath, n)
|
|
645
|
+
if not n.endswith(".jsonl") or now - os.path.getmtime(fp) > self.LIVE_WINDOW_S:
|
|
646
|
+
continue
|
|
647
|
+
steps, last, first_ctx, cwd, title, tokens = 0, None, None, None, None, 0
|
|
648
|
+
with open(fp, errors="replace") as fh:
|
|
649
|
+
for line in fh:
|
|
650
|
+
try:
|
|
651
|
+
r = json.loads(line)
|
|
652
|
+
except ValueError:
|
|
653
|
+
continue
|
|
654
|
+
cwd = cwd or r.get("cwd")
|
|
655
|
+
if r.get("type") == "ai-title":
|
|
656
|
+
title = r.get("aiTitle")
|
|
657
|
+
u = (r.get("message") or {}).get("usage") if r.get("type") == "assistant" else None
|
|
658
|
+
if u:
|
|
659
|
+
ctx = (u.get("input_tokens") or 0) + (u.get("cache_read_input_tokens") or 0) \
|
|
660
|
+
+ (u.get("cache_creation_input_tokens") or 0)
|
|
661
|
+
steps += 1
|
|
662
|
+
tokens += ctx + (u.get("output_tokens") or 0)
|
|
663
|
+
first_ctx = first_ctx or ctx
|
|
664
|
+
last = ctx
|
|
665
|
+
if not last:
|
|
666
|
+
continue
|
|
667
|
+
sev = "high" if last >= self.CTX_CRIT else "medium" if last >= self.CTX_WARN else "ok"
|
|
668
|
+
out.append({
|
|
669
|
+
"session_id": n[:-6], "title": title, "project": os.path.basename(cwd or dirpath),
|
|
670
|
+
"idle_min": round((now - os.path.getmtime(fp)) / 60, 1), "steps": steps,
|
|
671
|
+
"context": last, "start_context": first_ctx, "tokens": tokens, "severity": sev,
|
|
672
|
+
"advice": ("Context is very large: every step re-reads ~%s tokens. Run /compact now, "
|
|
673
|
+
"or save state to a file and /clear." % f"{last:,}") if sev == "high" else
|
|
674
|
+
("Getting heavy. /compact at the next natural break; /clear if the "
|
|
675
|
+
"next task is unrelated.") if sev == "medium" else "Healthy.",
|
|
676
|
+
})
|
|
677
|
+
out.sort(key=lambda x: -x["context"])
|
|
678
|
+
return out
|
|
679
|
+
|
|
680
|
+
def session_health(self, f=None, limit=15):
|
|
681
|
+
"""Past sessions that carried too much context, with specific fixes."""
|
|
682
|
+
a = self.a
|
|
683
|
+
f = f or {}
|
|
684
|
+
w, p = a.where(f)
|
|
685
|
+
base = 100_000
|
|
686
|
+
rows = a.q(f"""SELECT r.session_id, s.title, pj.name project, COUNT(*) steps,
|
|
687
|
+
MAX(r.context_tokens) peak, AVG(r.context_tokens) avg_ctx,
|
|
688
|
+
SUM(r.billable_tokens) tokens, SUM(r.est_cost_usd) cost,
|
|
689
|
+
SUM(CASE WHEN r.context_tokens > {base} THEN r.context_tokens - {base} ELSE 0 END) over_base,
|
|
690
|
+
SUM(CASE WHEN r.context_tokens > {self.CTX_WARN} THEN 1 ELSE 0 END) heavy_steps,
|
|
691
|
+
COUNT(DISTINCT r.prompt_id) prompts, SUM(r.is_sidechain) side
|
|
692
|
+
FROM requests r JOIN sessions s ON s.id=r.session_id JOIN projects pj ON pj.id=r.project_id
|
|
693
|
+
WHERE {w} GROUP BY r.session_id HAVING peak >= ? ORDER BY over_base DESC LIMIT ?""",
|
|
694
|
+
p + [self.CTX_WARN, limit])
|
|
695
|
+
rate = a.pricing.rates(self._main_model(f)).get("cache_read", 0.3) / 1e6
|
|
696
|
+
compacts = {r["session_id"]: r["n"] for r in a.q(
|
|
697
|
+
"SELECT session_id, COUNT(*) n FROM prompts WHERE source='slash:/compact' GROUP BY 1")}
|
|
698
|
+
for r in rows:
|
|
699
|
+
sid = r["session_id"]
|
|
700
|
+
fixes = []
|
|
701
|
+
r["avoidable_tokens"] = r["over_base"]
|
|
702
|
+
r["avoidable_cost"] = r["over_base"] * rate
|
|
703
|
+
if not compacts.get(sid):
|
|
704
|
+
fixes.append(f"Never compacted. {r['heavy_steps']:,} steps ran above "
|
|
705
|
+
f"{self.CTX_WARN // 1000}K context; /compact (or /clear between the "
|
|
706
|
+
f"{r['prompts']} prompts) would have kept it near {base // 1000}K.")
|
|
707
|
+
if r["prompts"] >= 15:
|
|
708
|
+
fixes.append(f"{r['prompts']} prompts in one session. Split by task: one session "
|
|
709
|
+
f"per ticket/feature.")
|
|
710
|
+
reread = a.q("""SELECT path, COUNT(*) n FROM files_touched WHERE session_id=? AND op='read'
|
|
711
|
+
GROUP BY path HAVING n >= 3 ORDER BY n DESC LIMIT 3""", (sid,))
|
|
712
|
+
if reread:
|
|
713
|
+
fixes.append("Same files read again and again: " + ", ".join(
|
|
714
|
+
f"{os.path.basename(x['path'])} ×{x['n']}" for x in reread)
|
|
715
|
+
+ ". Key facts about them belong in CLAUDE.md.")
|
|
716
|
+
big = a.q(f"""SELECT name, target, result_chars/{CHARS_PER_TOKEN} t FROM tool_calls
|
|
717
|
+
WHERE session_id=? AND result_chars > 40000 ORDER BY result_chars DESC LIMIT 3""", (sid,))
|
|
718
|
+
if big:
|
|
719
|
+
fixes.append("Large tool outputs stayed in context: " + "; ".join(
|
|
720
|
+
f"{x['name'].split('__')[-1]} ~{x['t']:,} tok" for x in big)
|
|
721
|
+
+ ". Trim output (head/grep/quiet flags) or run it in a subagent.")
|
|
722
|
+
if not r["side"] and r["steps"] > 300:
|
|
723
|
+
fixes.append("No subagents used. Send wide searches/research to an Explore subagent "
|
|
724
|
+
"so the result, not the search, lands in this context.")
|
|
725
|
+
r["fixes"] = fixes
|
|
726
|
+
return rows
|
|
727
|
+
|
|
728
|
+
# ------------------------------------------------------------------ CLAUDE.md / memory quality
|
|
729
|
+
PATH_REF = re.compile(r"`((?:\.{0,2}/)?[\w.-]+(?:/[\w.-]+)+/?)`")
|
|
730
|
+
CMD_HINT = re.compile(r"\b(npm|yarn|pnpm|npx|make|pytest|python3? -m|go test|cargo|gradle|mvn|"
|
|
731
|
+
r"docker|\./[\w-]+\.sh)\b")
|
|
732
|
+
|
|
733
|
+
def claude_md_quality(self, path, files):
|
|
734
|
+
issues = []
|
|
735
|
+
text = ""
|
|
736
|
+
for fp in files:
|
|
737
|
+
with open(fp, errors="ignore") as fh:
|
|
738
|
+
text += fh.read() + "\n"
|
|
739
|
+
if not text.strip():
|
|
740
|
+
return issues
|
|
741
|
+
if not self.CMD_HINT.search(text):
|
|
742
|
+
issues.append({"severity": "medium", "title": "CLAUDE.md has no build/test commands",
|
|
743
|
+
"fix": "Add the exact commands to install, run, test and lint. Claude "
|
|
744
|
+
"otherwise rediscovers them from package.json/Makefile each time."})
|
|
745
|
+
# only refs to concrete files; a ref is stale if no file in the repo ends with it
|
|
746
|
+
refs = {m.lstrip("./") for m in self.PATH_REF.findall(text)
|
|
747
|
+
if re.search(r"\.\w{1,5}(:\d+)?$", m) and not m.startswith(("http", "~", "/"))}
|
|
748
|
+
refs = {re.sub(r":\d+$", "", m) for m in refs}
|
|
749
|
+
known = []
|
|
750
|
+
for dp, dns, fns in os.walk(path):
|
|
751
|
+
dns[:] = [d for d in dns if d not in ("node_modules", ".git", "dist", "build", ".next")]
|
|
752
|
+
known += [os.path.relpath(os.path.join(dp, n), path) for n in fns]
|
|
753
|
+
if len(known) > 60000:
|
|
754
|
+
break
|
|
755
|
+
dead = [m for m in refs if not any(k == m or k.endswith("/" + m) for k in known)]
|
|
756
|
+
if dead:
|
|
757
|
+
issues.append({"severity": "medium", "title": f"{len(dead)} stale path(s) in CLAUDE.md",
|
|
758
|
+
"fix": "These paths no longer exist, so Claude chases them: "
|
|
759
|
+
+ ", ".join(sorted(dead)[:5])})
|
|
760
|
+
lines = [l.strip() for l in text.splitlines() if len(l.strip()) > 30]
|
|
761
|
+
dup = {l for l in lines if lines.count(l) > 1}
|
|
762
|
+
if dup:
|
|
763
|
+
issues.append({"severity": "low", "title": f"{len(dup)} duplicated line(s) in CLAUDE.md",
|
|
764
|
+
"fix": "Remove repeats; each one is paid for on every request."})
|
|
765
|
+
return issues
|
|
766
|
+
|
|
767
|
+
def memory_quality(self, slug, heavy):
|
|
768
|
+
d = os.path.join(CLAUDE_DIR, "projects", slug or "", "memory")
|
|
769
|
+
idx = os.path.join(d, "MEMORY.md")
|
|
770
|
+
issues = []
|
|
771
|
+
if not os.path.exists(idx):
|
|
772
|
+
if heavy:
|
|
773
|
+
issues.append({"severity": "low", "title": "No auto-memory yet",
|
|
774
|
+
"fix": "Tell Claude \"remember …\" for preferences you keep repeating "
|
|
775
|
+
"(see Prompt-derived suggestions); it writes them to memory."})
|
|
776
|
+
return issues
|
|
777
|
+
with open(idx, errors="ignore") as fh:
|
|
778
|
+
lines = [l for l in fh.read().splitlines() if l.strip()]
|
|
779
|
+
long = [l for l in lines if len(l) > 200]
|
|
780
|
+
if long:
|
|
781
|
+
issues.append({"severity": "low", "title": f"{len(long)} long MEMORY.md index line(s)",
|
|
782
|
+
"fix": "The index loads every session; keep each line to a short hook "
|
|
783
|
+
"and put detail in the linked file."})
|
|
784
|
+
linked = set(re.findall(r"\]\(([^)]+\.md)\)", "\n".join(lines)))
|
|
785
|
+
files = {n for n in os.listdir(d) if n.endswith(".md") and n != "MEMORY.md"}
|
|
786
|
+
if linked - files:
|
|
787
|
+
issues.append({"severity": "medium", "title": "MEMORY.md links to missing files",
|
|
788
|
+
"fix": ", ".join(sorted(linked - files)[:5])})
|
|
789
|
+
if files - linked:
|
|
790
|
+
issues.append({"severity": "low", "title": f"{len(files - linked)} memory file(s) not indexed",
|
|
791
|
+
"fix": "Unindexed memories are never recalled: " + ", ".join(sorted(files - linked)[:5])})
|
|
792
|
+
return issues
|
|
793
|
+
|
|
794
|
+
# ------------------------------------------------------------------ prompt-derived memory
|
|
795
|
+
INSTRUCTION = re.compile(r"\b(always|never|don'?t|do not|avoid|make sure|remember|prefer|"
|
|
796
|
+
r"use|only|must|should|reply|answer|in english|crisp|short|commit|"
|
|
797
|
+
r"mat|nahi|hamesha|kabhi|karo|krna|chahiye)\b", re.I)
|
|
798
|
+
SPLIT = re.compile(r"(?<=[.!?\n])\s+|\s*[;\n]\s*")
|
|
799
|
+
PATHISH = re.compile(r"(?:~|/Users/|https?://)[^\s'\"`)]+")
|
|
800
|
+
|
|
801
|
+
THEMES = [
|
|
802
|
+
("git", "Git/commit rules (co-author, push, branch)",
|
|
803
|
+
r"\b(commit|push|co-?authou?red|coauthor|cherry.?pick|branch|pr\b)"),
|
|
804
|
+
("language", "Reply language", r"\b(english|hindi|hinglish)\b"),
|
|
805
|
+
("brevity", "Answer length/style", r"\b(crisp|concise|short|brief|to the point|chota|detail me)\b"),
|
|
806
|
+
("scope", "Keep changes small / don't redesign", r"\b(don'?t|dont|mt|mat) (redesign|change|touch|break)|\bonly (fix|change)\b|theek (kr|kar)"),
|
|
807
|
+
("tests", "How to test / verify", r"\b(run (the )?tests?|playwright|e2e|screenshot|verify)\b"),
|
|
808
|
+
("secrets", "Credentials pasted in prompts", r"(://[^\s/:]+:[^\s@]+@|password\s*[=:]|api[_-]?key\s*[=:]|token\s*[=:])"),
|
|
809
|
+
]
|
|
810
|
+
SECRET = re.compile(r"(://[^\s/:]+:)[^\s@]+@|((?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*)\S+", re.I)
|
|
811
|
+
|
|
812
|
+
def _redact(self, t):
|
|
813
|
+
return self.SECRET.sub(lambda m: (m.group(1) + "***@") if m.group(1) else (m.group(2) + "***"), t)
|
|
814
|
+
|
|
815
|
+
def _norm(self, t):
|
|
816
|
+
t = re.sub(r"[^\w\s/'-]", " ", t.lower())
|
|
817
|
+
t = re.sub(r"\d+", "#", t)
|
|
818
|
+
return re.sub(r"\s+", " ", t).strip()
|
|
819
|
+
|
|
820
|
+
def memory_suggestions(self, f=None):
|
|
821
|
+
a = self.a
|
|
822
|
+
f = f or {}
|
|
823
|
+
w, p = a.where(f)
|
|
824
|
+
prompts = a.q(f"""SELECT pr.id, pr.text, pr.session_id, pj.path, pj.slug
|
|
825
|
+
FROM prompts pr JOIN projects pj ON pj.id=pr.project_id
|
|
826
|
+
WHERE pr.id IN (SELECT DISTINCT r.prompt_id FROM requests r WHERE {w})
|
|
827
|
+
AND pj.is_sandbox=0 AND pr.char_len <= 1500
|
|
828
|
+
AND (pr.source IS NULL OR pr.source NOT LIKE 'slash:%')
|
|
829
|
+
AND pr.char_len BETWEEN 3 AND 4000 AND pr.text NOT LIKE '<%'
|
|
830
|
+
AND pr.text NOT LIKE 'You are %'""", p)
|
|
831
|
+
clauses = defaultdict(lambda: {"sessions": set(), "examples": [], "projects": Counter(),
|
|
832
|
+
"slugs": Counter()})
|
|
833
|
+
paths = defaultdict(lambda: {"sessions": set(), "projects": Counter(), "slugs": Counter()})
|
|
834
|
+
prefixes = defaultdict(lambda: {"sessions": set(), "example": None, "projects": Counter()})
|
|
835
|
+
themes = defaultdict(lambda: {"sessions": set(), "examples": [], "projects": Counter(),
|
|
836
|
+
"slugs": Counter()})
|
|
837
|
+
for pr in prompts:
|
|
838
|
+
text = pr["text"]
|
|
839
|
+
for c in self.SPLIT.split(text):
|
|
840
|
+
c = c.strip()
|
|
841
|
+
n = self._norm(c)
|
|
842
|
+
if not (3 <= len(n.split()) <= 25) or not self.INSTRUCTION.search(n):
|
|
843
|
+
continue
|
|
844
|
+
e = clauses[n]
|
|
845
|
+
e["sessions"].add(pr["session_id"])
|
|
846
|
+
e["projects"][pr["path"]] += 1
|
|
847
|
+
e["slugs"][pr["slug"]] += 1
|
|
848
|
+
if len(e["examples"]) < 2 and c not in e["examples"]:
|
|
849
|
+
e["examples"].append(c[:200])
|
|
850
|
+
low = text.lower()
|
|
851
|
+
for key, label, rx in self.THEMES:
|
|
852
|
+
if re.search(rx, low):
|
|
853
|
+
e = themes[key]
|
|
854
|
+
e["sessions"].add(pr["session_id"])
|
|
855
|
+
e["projects"][pr["path"]] += 1
|
|
856
|
+
e["slugs"][pr["slug"]] += 1
|
|
857
|
+
if len(e["examples"]) < 3:
|
|
858
|
+
e["examples"].append(text.strip()[:160])
|
|
859
|
+
for m in set(self.PATHISH.findall(text)):
|
|
860
|
+
m = m.rstrip(".,:")
|
|
861
|
+
if (pr["path"] and m.startswith(pr["path"])) or "/T/" in m or "/tmp/" in m:
|
|
862
|
+
continue # inside the repo: Claude finds these itself
|
|
863
|
+
e = paths[m]
|
|
864
|
+
e["sessions"].add(pr["session_id"])
|
|
865
|
+
e["projects"][pr["path"]] += 1
|
|
866
|
+
if len(text) > 300:
|
|
867
|
+
k = self._norm(text[:160])
|
|
868
|
+
e = prefixes[k]
|
|
869
|
+
e["sessions"].add(pr["session_id"])
|
|
870
|
+
e["projects"][pr["path"]] += 1
|
|
871
|
+
e["example"] = e["example"] or text[:220]
|
|
872
|
+
|
|
873
|
+
known_cache = {}
|
|
874
|
+
|
|
875
|
+
def known_text(path, slug):
|
|
876
|
+
if (path, slug) not in known_cache:
|
|
877
|
+
t = ""
|
|
878
|
+
cands = [os.path.join(CLAUDE_DIR, "CLAUDE.md")]
|
|
879
|
+
if path:
|
|
880
|
+
cands += [os.path.join(path, "CLAUDE.md"), os.path.join(path, ".claude", "CLAUDE.md"),
|
|
881
|
+
os.path.join(path, "CLAUDE.local.md")]
|
|
882
|
+
md = os.path.join(CLAUDE_DIR, "projects", slug or "", "memory")
|
|
883
|
+
if os.path.isdir(md):
|
|
884
|
+
cands += [os.path.join(md, n) for n in os.listdir(md)]
|
|
885
|
+
for fp in cands:
|
|
886
|
+
if os.path.isfile(fp):
|
|
887
|
+
with open(fp, errors="ignore") as fh:
|
|
888
|
+
t += fh.read().lower() + "\n"
|
|
889
|
+
known_cache[(path, slug)] = self._norm(t)
|
|
890
|
+
return known_cache[(path, slug)]
|
|
891
|
+
|
|
892
|
+
def covered(n, path, slug):
|
|
893
|
+
kt = known_text(path, slug)
|
|
894
|
+
words = [x for x in n.split() if len(x) > 3]
|
|
895
|
+
return bool(words) and sum(1 for x in words if x in kt) / len(words) >= 0.7
|
|
896
|
+
|
|
897
|
+
out = []
|
|
898
|
+
labels = {k: l for k, l, _ in self.THEMES}
|
|
899
|
+
for key, e in themes.items():
|
|
900
|
+
if len(e["sessions"]) < 2:
|
|
901
|
+
continue
|
|
902
|
+
multi = len(e["projects"]) > 1
|
|
903
|
+
if key == "secrets":
|
|
904
|
+
out.append({"kind": "security", "text": labels[key], "sessions": len(e["sessions"]),
|
|
905
|
+
"projects": [os.path.basename(x or "") for x in e["projects"]],
|
|
906
|
+
"examples": [self._redact(x) for x in e["examples"]],
|
|
907
|
+
"target": "An env file / secret manager — not prompts or memory",
|
|
908
|
+
"why": f"Credentials appear in prompts in {len(e['sessions'])} sessions; "
|
|
909
|
+
f"they are now stored in plain text in your transcripts."})
|
|
910
|
+
continue
|
|
911
|
+
path = e["projects"].most_common(1)[0][0]
|
|
912
|
+
slug = e["slugs"].most_common(1)[0][0]
|
|
913
|
+
rx = dict((k, r) for k, _, r in self.THEMES)[key]
|
|
914
|
+
in_mem = bool(re.search(rx, known_text(path, slug)))
|
|
915
|
+
out.append({"kind": "theme", "text": labels[key], "already_saved": in_mem, "sessions": len(e["sessions"]),
|
|
916
|
+
"projects": [os.path.basename(x or "") for x in e["projects"]],
|
|
917
|
+
"examples": [self._redact(x) for x in e["examples"]],
|
|
918
|
+
"target": "~/.claude/CLAUDE.md or memory (applies everywhere)" if multi
|
|
919
|
+
else "that project's CLAUDE.md or memory",
|
|
920
|
+
"why": (f"Already in CLAUDE.md/memory, yet you repeated it in "
|
|
921
|
+
f"{len(e['sessions'])} sessions — make the rule more explicit, or "
|
|
922
|
+
f"update it if your preference changed.") if in_mem else
|
|
923
|
+
(f"You gave this kind of instruction in {len(e['sessions'])} sessions. "
|
|
924
|
+
f"Check the examples; if it's a standing rule, write it down once.")})
|
|
925
|
+
for n, e in clauses.items():
|
|
926
|
+
if len(e["sessions"]) < 2:
|
|
927
|
+
continue
|
|
928
|
+
path = e["projects"].most_common(1)[0][0]
|
|
929
|
+
slug = e["slugs"].most_common(1)[0][0]
|
|
930
|
+
if covered(n, path, slug):
|
|
931
|
+
continue
|
|
932
|
+
multi = len(e["projects"]) > 1
|
|
933
|
+
out.append({"kind": "instruction", "text": self._redact(e["examples"][0]), "sessions": len(e["sessions"]),
|
|
934
|
+
"projects": [os.path.basename(x or "") for x in e["projects"]],
|
|
935
|
+
"target": "~/.claude/CLAUDE.md (applies everywhere)" if multi
|
|
936
|
+
else f"{os.path.basename(path or '')}: CLAUDE.md or memory",
|
|
937
|
+
"why": f"You typed this in {len(e['sessions'])} different sessions."})
|
|
938
|
+
for m, e in paths.items():
|
|
939
|
+
if len(e["sessions"]) < 2 or "/browse/" in m or (
|
|
940
|
+
not m.startswith("http") and not os.path.exists(os.path.expanduser(m))):
|
|
941
|
+
continue
|
|
942
|
+
path = e["projects"].most_common(1)[0][0]
|
|
943
|
+
out.append({"kind": "reference", "text": self._redact(m), "sessions": len(e["sessions"]),
|
|
944
|
+
"projects": [os.path.basename(x or "") for x in e["projects"]],
|
|
945
|
+
"target": f"{os.path.basename(path or '')}: CLAUDE.md (\"Related locations\")",
|
|
946
|
+
"why": f"Pasted into prompts in {len(e['sessions'])} sessions; note what it is "
|
|
947
|
+
f"once so you can refer to it by name."})
|
|
948
|
+
for k, e in prefixes.items():
|
|
949
|
+
if len(e["sessions"]) < 2:
|
|
950
|
+
continue
|
|
951
|
+
out.append({"kind": "template", "text": self._redact(e["example"]), "sessions": len(e["sessions"]),
|
|
952
|
+
"projects": [os.path.basename(x or "") for x in e["projects"]],
|
|
953
|
+
"target": "A skill or slash command (.claude/skills/<name>/SKILL.md)",
|
|
954
|
+
"why": f"Same long prompt opening reused in {len(e['sessions'])} sessions; "
|
|
955
|
+
f"make it a /command instead of pasting it."})
|
|
956
|
+
out.sort(key=lambda x: -x["sessions"])
|
|
957
|
+
return out[:40]
|
|
958
|
+
|
|
959
|
+
# ------------------------------------------------------------------ entry point
|
|
960
|
+
def _agent(self, f):
|
|
961
|
+
"""The agent the advice is written for: the one that spent most tokens in range."""
|
|
962
|
+
w, p = self.a.where(f)
|
|
963
|
+
r = self.a.one(f"SELECT r.agent a FROM requests r WHERE {w} AND r.agent IN "
|
|
964
|
+
f"('claude','codex','gemini') GROUP BY r.agent "
|
|
965
|
+
f"ORDER BY SUM(r.billable_tokens) DESC LIMIT 1", p)
|
|
966
|
+
return r.get("a") or "claude"
|
|
967
|
+
|
|
968
|
+
def run(self, f=None):
|
|
969
|
+
f = f or {}
|
|
970
|
+
self.agent = self._agent(f)
|
|
971
|
+
self.v = VOCAB[self.agent]
|
|
972
|
+
drv = self.drivers(f)
|
|
973
|
+
tr = self.trend(f)
|
|
974
|
+
pj = self.projects(f)
|
|
975
|
+
gl = self.global_config() if self.agent == "claude" else self.agent_global_config()
|
|
976
|
+
recs = self.recommendations(f, drv, pj)
|
|
977
|
+
top = drv["drivers"][:3]
|
|
978
|
+
headline = ("Your tokens are high mainly because of: "
|
|
979
|
+
+ "; ".join(f"{d['title'].lower()} ({d['share_pct']}%)" for d in top)
|
|
980
|
+
+ ".") if top else "No usage in the selected range."
|
|
981
|
+
from .playbook import attach
|
|
982
|
+
wants = f.get("agents") or ["claude"]
|
|
983
|
+
res = attach({"headline": headline, "trend": tr, **drv, "recommendations": recs,
|
|
984
|
+
"live_sessions": self.live_sessions() if "claude" in wants else [], "session_health": self.session_health(f),
|
|
985
|
+
"memory_suggestions": self.memory_suggestions(f),
|
|
986
|
+
"projects": pj, "global": gl,
|
|
987
|
+
"note": "Token counts are actual. Dollar figures are estimated. Config checks "
|
|
988
|
+
f"read files on disk now; token sizes use ~{CHARS_PER_TOKEN} chars/token.",
|
|
989
|
+
"basis": "mixed", "agent": self.agent, "vocab": {k: v for k, v in self.v.items()
|
|
990
|
+
if isinstance(v, str)}})
|
|
991
|
+
return res if self.agent == "claude" else _swap_md(res, self.v["md"])
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _swap_md(obj, md):
|
|
995
|
+
"""Advice text for a non-Claude agent: point at its instructions file, not CLAUDE.md."""
|
|
996
|
+
if isinstance(obj, str):
|
|
997
|
+
return obj.replace("CLAUDE.md", md).replace("Claude Code", "the agent")
|
|
998
|
+
if isinstance(obj, list):
|
|
999
|
+
return [_swap_md(x, md) for x in obj]
|
|
1000
|
+
if isinstance(obj, dict):
|
|
1001
|
+
return {k: (x if k in ("path", "paths", "session_id") else _swap_md(x, md)) for k, x in obj.items()}
|
|
1002
|
+
return obj
|