@jeganwrites/claudash 1.0.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/CONTRIBUTING.md +35 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/analyzer.py +890 -0
- package/bin/claudash.js +121 -0
- package/claude_ai_tracker.py +358 -0
- package/cli.py +1034 -0
- package/config.py +100 -0
- package/db.py +1156 -0
- package/fix_tracker.py +539 -0
- package/insights.py +359 -0
- package/mcp_server.py +414 -0
- package/package.json +39 -0
- package/scanner.py +385 -0
- package/server.py +762 -0
- package/templates/accounts.html +936 -0
- package/templates/dashboard.html +1742 -0
- package/tools/get-derived-keys.py +112 -0
- package/tools/mac-sync.py +386 -0
- package/tools/oauth_sync.py +308 -0
- package/tools/setup-pm2.sh +53 -0
- package/waste_patterns.py +334 -0
package/insights.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Insights engine — runs after every scan, generates actionable insights."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime, timezone, timedelta
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
from config import MODEL_PRICING, COST_TARGETS
|
|
9
|
+
from db import get_conn, insert_insight, get_insights, get_accounts_config, get_project_map_config
|
|
10
|
+
from analyzer import (
|
|
11
|
+
account_metrics, window_metrics, project_metrics,
|
|
12
|
+
compaction_metrics, model_rightsizing,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _now():
|
|
17
|
+
return int(time.time())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _days_ago(n):
|
|
21
|
+
return _now() - (n * 86400)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fetch_rows(conn, account=None, since=None):
|
|
25
|
+
sql = "SELECT * FROM sessions WHERE 1=1"
|
|
26
|
+
params = []
|
|
27
|
+
if account and account != "all":
|
|
28
|
+
sql += " AND account = ?"
|
|
29
|
+
params.append(account)
|
|
30
|
+
if since:
|
|
31
|
+
sql += " AND timestamp >= ?"
|
|
32
|
+
params.append(since)
|
|
33
|
+
return conn.execute(sql, params).fetchall()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _clear_stale_insights(conn, max_age_hours=24):
|
|
37
|
+
cutoff = _now() - (max_age_hours * 3600)
|
|
38
|
+
conn.execute("DELETE FROM insights WHERE dismissed = 0 AND created_at < ?", (cutoff,))
|
|
39
|
+
conn.commit()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _insight_exists_recent(conn, insight_type, project, hours=12):
|
|
43
|
+
cutoff = _now() - (hours * 3600)
|
|
44
|
+
row = conn.execute(
|
|
45
|
+
"SELECT COUNT(*) FROM insights WHERE insight_type = ? AND project = ? AND created_at > ? AND dismissed = 0",
|
|
46
|
+
(insight_type, project, cutoff),
|
|
47
|
+
).fetchone()
|
|
48
|
+
return row[0] > 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def generate_insights(conn=None):
|
|
52
|
+
"""Run all insight rules. Returns count of new insights generated."""
|
|
53
|
+
should_close = False
|
|
54
|
+
if conn is None:
|
|
55
|
+
conn = get_conn()
|
|
56
|
+
should_close = True
|
|
57
|
+
|
|
58
|
+
ACCOUNTS = get_accounts_config(conn)
|
|
59
|
+
PROJECT_MAP = get_project_map_config(conn)
|
|
60
|
+
|
|
61
|
+
_clear_stale_insights(conn)
|
|
62
|
+
generated = 0
|
|
63
|
+
|
|
64
|
+
# ── 1. MODEL_WASTE ──
|
|
65
|
+
for acct_key in ACCOUNTS:
|
|
66
|
+
rs = model_rightsizing(conn, acct_key)
|
|
67
|
+
for s in rs:
|
|
68
|
+
if _insight_exists_recent(conn, "model_waste", s["project"]):
|
|
69
|
+
continue
|
|
70
|
+
msg = (
|
|
71
|
+
f"{s['project']} uses Opus but avg response is {s['avg_output_tokens']} tokens "
|
|
72
|
+
f"— Sonnet saves ~${s['monthly_savings']:.2f}/mo"
|
|
73
|
+
)
|
|
74
|
+
detail = json.dumps({"avg_output": s["avg_output_tokens"], "savings": s["monthly_savings"]})
|
|
75
|
+
insert_insight(conn, acct_key, s["project"], "model_waste", msg, detail)
|
|
76
|
+
generated += 1
|
|
77
|
+
|
|
78
|
+
# ── 2. CACHE_SPIKE ──
|
|
79
|
+
rows_7d = _fetch_rows(conn, since=_days_ago(7))
|
|
80
|
+
rows_24h = _fetch_rows(conn, since=_days_ago(1))
|
|
81
|
+
|
|
82
|
+
project_cache_7d = defaultdict(int)
|
|
83
|
+
for r in rows_7d:
|
|
84
|
+
project_cache_7d[r["project"]] += r["cache_creation_tokens"]
|
|
85
|
+
|
|
86
|
+
project_cache_24h = defaultdict(int)
|
|
87
|
+
for r in rows_24h:
|
|
88
|
+
project_cache_24h[r["project"]] += r["cache_creation_tokens"]
|
|
89
|
+
|
|
90
|
+
for p, cache_24h in project_cache_24h.items():
|
|
91
|
+
avg_daily = project_cache_7d.get(p, 0) / 7
|
|
92
|
+
if avg_daily > 0 and cache_24h > avg_daily * 3:
|
|
93
|
+
if _insight_exists_recent(conn, "cache_spike", p):
|
|
94
|
+
continue
|
|
95
|
+
ratio = round(cache_24h / avg_daily, 1)
|
|
96
|
+
acct = "personal_max"
|
|
97
|
+
for proj_name, info in PROJECT_MAP.items():
|
|
98
|
+
if proj_name == p:
|
|
99
|
+
acct = info["account"]
|
|
100
|
+
break
|
|
101
|
+
msg = f"{p} cache creation spiked {ratio}x — possible CLAUDE.md reload bug"
|
|
102
|
+
detail = json.dumps({"ratio": ratio, "cache_24h": cache_24h, "avg_daily": round(avg_daily)})
|
|
103
|
+
insert_insight(conn, acct, p, "cache_spike", msg, detail)
|
|
104
|
+
generated += 1
|
|
105
|
+
|
|
106
|
+
# ── 3. COMPACTION_GAP ──
|
|
107
|
+
for acct_key in ACCOUNTS:
|
|
108
|
+
comp = compaction_metrics(conn, acct_key)
|
|
109
|
+
if comp["sessions_needing_compact"] > 0:
|
|
110
|
+
if _insight_exists_recent(conn, "compaction_gap", acct_key):
|
|
111
|
+
continue
|
|
112
|
+
n = comp["sessions_needing_compact"]
|
|
113
|
+
msg = f"{n} sessions this week hit 80% context with no /compact — risk of context rot"
|
|
114
|
+
detail = json.dumps({"sessions_needing_compact": n})
|
|
115
|
+
insert_insight(conn, acct_key, acct_key, "compaction_gap", msg, detail)
|
|
116
|
+
generated += 1
|
|
117
|
+
|
|
118
|
+
# ── 4. COST_TARGET_HIT ──
|
|
119
|
+
projs = project_metrics(conn)
|
|
120
|
+
for pm in projs:
|
|
121
|
+
target = COST_TARGETS.get(pm["name"])
|
|
122
|
+
if target and pm["avg_cost_per_session"] <= target and pm["avg_cost_per_session"] > 0:
|
|
123
|
+
if _insight_exists_recent(conn, "cost_target", pm["name"]):
|
|
124
|
+
continue
|
|
125
|
+
msg = f"{pm['name']} hit ${target:.2f}/file target — avg ${pm['avg_cost_per_session']:.4f}/session"
|
|
126
|
+
detail = json.dumps({"target": target, "actual": pm["avg_cost_per_session"]})
|
|
127
|
+
insert_insight(conn, pm["account"], pm["name"], "cost_target", msg, detail)
|
|
128
|
+
generated += 1
|
|
129
|
+
|
|
130
|
+
# ── 5. WINDOW_RISK ──
|
|
131
|
+
for acct_key in ACCOUNTS:
|
|
132
|
+
wm = window_metrics(conn, acct_key)
|
|
133
|
+
if wm["minutes_to_limit"] is not None and wm["minutes_to_limit"] < 60:
|
|
134
|
+
if _insight_exists_recent(conn, "window_risk", acct_key):
|
|
135
|
+
continue
|
|
136
|
+
label = ACCOUNTS[acct_key]["label"]
|
|
137
|
+
pct = wm["window_pct"]
|
|
138
|
+
predicted = ""
|
|
139
|
+
if wm["predicted_limit_time"]:
|
|
140
|
+
predicted = datetime.fromtimestamp(
|
|
141
|
+
wm["predicted_limit_time"], tz=timezone.utc
|
|
142
|
+
).strftime("%H:%M UTC")
|
|
143
|
+
msg = f"{label} window at {pct:.0f}% — exhaust predicted at {predicted}"
|
|
144
|
+
detail = json.dumps({"pct": pct, "minutes_left": wm["minutes_to_limit"]})
|
|
145
|
+
insert_insight(conn, acct_key, acct_key, "window_risk", msg, detail)
|
|
146
|
+
generated += 1
|
|
147
|
+
|
|
148
|
+
# ── 6. ROI_MILESTONE ──
|
|
149
|
+
for acct_key, acct_info in ACCOUNTS.items():
|
|
150
|
+
am = account_metrics(conn, acct_key)
|
|
151
|
+
roi = am.get("subscription_roi", 0)
|
|
152
|
+
for threshold in [10, 5, 2]:
|
|
153
|
+
if roi >= threshold:
|
|
154
|
+
milestone_key = f"roi_{threshold}x"
|
|
155
|
+
if _insight_exists_recent(conn, "roi_milestone", f"{acct_key}_{milestone_key}", hours=168):
|
|
156
|
+
break
|
|
157
|
+
label = acct_info["label"]
|
|
158
|
+
plan_cost = acct_info.get("monthly_cost_usd", 0)
|
|
159
|
+
api_equiv = round(roi * plan_cost, 0)
|
|
160
|
+
msg = f"{label} ROI crossed {threshold}x this month — ${api_equiv:.0f} API equiv on ${plan_cost} plan"
|
|
161
|
+
detail = json.dumps({"roi": roi, "threshold": threshold, "api_equiv": api_equiv})
|
|
162
|
+
insert_insight(conn, acct_key, f"{acct_key}_{milestone_key}", "roi_milestone", msg, detail)
|
|
163
|
+
generated += 1
|
|
164
|
+
break
|
|
165
|
+
|
|
166
|
+
# ── 7. HEAVY_DAY_PATTERN ──
|
|
167
|
+
for acct_key in ACCOUNTS:
|
|
168
|
+
rows_30d_acct = _fetch_rows(conn, acct_key, _days_ago(30))
|
|
169
|
+
day_sessions = defaultdict(lambda: defaultdict(int))
|
|
170
|
+
for r in rows_30d_acct:
|
|
171
|
+
dow = datetime.fromtimestamp(r["timestamp"], tz=timezone.utc).strftime("%A")
|
|
172
|
+
day_sessions[dow][r["project"]] += 1
|
|
173
|
+
|
|
174
|
+
if day_sessions:
|
|
175
|
+
heaviest = max(day_sessions.items(), key=lambda x: sum(x[1].values()))
|
|
176
|
+
day_name = heaviest[0]
|
|
177
|
+
total = sum(heaviest[1].values())
|
|
178
|
+
avg_day = sum(sum(v.values()) for v in day_sessions.values()) / len(day_sessions)
|
|
179
|
+
if total > avg_day * 1.5:
|
|
180
|
+
top_project = max(heaviest[1].items(), key=lambda x: x[1])[0]
|
|
181
|
+
if not _insight_exists_recent(conn, "heavy_day", f"{acct_key}_{day_name}", hours=168):
|
|
182
|
+
label = ACCOUNTS[acct_key]["label"]
|
|
183
|
+
msg = f"{day_name}s are your heaviest Claude day — {top_project} pattern"
|
|
184
|
+
detail = json.dumps({"day": day_name, "sessions": total, "top_project": top_project})
|
|
185
|
+
insert_insight(conn, acct_key, f"{acct_key}_{day_name}", "heavy_day", msg, detail)
|
|
186
|
+
generated += 1
|
|
187
|
+
|
|
188
|
+
# ── 8. BEST_WINDOW ──
|
|
189
|
+
for acct_key in ACCOUNTS:
|
|
190
|
+
rows_7d_acct = _fetch_rows(conn, acct_key, _days_ago(7))
|
|
191
|
+
hour_tokens = defaultdict(int)
|
|
192
|
+
for r in rows_7d_acct:
|
|
193
|
+
h = datetime.fromtimestamp(r["timestamp"], tz=timezone.utc).hour
|
|
194
|
+
hour_tokens[h] += r["input_tokens"] + r["output_tokens"]
|
|
195
|
+
|
|
196
|
+
if hour_tokens:
|
|
197
|
+
best_start = 0
|
|
198
|
+
min_usage = float("inf")
|
|
199
|
+
for start_h in range(24):
|
|
200
|
+
block = sum(hour_tokens.get((start_h + i) % 24, 0) for i in range(5))
|
|
201
|
+
if block < min_usage:
|
|
202
|
+
min_usage = block
|
|
203
|
+
best_start = start_h
|
|
204
|
+
|
|
205
|
+
if not _insight_exists_recent(conn, "best_window", acct_key, hours=168):
|
|
206
|
+
end_h = (best_start + 5) % 24
|
|
207
|
+
msg = f"Your quietest window is {best_start}:00-{end_h}:00 UTC — ideal for autonomous runs"
|
|
208
|
+
detail = json.dumps({"start_hour": best_start, "end_hour": end_h, "tokens_in_block": min_usage})
|
|
209
|
+
insert_insight(conn, acct_key, acct_key, "best_window", msg, detail)
|
|
210
|
+
generated += 1
|
|
211
|
+
|
|
212
|
+
# ── 9. WINDOW_COMBINED_RISK (Code + Browser combined > 80%) ──
|
|
213
|
+
try:
|
|
214
|
+
from db import get_claude_ai_accounts_all, get_latest_claude_ai_snapshot
|
|
215
|
+
browser_accts = get_claude_ai_accounts_all(conn)
|
|
216
|
+
for ba in browser_accts:
|
|
217
|
+
aid = ba["account_id"]
|
|
218
|
+
if ba.get("status") != "active":
|
|
219
|
+
continue
|
|
220
|
+
snap = get_latest_claude_ai_snapshot(conn, aid)
|
|
221
|
+
if not snap:
|
|
222
|
+
continue
|
|
223
|
+
# Get Code window pct
|
|
224
|
+
code_wm = window_metrics(conn, aid)
|
|
225
|
+
code_pct = code_wm.get("window_pct", 0)
|
|
226
|
+
browser_pct = snap.get("pct_used", 0)
|
|
227
|
+
acct_info = ACCOUNTS.get(aid, {})
|
|
228
|
+
limit = acct_info.get("window_token_limit", 1_000_000)
|
|
229
|
+
# Combined estimate (both eat from same window)
|
|
230
|
+
combined_pct = code_pct + browser_pct
|
|
231
|
+
if combined_pct > 80:
|
|
232
|
+
if not _insight_exists_recent(conn, "window_combined_risk", aid):
|
|
233
|
+
label = acct_info.get("label", aid)
|
|
234
|
+
msg = f"Combined window (Code + browser) at {combined_pct:.0f}% for {label} — slow down"
|
|
235
|
+
detail = json.dumps({"code_pct": code_pct, "browser_pct": browser_pct, "combined": combined_pct})
|
|
236
|
+
insert_insight(conn, aid, aid, "window_combined_risk", msg, detail)
|
|
237
|
+
generated += 1
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
# ── 10. SESSION_EXPIRY_WARNING ──
|
|
242
|
+
try:
|
|
243
|
+
for ba in browser_accts:
|
|
244
|
+
aid = ba["account_id"]
|
|
245
|
+
if ba.get("status") == "expired":
|
|
246
|
+
last_polled = ba.get("last_polled", 0) or 0
|
|
247
|
+
if _now() - last_polled > 1800: # > 30 min stale
|
|
248
|
+
if not _insight_exists_recent(conn, "session_expiry", aid):
|
|
249
|
+
label = ACCOUNTS.get(aid, {}).get("label", aid)
|
|
250
|
+
msg = f"{label} claude.ai session expired — update key in Accounts"
|
|
251
|
+
insert_insight(conn, aid, aid, "session_expiry", msg, "{}")
|
|
252
|
+
generated += 1
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
# ── 11. PRO_MESSAGES_LOW ──
|
|
257
|
+
try:
|
|
258
|
+
for ba in browser_accts:
|
|
259
|
+
aid = ba["account_id"]
|
|
260
|
+
if ba.get("status") != "active" or ba.get("plan") != "pro":
|
|
261
|
+
continue
|
|
262
|
+
snap = get_latest_claude_ai_snapshot(conn, aid)
|
|
263
|
+
if not snap:
|
|
264
|
+
continue
|
|
265
|
+
msgs_used = snap.get("messages_used", 0)
|
|
266
|
+
msgs_limit = snap.get("messages_limit", 0)
|
|
267
|
+
if msgs_limit > 0 and msgs_used / msgs_limit > 0.7:
|
|
268
|
+
if not _insight_exists_recent(conn, "pro_messages_low", aid):
|
|
269
|
+
label = ACCOUNTS.get(aid, {}).get("label", aid)
|
|
270
|
+
msg = f"{label} at {msgs_used}/{msgs_limit} messages — consider spacing out conversations"
|
|
271
|
+
detail = json.dumps({"used": msgs_used, "limit": msgs_limit})
|
|
272
|
+
insert_insight(conn, aid, aid, "pro_messages_low", msg, detail)
|
|
273
|
+
generated += 1
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
# ── 12. SUBAGENT_COST_SPIKE ──
|
|
278
|
+
try:
|
|
279
|
+
from analyzer import subagent_metrics as _subagent_metrics
|
|
280
|
+
sm = _subagent_metrics(conn, "all")
|
|
281
|
+
for proj, s in sm.items():
|
|
282
|
+
if s["subagent_pct_of_total"] > 30 and s["subagent_session_count"] > 0:
|
|
283
|
+
if _insight_exists_recent(conn, "subagent_cost_spike", proj):
|
|
284
|
+
continue
|
|
285
|
+
pct = s["subagent_pct_of_total"]
|
|
286
|
+
cost = s["subagent_cost_usd"]
|
|
287
|
+
msg = (f"{proj} sub-agents consumed {pct:.0f}% of project cost "
|
|
288
|
+
f"(${cost:.2f}) — check if orchestration is efficient")
|
|
289
|
+
detail = json.dumps({"pct": pct, "cost": cost,
|
|
290
|
+
"sessions": s["subagent_session_count"]})
|
|
291
|
+
# Pick any account for this project (first match)
|
|
292
|
+
acct_row = conn.execute(
|
|
293
|
+
"SELECT account FROM sessions WHERE project=? LIMIT 1", (proj,)
|
|
294
|
+
).fetchone()
|
|
295
|
+
acct_key = acct_row["account"] if acct_row else "all"
|
|
296
|
+
insert_insight(conn, acct_key, proj, "subagent_cost_spike", msg, detail)
|
|
297
|
+
generated += 1
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
# ── 13. FLOUNDERING_DETECTED ──
|
|
302
|
+
try:
|
|
303
|
+
floundering_rows = conn.execute(
|
|
304
|
+
"SELECT project, account, COUNT(*) AS n, SUM(token_cost) AS wasted "
|
|
305
|
+
"FROM waste_events WHERE pattern_type='floundering' "
|
|
306
|
+
" AND detected_at >= ? "
|
|
307
|
+
"GROUP BY project, account",
|
|
308
|
+
(_days_ago(7),),
|
|
309
|
+
).fetchall()
|
|
310
|
+
for r in floundering_rows:
|
|
311
|
+
proj = r["project"] or "Other"
|
|
312
|
+
if _insight_exists_recent(conn, "floundering_detected", proj):
|
|
313
|
+
continue
|
|
314
|
+
n = r["n"] or 0
|
|
315
|
+
wasted = r["wasted"] or 0
|
|
316
|
+
msg = (f"{proj} has {n} floundering session{'s' if n != 1 else ''} — "
|
|
317
|
+
f"Claude stuck retrying the same tool (~${wasted:.2f} at risk)")
|
|
318
|
+
detail = json.dumps({"count": n, "estimated_waste_usd": round(wasted, 4)})
|
|
319
|
+
insert_insight(conn, r["account"] or "all", proj,
|
|
320
|
+
"floundering_detected", msg, detail)
|
|
321
|
+
generated += 1
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
# ── 14. DAILY_BUDGET alerts (exceeded + warning) ──
|
|
326
|
+
try:
|
|
327
|
+
from analyzer import daily_budget_metrics as _daily_budget_metrics
|
|
328
|
+
dbm = _daily_budget_metrics(conn, "all")
|
|
329
|
+
for acct_id, b in dbm.items():
|
|
330
|
+
if not b.get("has_budget"):
|
|
331
|
+
continue
|
|
332
|
+
label = ACCOUNTS.get(acct_id, {}).get("label", acct_id)
|
|
333
|
+
cost = b["today_cost"]
|
|
334
|
+
limit = b["budget_usd"]
|
|
335
|
+
pct = b["budget_pct"]
|
|
336
|
+
if cost > limit:
|
|
337
|
+
if not _insight_exists_recent(conn, "budget_exceeded", acct_id, hours=6):
|
|
338
|
+
over = cost - limit
|
|
339
|
+
msg = (f"{label} exceeded daily budget — ${cost:.2f} spent vs "
|
|
340
|
+
f"${limit:.2f} limit (${over:.2f} over). Slow down or switch to Sonnet.")
|
|
341
|
+
detail = json.dumps({"today_cost": cost, "budget": limit, "over": over})
|
|
342
|
+
insert_insight(conn, acct_id, acct_id, "budget_exceeded", msg, detail)
|
|
343
|
+
generated += 1
|
|
344
|
+
elif pct > 80:
|
|
345
|
+
if not _insight_exists_recent(conn, "budget_warning", acct_id, hours=6):
|
|
346
|
+
proj_daily = b["projected_daily"]
|
|
347
|
+
msg = (f"{label} at {pct:.0f}% of daily budget — projected "
|
|
348
|
+
f"${proj_daily:.2f} vs ${limit:.2f} limit")
|
|
349
|
+
detail = json.dumps({"pct": pct, "projected": proj_daily, "budget": limit})
|
|
350
|
+
insert_insight(conn, acct_id, acct_id, "budget_warning", msg, detail)
|
|
351
|
+
generated += 1
|
|
352
|
+
except Exception:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
conn.commit()
|
|
356
|
+
if should_close:
|
|
357
|
+
conn.close()
|
|
358
|
+
|
|
359
|
+
return generated
|