@monoes/monomindcli 1.10.37 → 1.10.38

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.
Files changed (56) hide show
  1. package/.claude/helpers/utils/telemetry.cjs +1 -0
  2. package/dist/src/browser/actions.d.ts +1 -0
  3. package/dist/src/browser/actions.d.ts.map +1 -1
  4. package/dist/src/browser/actions.js +12 -5
  5. package/dist/src/browser/actions.js.map +1 -1
  6. package/dist/src/browser/batch.d.ts +0 -6
  7. package/dist/src/browser/batch.d.ts.map +1 -1
  8. package/dist/src/browser/batch.js.map +1 -1
  9. package/dist/src/browser/browser.d.ts.map +1 -1
  10. package/dist/src/browser/browser.js +37 -20
  11. package/dist/src/browser/browser.js.map +1 -1
  12. package/dist/src/browser/cdp.d.ts +1 -0
  13. package/dist/src/browser/cdp.d.ts.map +1 -1
  14. package/dist/src/browser/cdp.js +14 -4
  15. package/dist/src/browser/cdp.js.map +1 -1
  16. package/dist/src/browser/console-log.d.ts +4 -4
  17. package/dist/src/browser/console-log.d.ts.map +1 -1
  18. package/dist/src/browser/console-log.js +49 -16
  19. package/dist/src/browser/console-log.js.map +1 -1
  20. package/dist/src/browser/dialog.d.ts +2 -1
  21. package/dist/src/browser/dialog.d.ts.map +1 -1
  22. package/dist/src/browser/dialog.js +24 -10
  23. package/dist/src/browser/dialog.js.map +1 -1
  24. package/dist/src/browser/find.d.ts +1 -1
  25. package/dist/src/browser/find.d.ts.map +1 -1
  26. package/dist/src/browser/find.js +29 -10
  27. package/dist/src/browser/find.js.map +1 -1
  28. package/dist/src/browser/har.d.ts.map +1 -1
  29. package/dist/src/browser/har.js +3 -3
  30. package/dist/src/browser/har.js.map +1 -1
  31. package/dist/src/browser/network.d.ts +2 -0
  32. package/dist/src/browser/network.d.ts.map +1 -1
  33. package/dist/src/browser/network.js +64 -23
  34. package/dist/src/browser/network.js.map +1 -1
  35. package/dist/src/browser/profiler.d.ts.map +1 -1
  36. package/dist/src/browser/profiler.js +25 -15
  37. package/dist/src/browser/profiler.js.map +1 -1
  38. package/dist/src/browser/record.d.ts.map +1 -1
  39. package/dist/src/browser/record.js +7 -3
  40. package/dist/src/browser/record.js.map +1 -1
  41. package/dist/src/browser/session.d.ts.map +1 -1
  42. package/dist/src/browser/session.js +11 -7
  43. package/dist/src/browser/session.js.map +1 -1
  44. package/dist/src/browser/snapshot.js.map +1 -1
  45. package/dist/src/browser/trace.d.ts.map +1 -1
  46. package/dist/src/browser/trace.js +32 -17
  47. package/dist/src/browser/trace.js.map +1 -1
  48. package/dist/src/browser/wait.js +28 -14
  49. package/dist/src/browser/wait.js.map +1 -1
  50. package/dist/src/commands/browse.d.ts.map +1 -1
  51. package/dist/src/commands/browse.js +8 -14
  52. package/dist/src/commands/browse.js.map +1 -1
  53. package/dist/src/ui/dashboard-v2.html +292 -1
  54. package/dist/src/ui/server.mjs +86 -0
  55. package/dist/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +1 -1
@@ -436,6 +436,7 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
436
436
  #app.ambient #feed-recap,
437
437
  #app.ambient #feed-timeline,
438
438
  #app.ambient #digest-card { display:none !important; }
439
+ #app.ambient #weekly-card { display:none !important; }
439
440
  #app.ambient #main { background:var(--bg); }
440
441
  #app.ambient #view-now { height:100vh; }
441
442
  #app.ambient #feed-pane { border:none; }
@@ -597,6 +598,59 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
597
598
  .bm-save:hover { background:oklch(68% 0.18 75); }
598
599
  .bm-cancel { background:transparent; border:1px solid var(--border); border-radius:var(--r); padding:7px 12px; font-size:13px; color:var(--text-lo); cursor:pointer; }
599
600
  .bm-cancel:hover { color:var(--text-hi); }
601
+
602
+ /* ── toast notifications ──────────────────────────────────── */
603
+ #toast-rack { position:fixed; bottom:20px; right:20px; z-index:9999; display:flex; flex-direction:column; gap:8px; pointer-events:none; }
604
+ .toast { display:flex; align-items:flex-start; gap:10px; padding:10px 14px; border-radius:8px; font-size:12px; max-width:300px; pointer-events:all; background:oklch(18% 0.012 55); border:1px solid var(--border); color:var(--text-mid); box-shadow:0 4px 24px rgba(0,0,0,0.4); animation:toast-in 0.25s var(--ease-out); }
605
+ .toast.t-warn { border-color:oklch(72% 0.18 75 / 0.4); background:oklch(18% 0.015 75); }
606
+ .toast.t-err { border-color:oklch(60% 0.18 25 / 0.4); background:oklch(17% 0.015 25); }
607
+ .toast.t-ok { border-color:oklch(65% 0.15 150 / 0.4); background:oklch(17% 0.012 150); }
608
+ .toast-ico { flex-shrink:0; font-size:14px; }
609
+ .toast-body { flex:1; min-width:0; }
610
+ .toast-title { font-weight:600; color:var(--text-hi); margin-bottom:2px; }
611
+ .toast-msg { color:var(--text-lo); line-height:1.4; }
612
+ .toast-close { background:none; border:none; color:var(--text-xs); cursor:pointer; font-size:12px; line-height:1; padding:0; flex-shrink:0; }
613
+ .toast-close:hover { color:var(--text-lo); }
614
+ @keyframes toast-in { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
615
+
616
+ /* ── token velocity sparkline ─────────────────────────────── */
617
+ #vel-chart { display:flex; align-items:flex-end; gap:2px; height:28px; margin-top:6px; }
618
+ .vel-bar { flex:1; border-radius:2px 2px 0 0; min-height:2px; background:oklch(65% 0.18 200 / 0.55); }
619
+ .vel-bar.vel-hi { background:oklch(65% 0.18 150 / 0.8); }
620
+ .vel-bar.vel-lo { background:oklch(65% 0.08 200 / 0.3); }
621
+
622
+ /* ── shortcut help modal ──────────────────────────────────── */
623
+ #shortcut-modal { display:none; position:fixed; inset:0; z-index:500; background:oklch(0% 0 0 / 0.6); align-items:center; justify-content:center; }
624
+ #shortcut-modal.open { display:flex; }
625
+ #shortcut-box { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:24px 28px; max-width:460px; width:90%; max-height:80vh; overflow-y:auto; }
626
+ .sk-title { font-size:14px; font-weight:600; color:var(--text-hi); margin-bottom:16px; display:flex; justify-content:space-between; align-items:center; }
627
+ .sk-close { background:none; border:none; color:var(--text-xs); cursor:pointer; font-size:16px; padding:0; }
628
+ .sk-close:hover { color:var(--text-lo); }
629
+ .sk-section { font-size:10px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-xs); margin:14px 0 6px; }
630
+ .sk-row { display:flex; justify-content:space-between; align-items:center; padding:5px 0; border-bottom:1px solid oklch(25% 0.008 55 / 0.4); }
631
+ .sk-row:last-child { border-bottom:none; }
632
+ .sk-keys { display:flex; gap:3px; }
633
+ .sk-keys kbd { font-size:10px; background:var(--surface-hi); border:1px solid var(--border); border-radius:3px; padding:1px 6px; font-family:var(--mono); color:var(--text-lo); }
634
+ .sk-desc { font-size:11px; color:var(--text-mid); }
635
+
636
+ /* ── session filter ───────────────────────────────────────── */
637
+ #sess-filter-wrap { display:flex; align-items:center; gap:8px; margin-bottom:10px; }
638
+ #sess-filter-input { flex:1; background:var(--surface-hi); border:1px solid var(--border); border-radius:var(--r); padding:5px 10px; font-size:12px; color:var(--text-hi); outline:none; font-family:var(--sans); }
639
+ #sess-filter-input:focus { border-color:oklch(72% 0.18 75 / 0.5); }
640
+ #sess-filter-input::placeholder { color:var(--text-xs); }
641
+ #sess-filter-count { font-size:11px; color:var(--text-xs); white-space:nowrap; min-width:60px; text-align:right; }
642
+
643
+ /* ── topbar activity chip ─────────────────────────────────── */
644
+ #topbar-activity { font-size:10px; color:var(--text-xs); font-family:var(--mono); max-width:180px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; opacity:0; transition:opacity 0.3s; }
645
+ #topbar-activity.loaded { opacity:1; }
646
+
647
+ /* ── anomaly badge on session rows ────────────────────────── */
648
+ .sess-anomaly { display:inline-flex; align-items:center; font-size:10px; padding:1px 5px; border-radius:5px; font-weight:600; margin-left:4px; vertical-align:middle; }
649
+ .sess-anomaly.anom-cost { background:oklch(60% 0.18 25 / 0.12); color:oklch(70% 0.18 25); }
650
+ .sess-anomaly.anom-err { background:oklch(65% 0.15 300 / 0.12); color:oklch(72% 0.15 300); }
651
+
652
+ /* ── streak badge ─────────────────────────────────────────── */
653
+ .streak-chip { display:inline-flex; align-items:center; gap:3px; font-size:11px; padding:2px 7px; border-radius:8px; background:oklch(65% 0.18 75 / 0.12); color:oklch(78% 0.18 75); white-space:nowrap; }
600
654
  </style>
601
655
  </head>
602
656
  <body>
@@ -654,9 +708,11 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
654
708
  <span id="view-title">Now</span>
655
709
  <span class="pill"><span class="live-dot"></span> live</span>
656
710
  <span id="topbar-cost"></span>
711
+ <span id="topbar-activity"></span>
657
712
  <div id="tb-right">
658
713
  <button class="btn" id="btn-budget" onclick="openBudgetModal()" title="Set daily/monthly cost budget">⚑ Budget</button>
659
714
  <button class="btn" onclick="openCmdPalette()">⌕ Search <kbd style="font-size:10px;opacity:0.6;margin-left:3px">⌘K</kbd></button>
715
+ <button class="btn" onclick="openShortcutHelp()" title="Keyboard shortcuts (?)">? Help</button>
660
716
  <button class="btn" onclick="refreshCurrent()">↺ Refresh</button>
661
717
  </div>
662
718
  </div>
@@ -792,6 +848,10 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
792
848
  <div class="m-group-title">Activity Heatmap</div>
793
849
  <div class="loading-txt">—</div>
794
850
  </div>
851
+ <div id="m-velocity">
852
+ <div class="m-group-title">Token Velocity</div>
853
+ <div class="loading-txt">—</div>
854
+ </div>
795
855
  </div>
796
856
  </div>
797
857
 
@@ -816,6 +876,10 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
816
876
  <button class="lb-toggle" id="btn-leaderboard" onclick="toggleLeaderboard()" title="Cost leaderboard">⬆ Leaderboard</button>
817
877
  <button class="lb-toggle" id="btn-model-mix" onclick="toggleModelMix()" title="Model usage breakdown">⬡ Models</button>
818
878
  <button class="lb-toggle" id="btn-tool-errors" onclick="toggleToolErrors()" title="Tool error rate">⚠ Errors</button>
879
+ <button class="lb-toggle" id="btn-tool-rank" onclick="toggleToolRank()" title="Most-used tools across sessions">⟳ Tools</button>
880
+ <button class="lb-toggle" id="btn-proj-costs" onclick="toggleProjCosts()" title="Cost breakdown by project">$ Projects</button>
881
+ <button class="lb-toggle" id="btn-export-csv" onclick="exportSessionsCSV()" title="Export sessions as CSV">⬇ CSV</button>
882
+ <button class="lb-toggle" id="btn-patterns" onclick="togglePatterns()" title="Prompt word frequency across sessions">⊞ Patterns</button>
819
883
  <button class="diff-toggle" id="btn-diff" onclick="toggleDiffMode()" title="Compare two sessions">⇄ Compare</button>
820
884
  </div>
821
885
  <div class="pg-sub" id="sess-pg-sub">Recent Claude Code sessions for this project</div>
@@ -835,6 +899,19 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
835
899
  <div id="tool-errors-panel" style="display:none;margin-bottom:16px">
836
900
  <div id="tool-errors-body"></div>
837
901
  </div>
902
+ <div id="tool-rank-panel" style="display:none;margin-bottom:16px">
903
+ <div id="tool-rank-body"></div>
904
+ </div>
905
+ <div id="proj-costs-panel" style="display:none;margin-bottom:16px">
906
+ <div id="proj-costs-body"></div>
907
+ </div>
908
+ <div id="patterns-panel" style="display:none;margin-bottom:16px">
909
+ <div id="patterns-body"></div>
910
+ </div>
911
+ <div id="sess-filter-wrap">
912
+ <input id="sess-filter-input" type="text" placeholder="Filter sessions by prompt…" oninput="filterSessions(this.value)" autocomplete="off">
913
+ <span id="sess-filter-count"></span>
914
+ </div>
838
915
  <div id="sess-content" class="sess-list"><div class="loading-txt">Loading…</div></div>
839
916
  </div>
840
917
  </div>
@@ -885,6 +962,7 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
885
962
  </div><!-- /main -->
886
963
  <div id="app-ambient-hint">Press A to exit ambient mode</div>
887
964
  </div><!-- /app -->
965
+ <div id="toast-rack"></div>
888
966
 
889
967
  <!-- budget modal (fixed overlay, outside app) -->
890
968
  <div id="budget-modal" onclick="if(event.target===this)closeBudgetModal()">
@@ -1613,7 +1691,24 @@ async function renderSessions() {
1613
1691
  buildWeeklyRecap();
1614
1692
  buildEfficiencyPanel();
1615
1693
  buildActivityHeatmap();
1694
+ buildTokenVelocity();
1616
1695
  if (leaderboardOpen) renderLeaderboard();
1696
+ // check budget thresholds and fire toasts
1697
+ const todayCost = allSessions.filter(s => {
1698
+ const t = s.firstTs || s.mtime;
1699
+ if (!t) return false;
1700
+ const d = new Date(typeof t === 'number' ? t : t);
1701
+ const now = new Date();
1702
+ return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
1703
+ }).reduce((a, s) => a + (s.totalCost || 0), 0);
1704
+ const monthCost = allSessions.filter(s => {
1705
+ const t = s.firstTs || s.mtime;
1706
+ if (!t) return false;
1707
+ const d = new Date(typeof t === 'number' ? t : t);
1708
+ const now = new Date();
1709
+ return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
1710
+ }).reduce((a, s) => a + (s.totalCost || 0), 0);
1711
+ checkBudgetToast(todayCost, monthCost);
1617
1712
  } catch (err) {
1618
1713
  el.innerHTML = '<div class="empty">Could not load sessions: ' + esc(err.message) + '</div>';
1619
1714
  }
@@ -2331,7 +2426,33 @@ function buildSwimlane() {
2331
2426
  </div>
2332
2427
  </div>`;
2333
2428
  }).join('');
2334
- el.innerHTML = `<div class="m-group-title">Session Lanes <span style="font-size:10px;color:var(--text-xs);font-weight:400">24h</span></div><div id="swimlane-wrap">${rows}</div>`;
2429
+ // dead time: find largest gap between consecutive sessions
2430
+ const sorted = recent.slice().sort((a, b) => {
2431
+ const aTs = typeof (a.firstTs || a.mtime) === 'number' ? (a.firstTs || a.mtime) : new Date(a.firstTs || a.mtime).getTime();
2432
+ const bTs = typeof (b.firstTs || b.mtime) === 'number' ? (b.firstTs || b.mtime) : new Date(b.firstTs || b.mtime).getTime();
2433
+ return aTs - bTs;
2434
+ });
2435
+ let maxGapMs = 0; let gapStart = 0;
2436
+ for (let i = 1; i < sorted.length; i++) {
2437
+ const prev = sorted[i - 1];
2438
+ const curr = sorted[i];
2439
+ const prevTs = typeof (prev.firstTs || prev.mtime) === 'number' ? (prev.firstTs || prev.mtime) : new Date(prev.firstTs || prev.mtime).getTime();
2440
+ const prevEnd = prevTs + (prev.totalDurationMs || 60000);
2441
+ const currTs = typeof (curr.firstTs || curr.mtime) === 'number' ? (curr.firstTs || curr.mtime) : new Date(curr.firstTs || curr.mtime).getTime();
2442
+ const gap = currTs - prevEnd;
2443
+ if (gap > maxGapMs) { maxGapMs = gap; gapStart = prevEnd; }
2444
+ }
2445
+ const MIN15 = 15 * 60000;
2446
+ let gapNote = '';
2447
+ if (maxGapMs > MIN15) {
2448
+ const gapMins = Math.round(maxGapMs / 60000);
2449
+ const when = new Date(gapStart);
2450
+ const hr = when.getHours().toString().padStart(2, '0');
2451
+ const mn = when.getMinutes().toString().padStart(2, '0');
2452
+ const label = maxGapMs > 3600000 ? `${Math.floor(maxGapMs/3600000)}h idle` : `${gapMins}m idle`;
2453
+ gapNote = `<div style="font-size:10px;color:var(--text-xs);margin-top:5px;padding:3px 6px;border-radius:4px;background:oklch(40% 0.04 55 / 0.15)">Longest gap: ${label} starting ${hr}:${mn}</div>`;
2454
+ }
2455
+ el.innerHTML = `<div class="m-group-title">Session Lanes <span style="font-size:10px;color:var(--text-xs);font-weight:400">24h</span></div><div id="swimlane-wrap">${rows}</div>${gapNote}`;
2335
2456
  }
2336
2457
 
2337
2458
  // ── feature 18: loop run history ──────────────────────────
@@ -3026,6 +3147,176 @@ document.addEventListener('keydown', e => {
3026
3147
  }
3027
3148
  });
3028
3149
 
3150
+ // ── feature 25: notification toasts ──────────────────────
3151
+ let _toastLastBudgetKey = '';
3152
+ function showToast(title, msg, type = 'info', duration = 5000) {
3153
+ const rack = document.getElementById('toast-rack');
3154
+ if (!rack) return;
3155
+ const icoMap = { warn: '⚑', err: '⚠', ok: '✓', info: '◉' };
3156
+ const div = document.createElement('div');
3157
+ div.className = 'toast t-' + type;
3158
+ div.innerHTML = `<span class="toast-ico">${icoMap[type] || '◉'}</span>
3159
+ <div class="toast-body">
3160
+ <div class="toast-title">${esc(title)}</div>
3161
+ <div class="toast-msg">${esc(msg)}</div>
3162
+ </div>
3163
+ <button class="toast-close" onclick="this.closest('.toast').remove()">✕</button>`;
3164
+ rack.appendChild(div);
3165
+ if (duration > 0) setTimeout(() => { try { div.remove(); } catch {} }, duration);
3166
+ }
3167
+
3168
+ function checkBudgetToast(todayCost, monthCost) {
3169
+ const budget = JSON.parse(localStorage.getItem('mm-budget') || '{}');
3170
+ const daily = parseFloat(budget.daily) || 0;
3171
+ const monthly = parseFloat(budget.monthly) || 0;
3172
+ if (daily > 0 && todayCost >= daily * 0.9) {
3173
+ const pct = Math.round((todayCost / daily) * 100);
3174
+ const key = 'daily-' + pct;
3175
+ if (key !== _toastLastBudgetKey) {
3176
+ _toastLastBudgetKey = key;
3177
+ showToast('Budget alert', `Daily spend at ${pct}% ($${todayCost.toFixed(2)} of $${daily})`, todayCost >= daily ? 'err' : 'warn');
3178
+ }
3179
+ }
3180
+ if (monthly > 0 && monthCost >= monthly * 0.9) {
3181
+ const pct = Math.round((monthCost / monthly) * 100);
3182
+ const key = 'monthly-' + pct;
3183
+ if (key !== _toastLastBudgetKey) {
3184
+ _toastLastBudgetKey = key;
3185
+ showToast('Monthly budget', `Monthly spend at ${pct}% ($${monthCost.toFixed(2)} of $${monthly})`, monthCost >= monthly ? 'err' : 'warn');
3186
+ }
3187
+ }
3188
+ }
3189
+
3190
+ // ── feature 26: token velocity sparkline ──────────────────
3191
+ function buildTokenVelocity() {
3192
+ const el = document.getElementById('m-velocity');
3193
+ if (!el || !allSessions.length) return;
3194
+ const now = Date.now();
3195
+ const HOUR = 3600000;
3196
+ const buckets = new Array(24).fill(0);
3197
+ for (const s of allSessions) {
3198
+ const t = s.firstTs || s.mtime;
3199
+ if (!t) continue;
3200
+ const ts = typeof t === 'number' ? t : new Date(t).getTime();
3201
+ const hoursAgo = (now - ts) / HOUR;
3202
+ if (hoursAgo < 0 || hoursAgo >= 24) continue;
3203
+ const bucket = Math.min(23, Math.floor(23 - hoursAgo));
3204
+ buckets[bucket] += s.totalInputTokens || 0;
3205
+ }
3206
+ const maxTok = Math.max(1, ...buckets);
3207
+ const totalTok = buckets.reduce((a, b) => a + b, 0);
3208
+ const fmt = n => n > 1e6 ? (n/1e6).toFixed(1)+'M' : n > 1e3 ? (n/1e3).toFixed(0)+'k' : String(n);
3209
+ const bars = buckets.map((v, i) => {
3210
+ const h = Math.max(2, Math.round((v / maxTok) * 28));
3211
+ const cls = v > maxTok * 0.66 ? 'vel-hi' : v < maxTok * 0.15 ? 'vel-lo' : '';
3212
+ const hrsAgo = 23 - i;
3213
+ return `<div class="vel-bar ${cls}" style="height:${h}px" title="${fmt(v)} tokens — ${hrsAgo}h ago"></div>`;
3214
+ }).join('');
3215
+ const totalCost = allSessions.reduce((a, s) => a + (s.totalCost || 0), 0);
3216
+ el.innerHTML = `<div class="m-group-title">Token Velocity <span style="font-size:10px;color:var(--text-xs);font-weight:400">24h · ${fmt(totalTok)}</span></div>
3217
+ <div id="vel-chart">${bars}</div>
3218
+ <div style="font-size:10px;color:var(--text-xs);margin-top:4px">Total cost <span style="color:oklch(78% 0.18 75)">$${totalCost.toFixed(2)}</span></div>`;
3219
+ }
3220
+
3221
+ // ── feature 27: export sessions CSV ───────────────────────
3222
+ function exportSessionsCSV() {
3223
+ if (!allSessions.length) { showToast('No data', 'No sessions loaded yet', 'warn'); return; }
3224
+ const headers = ['Date', 'Session ID', 'Prompt', 'Cost ($)', 'Duration (s)', 'Tool Calls', 'User Messages', 'Cache Hit %', 'Input Tokens'];
3225
+ const rows = allSessions.map(s => {
3226
+ const dt = new Date(s.firstTs || s.mtime || 0).toISOString().slice(0, 19).replace('T', ' ');
3227
+ const cost = typeof s.totalCost === 'number' ? s.totalCost.toFixed(4) : '';
3228
+ const dur = s.totalDurationMs ? Math.round(s.totalDurationMs / 1000) : '';
3229
+ const cachePct = s.totalInputTokens > 0 ? Math.round((s.cacheReadTokens || 0) / s.totalInputTokens * 100) : '';
3230
+ const prompt = (s.lastPrompt || '').replace(/"/g, '""');
3231
+ return [dt, s.id, prompt, cost, dur, s.toolCalls || '', s.userMessages || '', cachePct, s.totalInputTokens || ''];
3232
+ });
3233
+ const csv = [headers, ...rows].map(r => r.map(c => `"${c}"`).join(',')).join('\n');
3234
+ const blob = new Blob([csv], { type: 'text/csv' });
3235
+ const a = document.createElement('a');
3236
+ a.href = URL.createObjectURL(blob);
3237
+ a.download = `sessions-${new Date().toISOString().slice(0, 10)}.csv`;
3238
+ a.click();
3239
+ URL.revokeObjectURL(a.href);
3240
+ showToast('Exported', `${allSessions.length} sessions saved as CSV`, 'ok');
3241
+ }
3242
+
3243
+ // ── feature 28: tool usage ranking ────────────────────────
3244
+ let toolRankOpen = false;
3245
+ function toggleToolRank() {
3246
+ toolRankOpen = !toolRankOpen;
3247
+ document.getElementById('btn-tool-rank').classList.toggle('on', toolRankOpen);
3248
+ const p = document.getElementById('tool-rank-panel');
3249
+ p.style.display = toolRankOpen ? '' : 'none';
3250
+ if (toolRankOpen) loadToolRank();
3251
+ }
3252
+ async function loadToolRank() {
3253
+ const el = document.getElementById('tool-rank-body');
3254
+ el.innerHTML = '<div class="loading-txt">Loading…</div>';
3255
+ try {
3256
+ const data = await apiFetch('/api/tool-ranking?dir=' + enc(DIR));
3257
+ if (!data.tools?.length) { el.innerHTML = '<div class="loading-txt">No tool usage data</div>'; return; }
3258
+ const maxCount = data.tools[0].count;
3259
+ const rows = data.tools.slice(0, 15).map((t, i) => {
3260
+ const barW = Math.round((t.count / maxCount) * 100);
3261
+ const errRate = t.errors > 0 ? ((t.errors / t.count) * 100).toFixed(0) + '%' : '—';
3262
+ return `<tr><td class="lb-rank">${i + 1}</td>
3263
+ <td style="font-size:12px;color:var(--text-mid);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.tool)}</td>
3264
+ <td style="width:80px;padding:4px 6px">
3265
+ <div style="height:4px;background:var(--surface-hi);border-radius:2px;overflow:hidden">
3266
+ <div style="height:100%;width:${barW}%;background:oklch(65% 0.15 200);border-radius:2px"></div>
3267
+ </div>
3268
+ </td>
3269
+ <td class="lb-cost" style="color:var(--text-mid)">${t.count.toLocaleString()}</td>
3270
+ <td class="lb-dur" style="color:${t.errors > 0 ? 'oklch(70% 0.18 25)' : 'var(--text-xs)'}">${errRate}</td>
3271
+ </tr>`;
3272
+ }).join('');
3273
+ el.innerHTML = `<table class="lb-table"><thead><tr>
3274
+ <th class="lb-rank">#</th><th>Tool</th><th></th><th class="lb-cost">Calls</th><th class="lb-dur">Error%</th>
3275
+ </tr></thead><tbody>${rows}</tbody></table>`;
3276
+ } catch (err) {
3277
+ el.innerHTML = '<div class="loading-txt">Could not load: ' + esc(err.message) + '</div>';
3278
+ }
3279
+ }
3280
+
3281
+ // ── feature 29: cost breakdown by project ─────────────────
3282
+ let projCostsOpen = false;
3283
+ function toggleProjCosts() {
3284
+ projCostsOpen = !projCostsOpen;
3285
+ document.getElementById('btn-proj-costs').classList.toggle('on', projCostsOpen);
3286
+ const p = document.getElementById('proj-costs-panel');
3287
+ p.style.display = projCostsOpen ? '' : 'none';
3288
+ if (projCostsOpen) loadProjCosts();
3289
+ }
3290
+ async function loadProjCosts() {
3291
+ const el = document.getElementById('proj-costs-body');
3292
+ el.innerHTML = '<div class="loading-txt">Loading…</div>';
3293
+ try {
3294
+ const data = await apiFetch('/api/project-costs');
3295
+ if (!data.projects?.length) { el.innerHTML = '<div class="loading-txt">No cost data across projects</div>'; return; }
3296
+ const maxCost = data.projects[0].cost;
3297
+ const rows = data.projects.slice(0, 10).map((p, i) => {
3298
+ const barW = maxCost > 0 ? Math.round((p.cost / maxCost) * 100) : 0;
3299
+ const name = p.path.split('/').filter(Boolean).pop() || p.path;
3300
+ return `<tr onclick="switchProject('${esc(p.path)}')" style="cursor:pointer" title="${esc(p.path)}">
3301
+ <td class="lb-rank">${i + 1}</td>
3302
+ <td style="font-size:12px;color:var(--text-mid);max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(name)}</td>
3303
+ <td class="lb-cost">$${p.cost.toFixed(2)}</td>
3304
+ <td style="width:80px;padding:4px 6px">
3305
+ <div style="height:4px;background:var(--surface-hi);border-radius:2px;overflow:hidden">
3306
+ <div style="height:100%;width:${barW}%;background:oklch(72% 0.18 75 / 0.7);border-radius:2px"></div>
3307
+ </div>
3308
+ </td>
3309
+ <td class="lb-dur">${p.sessions}</td>
3310
+ </tr>`;
3311
+ }).join('');
3312
+ el.innerHTML = `<table class="lb-table"><thead><tr>
3313
+ <th class="lb-rank">#</th><th>Project</th><th class="lb-cost">Cost</th><th></th><th class="lb-dur">Sessions</th>
3314
+ </tr></thead><tbody>${rows}</tbody></table>`;
3315
+ } catch (err) {
3316
+ el.innerHTML = '<div class="loading-txt">Could not load: ' + esc(err.message) + '</div>';
3317
+ }
3318
+ }
3319
+
3029
3320
  // ── helpers ────────────────────────────────────────────────
3030
3321
  function enc(s) { return encodeURIComponent(s); }
3031
3322
  function esc(s) {
@@ -534,6 +534,92 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
534
534
  return;
535
535
  }
536
536
 
537
+ // ------------------------------------------------------- GET /api/tool-ranking
538
+ if (req.method === 'GET' && url === '/api/tool-ranking') {
539
+ try {
540
+ const qs = new URL(req.url, 'http://localhost').searchParams;
541
+ const dir = qs.get('dir') || projectDir || process.cwd();
542
+ const d = path.resolve(dir || process.cwd());
543
+ const slug = d.replace(/\//g, '-');
544
+ const projectClaudeDir = path.join(os.homedir(), '.claude', 'projects', slug);
545
+ let sessionFiles = [];
546
+ try {
547
+ sessionFiles = fs.readdirSync(projectClaudeDir)
548
+ .filter(f => f.endsWith('.jsonl'))
549
+ .map(f => { try { return { f, mtime: fs.statSync(path.join(projectClaudeDir, f)).mtimeMs }; } catch { return null; } })
550
+ .filter(Boolean).sort((a,b) => b.mtime - a.mtime).slice(0, 30);
551
+ } catch {}
552
+ const toolCounts = {}, errorCounts = {};
553
+ for (const { f } of sessionFiles) {
554
+ const fp = path.join(projectClaudeDir, f);
555
+ try {
556
+ const lines = fs.readFileSync(fp, 'utf8').split('\n').filter(Boolean);
557
+ const toolIdMap = {};
558
+ for (const line of lines) {
559
+ let e; try { e = JSON.parse(line); } catch { continue; }
560
+ if (e.type === 'assistant') {
561
+ for (const b of (e.message?.content || [])) {
562
+ if (b && b.type === 'tool_use') { toolIdMap[b.id] = b.name; toolCounts[b.name] = (toolCounts[b.name] || 0) + 1; }
563
+ }
564
+ }
565
+ if (e.type === 'user') {
566
+ for (const b of (e.message?.content || [])) {
567
+ if (b && b.type === 'tool_result' && b.is_error) {
568
+ const name = toolIdMap[b.tool_use_id] || '?';
569
+ errorCounts[name] = (errorCounts[name] || 0) + 1;
570
+ }
571
+ }
572
+ }
573
+ }
574
+ } catch {}
575
+ }
576
+ const tools = Object.entries(toolCounts)
577
+ .map(([tool, count]) => ({ tool, count, errors: errorCounts[tool] || 0 }))
578
+ .sort((a,b) => b.count - a.count);
579
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache' });
580
+ res.end(JSON.stringify({ tools }));
581
+ } catch (err) {
582
+ res.writeHead(500, { 'Content-Type': 'application/json' });
583
+ res.end(JSON.stringify({ error: err.message }));
584
+ }
585
+ return;
586
+ }
587
+
588
+ // ------------------------------------------------------- GET /api/project-costs
589
+ if (req.method === 'GET' && url === '/api/project-costs') {
590
+ try {
591
+ const projectsBase = path.join(os.homedir(), '.claude', 'projects');
592
+ let slugDirs = [];
593
+ try { slugDirs = fs.readdirSync(projectsBase, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name); } catch {}
594
+ const projectCosts = [];
595
+ for (const slug of slugDirs) {
596
+ const projDir = path.join(projectsBase, slug);
597
+ const projPath = '/' + slug.replace(/^-/, '').replace(/-/g, '/');
598
+ let sessionFiles = [];
599
+ try { sessionFiles = fs.readdirSync(projDir).filter(f => f.endsWith('.jsonl')).map(f => path.join(projDir, f)); } catch {}
600
+ if (!sessionFiles.length) continue;
601
+ let totalCost = 0;
602
+ for (const fp of sessionFiles) {
603
+ try {
604
+ const lines = fs.readFileSync(fp, 'utf8').split('\n').filter(Boolean);
605
+ for (const line of lines) {
606
+ let e; try { e = JSON.parse(line); } catch { continue; }
607
+ if (e.type === 'assistant' && e.message?.usage) { totalCost += _sjCalcCost(e.message.model || '', e.message.usage); }
608
+ }
609
+ } catch {}
610
+ }
611
+ if (totalCost > 0) projectCosts.push({ path: projPath, cost: totalCost, sessions: sessionFiles.length });
612
+ }
613
+ projectCosts.sort((a, b) => b.cost - a.cost);
614
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache' });
615
+ res.end(JSON.stringify({ projects: projectCosts }));
616
+ } catch (err) {
617
+ res.writeHead(500, { 'Content-Type': 'application/json' });
618
+ res.end(JSON.stringify({ error: err.message }));
619
+ }
620
+ return;
621
+ }
622
+
537
623
  // ------------------------------------------------------- GET /api/projects
538
624
  if (req.method === 'GET' && url === '/api/projects') {
539
625
  try {