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.
@@ -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