@monoes/monomindcli 1.10.38 → 1.10.40

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.
@@ -389,6 +389,69 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
389
389
  /* ── forecast row ────────────────────────────────────────── */
390
390
  .m-val.forecast { color:var(--text-lo); font-size:11px; font-weight:500; }
391
391
 
392
+ /* ── session date groups ─────────────────────────────────── */
393
+ .sg-section { margin-bottom:4px; }
394
+ .sg-header { display:flex; align-items:center; gap:6px; padding:5px 2px 3px; cursor:pointer; user-select:none; }
395
+ .sg-title { font-size:10px; letter-spacing:0.07em; text-transform:uppercase; color:var(--text-xs); font-weight:600; }
396
+ .sg-count { font-size:10px; color:var(--text-lo); background:var(--surface-hi); border-radius:8px; padding:1px 6px; }
397
+ .sg-toggle { font-size:10px; color:var(--text-xs); margin-left:auto; }
398
+ .sg-header:hover .sg-title { color:var(--text-lo); }
399
+ .sg-body.collapsed { display:none; }
400
+
401
+ /* ── session heatmap calendar ─────────────────────────────── */
402
+ #sess-heatmap { margin-bottom:14px; }
403
+ .shm-grid { display:grid; grid-template-rows:repeat(7,10px); grid-auto-flow:column; grid-auto-columns:10px; gap:2px; margin-top:6px; }
404
+ .shm-cell { border-radius:2px; background:var(--surface-hi); cursor:pointer; transition:outline 0.1s; }
405
+ .shm-cell:hover { outline:1px solid var(--accent); outline-offset:-1px; }
406
+ .shm-cell.shm-1 { background:oklch(72% 0.18 75 / 0.22); }
407
+ .shm-cell.shm-2 { background:oklch(72% 0.18 75 / 0.42); }
408
+ .shm-cell.shm-3 { background:oklch(72% 0.18 75 / 0.65); }
409
+ .shm-cell.shm-4 { background:oklch(72% 0.18 75 / 0.85); }
410
+ .shm-cell.shm-active { outline:2px solid var(--accent); outline-offset:-1px; }
411
+ .shm-label { font-size:10px; color:var(--text-xs); display:flex; align-items:center; justify-content:space-between; }
412
+ #shm-clear { font-size:10px; color:var(--accent); background:none; border:none; cursor:pointer; padding:0; display:none; }
413
+ #shm-clear.show { display:inline; }
414
+
415
+ /* ── cost period toggle ───────────────────────────────────── */
416
+ .period-toggles { display:flex; gap:4px; margin-bottom:10px; flex-wrap:wrap; }
417
+ .period-btn { font-size:11px; padding:3px 10px; border-radius:8px; border:1px solid var(--border); background:transparent; color:var(--text-lo); cursor:pointer; font-family:var(--sans); transition:color 0.1s, background 0.1s; }
418
+ .period-btn:hover { color:var(--text-hi); }
419
+ .period-btn.active { background:var(--accent-dim); color:var(--accent); border-color:oklch(72% 0.18 75 / 0.3); }
420
+
421
+ /* ── bulk session actions ─────────────────────────────────── */
422
+ .sess-row.bulk-sel { background:oklch(72% 0.18 75 / 0.08); outline:1px solid oklch(72% 0.18 75 / 0.3); outline-offset:-1px; }
423
+ #bulk-toolbar { display:none; position:sticky; top:0; z-index:10; background:oklch(18% 0.009 55); border:1px solid oklch(72% 0.18 75 / 0.3); border-radius:var(--r); padding:8px 14px; margin-bottom:10px; gap:10px; align-items:center; }
424
+ #bulk-toolbar.show { display:flex; }
425
+ .bulk-count { font-size:12px; color:var(--text-mid); flex:1; }
426
+ .bulk-btn { font-size:11px; padding:4px 12px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text-mid); cursor:pointer; font-family:var(--sans); transition:color 0.1s; }
427
+ .bulk-btn:hover { color:var(--text-hi); }
428
+ .bulk-btn.danger { color:oklch(65% 0.15 25); border-color:oklch(65% 0.15 25 / 0.3); }
429
+
430
+ /* ── files touched ────────────────────────────────────────── */
431
+ .sr-files { font-size:10px; color:var(--text-xs); margin-top:2px; display:flex; flex-wrap:wrap; gap:3px; }
432
+ .sr-file-chip { font-family:var(--mono); background:var(--surface-hi); border-radius:3px; padding:1px 5px; color:var(--text-lo); }
433
+
434
+ /* ── memory age bars ──────────────────────────────────────── */
435
+ .dr-age-bar { height:2px; border-radius:1px; margin-top:4px; background:oklch(72% 0.18 75 / 0.25); }
436
+ .dr-age-bar-fill { height:100%; border-radius:1px; background:var(--accent); }
437
+
438
+ /* ── loop creation form ───────────────────────────────────── */
439
+ #loop-create-form { background:var(--surface); border:1px solid var(--border); border-radius:var(--r); padding:14px 16px; margin-bottom:16px; }
440
+ .lcf-title { font-size:12px; font-weight:600; color:var(--text-hi); margin-bottom:10px; }
441
+ .lcf-row { display:flex; flex-direction:column; gap:3px; margin-bottom:10px; }
442
+ .lcf-label { font-size:10px; color:var(--text-xs); text-transform:uppercase; letter-spacing:0.06em; }
443
+ .lcf-input, .lcf-textarea { background:var(--bg); border:1px solid var(--border); border-radius:4px; color:var(--text-hi); font-family:var(--sans); font-size:12px; padding:6px 10px; transition:border-color 0.15s; }
444
+ .lcf-textarea { resize:vertical; min-height:56px; }
445
+ .lcf-input:focus, .lcf-textarea:focus { outline:none; border-color:oklch(72% 0.18 75 / 0.5); }
446
+ .lcf-row-inline { display:flex; gap:8px; }
447
+ .lcf-row-inline .lcf-row { flex:1; margin-bottom:0; }
448
+ .lcf-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:4px; }
449
+ .lcf-submit { background:var(--accent); color:oklch(11% 0.009 55); border:none; border-radius:6px; font-size:12px; font-weight:600; padding:5px 16px; cursor:pointer; font-family:var(--sans); }
450
+ .lcf-submit:hover { background:oklch(78% 0.18 75); }
451
+ .lcf-cancel { background:transparent; border:1px solid var(--border); border-radius:6px; font-size:12px; color:var(--text-lo); padding:5px 12px; cursor:pointer; font-family:var(--sans); }
452
+ #btn-new-loop { font-size:11px; color:var(--text-lo); background:transparent; border:1px solid var(--border); border-radius:8px; padding:3px 10px; cursor:pointer; transition:color 0.1s; font-family:var(--sans); margin-bottom:12px; }
453
+ #btn-new-loop:hover { color:var(--accent); border-color:oklch(72% 0.18 75 / 0.4); }
454
+
392
455
  /* ── auto-tags ───────────────────────────────────────────── */
393
456
  .sr-autotag { font-size:10px; padding:1px 6px; border-radius:8px; background:oklch(72% 0.18 75 / 0.1); color:oklch(78% 0.18 75); border:1px solid oklch(72% 0.18 75 / 0.2); }
394
457
  .tag-filter-bar { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:12px; }
@@ -651,6 +714,70 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
651
714
 
652
715
  /* ── streak badge ─────────────────────────────────────────── */
653
716
  .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; }
717
+
718
+ /* ── f47: 30-day daily cost trend ────────────────────────── */
719
+ #daily-trend-chart { display:flex; align-items:flex-end; gap:2px; height:40px; margin-top:6px; }
720
+ .dt-bar { flex:1; min-width:4px; border-radius:2px 2px 0 0; background:oklch(72% 0.18 75 / 0.5); cursor:pointer; transition:background 0.15s; }
721
+ .dt-bar:hover,.dt-bar.active { background:oklch(72% 0.18 75); }
722
+ .dt-bar.has-filter { background:oklch(65% 0.15 150 / 0.7); }
723
+
724
+ /* ── f48: live cost ticker ────────────────────────────────── */
725
+ #live-cost-ticker { font-size:11px; font-family:var(--mono); font-weight:600; color:oklch(78% 0.18 75); opacity:0; transition:opacity 0.3s; white-space:nowrap; }
726
+ #live-cost-ticker.show { opacity:1; }
727
+ #live-cost-ticker .lct-change { font-size:10px; color:var(--green); margin-left:3px; }
728
+
729
+ /* ── f49: hourly productivity heatmap ────────────────────── */
730
+ #hourly-heatmap-grid { display:grid; grid-template-columns:20px repeat(24,1fr); grid-template-rows:auto; gap:2px; margin-top:6px; font-size:9px; }
731
+ .hh-hour-lbl { color:var(--text-xs); text-align:center; padding-bottom:1px; }
732
+ .hh-day-lbl { color:var(--text-xs); line-height:12px; }
733
+ .hh-cell { height:10px; border-radius:2px; background:var(--surface-hi); cursor:default; }
734
+ .hh-cell.hh-1 { background:oklch(72% 0.18 75 / 0.25); }
735
+ .hh-cell.hh-2 { background:oklch(72% 0.18 75 / 0.5); }
736
+ .hh-cell.hh-3 { background:oklch(72% 0.18 75 / 0.75); }
737
+ .hh-cell.hh-4 { background:oklch(72% 0.18 75); }
738
+
739
+ /* ── f50: custom tag editor ──────────────────────────────── */
740
+ .sr-custom-tags { display:flex; flex-wrap:wrap; gap:4px; align-items:center; margin-top:4px; }
741
+ .sr-ctag { display:inline-flex; align-items:center; gap:3px; font-size:10px; padding:1px 6px; border-radius:8px; background:oklch(65% 0.15 200 / 0.15); color:oklch(72% 0.15 200); }
742
+ .sr-ctag .ctag-del { cursor:pointer; opacity:0.6; font-size:9px; line-height:1; }
743
+ .sr-ctag .ctag-del:hover { opacity:1; }
744
+ .ctag-add-btn { font-size:10px; color:var(--text-xs); background:none; border:1px dashed var(--border); border-radius:8px; padding:1px 6px; cursor:pointer; }
745
+ .ctag-add-btn:hover { color:var(--text-lo); border-color:var(--text-xs); }
746
+ .ctag-input-wrap { display:flex; gap:4px; margin-top:4px; }
747
+ .ctag-input { font-size:11px; background:var(--surface-hi); border:1px solid var(--border); border-radius:var(--r); padding:2px 6px; color:var(--text-hi); outline:none; width:100px; }
748
+ .ctag-input:focus { border-color:oklch(72% 0.18 75 / 0.5); }
749
+ .ctag-ok { font-size:10px; background:none; border:1px solid var(--border); border-radius:var(--r); padding:2px 6px; cursor:pointer; color:var(--text-lo); }
750
+ .ctag-ok:hover { color:var(--text-hi); }
751
+ .sr-autotag.ctag { background:oklch(65% 0.15 200 / 0.12); color:oklch(72% 0.15 200); }
752
+
753
+ /* ── f51: tool error drawer ──────────────────────────────── */
754
+ .sess-anomaly.anom-err.clickable { cursor:pointer; }
755
+ .sess-anomaly.anom-err.clickable:hover { opacity:0.8; }
756
+ .err-drawer { display:none; margin-top:6px; border:1px solid oklch(60% 0.18 25 / 0.3); border-radius:var(--r); overflow:hidden; }
757
+ .err-drawer.open { display:block; }
758
+ .err-drawer-head { display:flex; align-items:center; gap:8px; padding:6px 10px; background:oklch(60% 0.18 25 / 0.08); font-size:11px; font-weight:600; color:oklch(70% 0.18 25); }
759
+ .err-drawer-head .err-close { margin-left:auto; cursor:pointer; font-size:12px; background:none; border:none; color:var(--text-xs); }
760
+ .err-drawer-body { max-height:160px; overflow-y:auto; padding:8px 10px; }
761
+ .err-item { font-size:11px; font-family:var(--mono); color:oklch(80% 0.1 25); border-bottom:1px solid var(--border); padding:4px 0; white-space:pre-wrap; word-break:break-word; }
762
+ .err-item:last-child { border:none; }
763
+
764
+ /* ── f52: prompt copy button ─────────────────────────────── */
765
+ .sr-copy-btn { font-size:10px; background:none; border:none; cursor:pointer; color:var(--text-xs); padding:0 4px; opacity:0; transition:opacity 0.15s; line-height:1; }
766
+ .sess-row:hover .sr-copy-btn { opacity:1; }
767
+ .sr-copy-btn:hover { color:var(--text-hi); }
768
+ .sr-copy-btn.copied { color:var(--green); opacity:1; }
769
+
770
+ /* ── f53: session cost histogram ─────────────────────────── */
771
+ #cost-histogram-panel { display:none; margin-bottom:16px; padding:12px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); }
772
+ .ch-title { font-size:11px; font-weight:600; color:var(--text-lo); letter-spacing:0.06em; text-transform:uppercase; margin-bottom:8px; }
773
+ .ch-bars { display:flex; align-items:flex-end; gap:4px; height:50px; }
774
+ .ch-bar-wrap { flex:1; display:flex; flex-direction:column; align-items:center; gap:2px; }
775
+ .ch-bar { width:100%; border-radius:2px 2px 0 0; background:oklch(72% 0.18 75 / 0.4); min-height:2px; }
776
+ .ch-lbl { font-size:8px; color:var(--text-xs); white-space:nowrap; }
777
+ .ch-cnt { font-size:9px; color:var(--text-lo); }
778
+
779
+ /* ── f54: URL param indicator ────────────────────────────── */
780
+ .url-param-active { background:oklch(72% 0.18 75 / 0.08); }
654
781
  </style>
655
782
  </head>
656
783
  <body>
@@ -708,6 +835,7 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
708
835
  <span id="view-title">Now</span>
709
836
  <span class="pill"><span class="live-dot"></span> live</span>
710
837
  <span id="topbar-cost"></span>
838
+ <span id="live-cost-ticker" title="Current session accumulated cost"></span>
711
839
  <span id="topbar-activity"></span>
712
840
  <div id="tb-right">
713
841
  <button class="btn" id="btn-budget" onclick="openBudgetModal()" title="Set daily/monthly cost budget">⚑ Budget</button>
@@ -852,6 +980,14 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
852
980
  <div class="m-group-title">Token Velocity</div>
853
981
  <div class="loading-txt">—</div>
854
982
  </div>
983
+ <div id="m-daily-trend">
984
+ <div class="m-group-title">30-Day Cost Trend</div>
985
+ <div class="loading-txt">—</div>
986
+ </div>
987
+ <div id="m-hourly-heatmap">
988
+ <div class="m-group-title">Peak Work Hours</div>
989
+ <div class="loading-txt">—</div>
990
+ </div>
855
991
  </div>
856
992
  </div>
857
993
 
@@ -893,6 +1029,7 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
893
1029
  <th class="lb-rank">#</th><th>Session</th><th class="lb-cost">Cost</th><th class="lb-dur">Duration</th>
894
1030
  </tr></thead><tbody id="lb-body"></tbody></table>
895
1031
  </div>
1032
+ <div id="cost-histogram-panel"></div>
896
1033
  <div id="model-mix-panel" style="display:none;margin-bottom:16px">
897
1034
  <div id="model-mix-body"></div>
898
1035
  </div>
@@ -908,6 +1045,23 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
908
1045
  <div id="patterns-panel" style="display:none;margin-bottom:16px">
909
1046
  <div id="patterns-body"></div>
910
1047
  </div>
1048
+ <div id="sess-heatmap" style="margin-bottom:14px;display:none">
1049
+ <div class="shm-label"><span>12-week activity</span><button id="shm-clear" onclick="clearHeatmapFilter()">✕ Clear filter</button></div>
1050
+ <div class="shm-grid" id="shm-grid"></div>
1051
+ </div>
1052
+ <div class="period-toggles" id="period-toggles">
1053
+ <span style="font-size:10px;color:var(--text-xs);align-self:center;text-transform:uppercase;letter-spacing:0.06em">Period:</span>
1054
+ <button class="period-btn active" data-period="day" onclick="setPeriod('day')">Day</button>
1055
+ <button class="period-btn" data-period="week" onclick="setPeriod('week')">Week</button>
1056
+ <button class="period-btn" data-period="month" onclick="setPeriod('month')">Month</button>
1057
+ <button class="period-btn" data-period="all" onclick="setPeriod('all')">All</button>
1058
+ </div>
1059
+ <div id="bulk-toolbar">
1060
+ <span class="bulk-count" id="bulk-count">0 selected</span>
1061
+ <button class="bulk-btn" onclick="bulkExport()">⬇ Export</button>
1062
+ <button class="bulk-btn" onclick="bulkBookmark()">☆ Bookmark all</button>
1063
+ <button class="bulk-btn danger" onclick="clearBulkSelection()">✕ Clear</button>
1064
+ </div>
911
1065
  <div id="sess-filter-wrap">
912
1066
  <input id="sess-filter-input" type="text" placeholder="Filter sessions by prompt…" oninput="filterSessions(this.value)" autocomplete="off">
913
1067
  <span id="sess-filter-count"></span>
@@ -921,6 +1075,32 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
921
1075
  <div class="vscroll">
922
1076
  <div class="pg-title">Loops</div>
923
1077
  <div class="pg-sub">Scheduled automation loops</div>
1078
+ <button id="btn-new-loop" onclick="showLoopForm()">+ New Loop</button>
1079
+ <div id="loop-create-form" style="display:none">
1080
+ <div class="lcf-title">Create Loop</div>
1081
+ <div class="lcf-row">
1082
+ <label class="lcf-label">Prompt</label>
1083
+ <textarea class="lcf-textarea" id="lcf-prompt" placeholder="What should the agent do each iteration?"></textarea>
1084
+ </div>
1085
+ <div class="lcf-row">
1086
+ <label class="lcf-label">Name (optional)</label>
1087
+ <input class="lcf-input" id="lcf-name" type="text" placeholder="My loop">
1088
+ </div>
1089
+ <div class="lcf-row-inline">
1090
+ <div class="lcf-row">
1091
+ <label class="lcf-label">Interval</label>
1092
+ <input class="lcf-input" id="lcf-interval" type="text" placeholder="1h" value="1h">
1093
+ </div>
1094
+ <div class="lcf-row">
1095
+ <label class="lcf-label">Max reps (blank = ∞)</label>
1096
+ <input class="lcf-input" id="lcf-maxreps" type="number" placeholder="∞" min="1">
1097
+ </div>
1098
+ </div>
1099
+ <div class="lcf-actions">
1100
+ <button class="lcf-cancel" onclick="hideLoopForm()">Cancel</button>
1101
+ <button class="lcf-submit" onclick="createLoop()">Create Loop</button>
1102
+ </div>
1103
+ </div>
924
1104
  <div id="loops-content" class="loop-list"><div class="loading-txt">Loading…</div></div>
925
1105
  </div>
926
1106
  </div>
@@ -964,6 +1144,24 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
964
1144
  </div><!-- /app -->
965
1145
  <div id="toast-rack"></div>
966
1146
 
1147
+ <!-- shortcut help modal -->
1148
+ <div id="shortcut-modal" onclick="if(event.target===this)closeShortcutHelp()">
1149
+ <div id="shortcut-box">
1150
+ <div class="sk-title">Keyboard Shortcuts <button class="sk-close" onclick="closeShortcutHelp()">✕</button></div>
1151
+ <div class="sk-section">Feed (Now view)</div>
1152
+ <div class="sk-row"><span class="sk-desc">Navigate entries</span><span class="sk-keys"><kbd>J</kbd><kbd>K</kbd></span></div>
1153
+ <div class="sk-row"><span class="sk-desc">Open detail drawer</span><span class="sk-keys"><kbd>↵</kbd></span></div>
1154
+ <div class="sk-row"><span class="sk-desc">Search in feed</span><span class="sk-keys"><kbd>/</kbd></span></div>
1155
+ <div class="sk-row"><span class="sk-desc">Jump to live session</span><span class="sk-keys"><kbd>G</kbd></span></div>
1156
+ <div class="sk-row"><span class="sk-desc">Refresh current view</span><span class="sk-keys"><kbd>R</kbd></span></div>
1157
+ <div class="sk-row"><span class="sk-desc">Toggle ambient mode</span><span class="sk-keys"><kbd>A</kbd></span></div>
1158
+ <div class="sk-section">Global</div>
1159
+ <div class="sk-row"><span class="sk-desc">Command palette</span><span class="sk-keys"><kbd>⌘</kbd><kbd>K</kbd></span></div>
1160
+ <div class="sk-row"><span class="sk-desc">Close / dismiss</span><span class="sk-keys"><kbd>Esc</kbd></span></div>
1161
+ <div class="sk-row"><span class="sk-desc">This help</span><span class="sk-keys"><kbd>?</kbd></span></div>
1162
+ </div>
1163
+ </div>
1164
+
967
1165
  <!-- budget modal (fixed overlay, outside app) -->
968
1166
  <div id="budget-modal" onclick="if(event.target===this)closeBudgetModal()">
969
1167
  <div id="budget-box">
@@ -1053,10 +1251,21 @@ async function init() {
1053
1251
  document.getElementById('sb-path').textContent = DIR;
1054
1252
  document.getElementById('sb-proj').textContent = DIR.split('/').filter(Boolean).pop() || '—';
1055
1253
  } catch (_) {}
1056
- viewRendered['now'] = true; // prevents switchView from re-rendering NOW on jumpToSession
1254
+ // deep-link: ?sess=<id>&proj=<path>
1255
+ const params = new URLSearchParams(window.location.search);
1256
+ const projParam = params.get('proj');
1257
+ const sessParam = params.get('sess');
1258
+ if (projParam) {
1259
+ DIR = projParam;
1260
+ document.getElementById('sb-proj').textContent = DIR.split('/').filter(Boolean).pop() || '—';
1261
+ document.getElementById('sb-path').textContent = DIR;
1262
+ }
1263
+ restoreURLParams();
1264
+ viewRendered['now'] = true;
1057
1265
  updateBudgetBtnStyle();
1058
1266
  await refreshNow();
1059
- startPolling();
1267
+ if (sessParam) setTimeout(() => jumpToSession(sessParam), 300);
1268
+ initSSE();
1060
1269
  }
1061
1270
 
1062
1271
  function startPolling() {
@@ -1064,6 +1273,20 @@ function startPolling() {
1064
1273
  pollTimer = setInterval(() => { if (currentView === 'now') refreshNowSilent(); }, 30000);
1065
1274
  }
1066
1275
 
1276
+ let _sseSource = null;
1277
+ function initSSE() {
1278
+ if (_sseSource) { try { _sseSource.close(); } catch {} _sseSource = null; }
1279
+ if (!DIR || !window.EventSource) { startPolling(); return; }
1280
+ try {
1281
+ const src = new EventSource('/api/events-stream?dir=' + enc(DIR));
1282
+ src.addEventListener('update', () => { if (currentView === 'now') refreshNowSilent(); });
1283
+ src.addEventListener('connected', () => {});
1284
+ src.onerror = () => { src.close(); _sseSource = null; startPolling(); };
1285
+ _sseSource = src;
1286
+ clearInterval(pollTimer); // SSE replaces polling
1287
+ } catch { startPolling(); }
1288
+ }
1289
+
1067
1290
  async function apiFetch(url) {
1068
1291
  const r = await fetch(url);
1069
1292
  if (!r.ok) throw new Error(r.status);
@@ -1105,7 +1328,7 @@ function toggleLiveTail() {
1105
1328
  if (sessionIdx !== 0 && allSessions.length) { sessionIdx = 0; userScrolled = false; loadFeedForSession(allSessions[0]); }
1106
1329
  liveTailTimer = setInterval(() => { if (currentView === 'now') refreshNowSilent(); }, 5000);
1107
1330
  } else {
1108
- startPolling();
1331
+ initSSE();
1109
1332
  }
1110
1333
  }
1111
1334
 
@@ -1159,6 +1382,9 @@ async function loadFeedForSession(sess) {
1159
1382
  document.getElementById('btn-prev-sess').style.opacity = sessionIdx < allSessions.length - 1 ? '1' : '0.3';
1160
1383
  document.getElementById('btn-next-sess').style.opacity = sessionIdx > 0 ? '1' : '0.3';
1161
1384
  showSessCtx(sess);
1385
+ // f48: live cost ticker
1386
+ const sessCost = typeof sess.totalCost === 'number' ? sess.totalCost : (typeof sess.cost === 'number' ? sess.cost : null);
1387
+ updateLiveTicker(sessCost);
1162
1388
  if (!sess.file) { setFeedContent('<div class="feed-empty">Session file path unavailable.</div>'); return; }
1163
1389
  try {
1164
1390
  const data = await apiFetch('/api/session?dir=' + enc(DIR) + '&file=' + enc(sess.file) + '&limit=120');
@@ -1275,6 +1501,8 @@ function renderFeedEvents(events, silent) {
1275
1501
  updateBurnRate(filtered);
1276
1502
  // session recap card
1277
1503
  buildRecap(filtered, allSessions[sessionIdx]);
1504
+ // infer current activity from most recent tool
1505
+ updateCurrentActivity(visible);
1278
1506
  }
1279
1507
 
1280
1508
  function renderGroupRow(g) {
@@ -1642,17 +1870,49 @@ async function renderSessions() {
1642
1870
  }
1643
1871
  let toShow = showStarredOnly ? sessions.filter(s => bookmarks.has(s.id)) : sessions;
1644
1872
  if (activeTagFilter) toShow = toShow.filter(s => (allTags.sessionTags.get(s.id) || []).includes(activeTagFilter));
1873
+ if (heatmapDateFilter) toShow = toShow.filter(s => {
1874
+ const t = s.lastTs || s.mtime; if (!t) return false;
1875
+ return new Date(typeof t === 'number' ? t : t).toDateString() === heatmapDateFilter;
1876
+ });
1645
1877
  if (!toShow.length) {
1646
1878
  el.innerHTML = '<div class="empty"><div class="empty-ico">☆</div><div>No bookmarked sessions</div></div>';
1879
+ buildSessionHeatmap(sessions);
1647
1880
  return;
1648
1881
  }
1649
- const sessData = JSON.stringify(toShow).replace(/'/g, '&#39;');
1650
- el.innerHTML = toShow.map((s, idx) => {
1882
+ // compute median cost for anomaly detection
1883
+ const costsForMedian = sessions.map(s => s.totalCost || 0).filter(c => c > 0).sort((a, b) => a - b);
1884
+ const medianCost = costsForMedian.length ? costsForMedian[Math.floor(costsForMedian.length / 2)] : 0;
1885
+
1886
+ // group by date
1887
+ const now = Date.now(); const DAY = 86400000;
1888
+ function sessDateGroup(s) {
1889
+ const t = s.lastTs || s.mtime; if (!t) return 'Older';
1890
+ const age = now - (typeof t === 'number' ? t : new Date(t).getTime());
1891
+ if (age < DAY) return 'Today';
1892
+ if (age < 2 * DAY) return 'Yesterday';
1893
+ if (age < 8 * DAY) return 'This week';
1894
+ return 'Older';
1895
+ }
1896
+ const GROUP_ORDER = ['Today', 'Yesterday', 'This week', 'Older'];
1897
+ const groups = {};
1898
+ for (const s of toShow) {
1899
+ const g = sessDateGroup(s); if (!groups[g]) groups[g] = [];
1900
+ groups[g].push(s);
1901
+ }
1902
+
1903
+ function renderSessRow(s, idx) {
1651
1904
  const dur = s.totalDurationMs ? fmtDur(s.totalDurationMs) : '';
1652
1905
  const msgs = s.totalMessages ? s.totalMessages + ' msg' : '';
1653
1906
  const cost = typeof s.totalCost === 'number' ? '$' + s.totalCost.toFixed(2)
1654
1907
  : typeof s.cost === 'number' ? '$' + s.cost.toFixed(2) : '';
1655
1908
  const meta = [dur, msgs, cost].filter(Boolean).join(' · ') || s.id.slice(0, 16);
1909
+ const sCost = s.totalCost || 0;
1910
+ let anomBadge = '';
1911
+ if (medianCost > 0.05 && sCost > medianCost * 3 && sCost > 0.5) {
1912
+ anomBadge = `<span class="sess-anomaly anom-cost" title="Cost ${((sCost/medianCost).toFixed(1))}× above median">! costly</span>`;
1913
+ } else if (s.toolCalls > 0 && (s.errorCount || 0) / s.toolCalls > 0.3) {
1914
+ anomBadge = `<span class="sess-anomaly anom-err" title="${s.errorCount} tool errors">! errors</span>`;
1915
+ }
1656
1916
  const satPct = Math.min(100, Math.round((s.totalMessages || 0) / 200 * 100));
1657
1917
  const satColor = satPct > 80 ? 'oklch(65% 0.2 25)' : satPct > 50 ? 'oklch(70% 0.18 80)' : 'var(--accent)';
1658
1918
  const satBar = satPct > 0 ? `<div class="ctx-sat-wrap" title="Context saturation ~${satPct}% (${s.totalMessages||0} turns / 200 est. max)">
@@ -1664,16 +1924,29 @@ async function renderSessions() {
1664
1924
  const sData = JSON.stringify(s).replace(/'/g, '&#39;');
1665
1925
  const note = getSessNote(s.id);
1666
1926
  const hasNote = !!note;
1667
- return `<div class="sess-row" data-sess-idx="${idx}" onclick="diffMode ? diffSelectSession(JSON.parse(this.dataset.sessData || '{}'), this) : jumpToSession('${esc(s.id)}')" data-sess-data='${sData}'>
1927
+ const files = (s.filesTouched || []).slice(0, 5);
1928
+ const filesHtml = files.length ? `<div class="sr-files">${files.map(f => `<span class="sr-file-chip">${esc(f)}</span>`).join('')}${(s.filesTouched||[]).length > 5 ? `<span class="sr-file-chip">+${(s.filesTouched||[]).length-5}</span>` : ''}</div>` : '';
1929
+ // f51: error badge — clickable if errors exist
1930
+ const errBadge = (s.errorCount > 0 && s.toolCalls > 0 && (s.errorCount / s.toolCalls) > 0.3)
1931
+ ? `<span class="sess-anomaly anom-err clickable" onclick="toggleErrDrawer('${esc(s.id)}',event)" title="Click to see ${s.errorCount} tool errors">${s.errorCount} err</span>`
1932
+ : anomBadge;
1933
+ // f50: custom tags
1934
+ const ctags = getCustomTags(s.id);
1935
+ const ctagsHtml = renderCustomTagsInline(s.id, ctags);
1936
+ return `<div class="sess-row" data-sess-idx="${idx}" data-sess-id="${esc(s.id)}" onclick="handleSessRowClick(event,this,'${esc(s.id)}')" data-sess-data='${sData}'>
1668
1937
  <div class="sr-top">
1669
1938
  <div class="sr-prompt">${esc(s.lastPrompt || s.id)}</div>
1670
1939
  <div class="sr-time">${relTime(s.lastTs || s.mtime)}</div>
1940
+ <button class="sr-copy-btn" onclick="copyPrompt(${JSON.stringify(s.lastPrompt || s.id)},event)" title="Copy prompt to clipboard">⎘</button>
1671
1941
  <button class="sess-star${isStarred ? ' on' : ''}" data-sid="${esc(s.id)}" onclick="toggleBookmark('${esc(s.id)}',event)" title="${isStarred ? 'Remove bookmark' : 'Bookmark session'}">${isStarred ? '★' : '☆'}</button>
1672
1942
  <span class="sr-view">→ view</span>
1673
1943
  </div>
1674
- <div class="sr-meta">${esc(meta)}</div>
1944
+ <div class="sr-meta">${esc(meta)}${errBadge}</div>
1675
1945
  ${(summaries || autoTags) ? `<div class="sr-tags">${summaries}${autoTags}</div>` : ''}
1946
+ ${ctagsHtml}
1947
+ ${filesHtml}
1676
1948
  ${satBar}
1949
+ <div class="err-drawer" id="err-drawer-${esc(s.id)}"></div>
1677
1950
  <div class="sess-notes-wrap" onclick="event.stopPropagation()">
1678
1951
  <button class="sess-notes-toggle${hasNote ? ' has-note' : ''}" onclick="toggleSessNote('${esc(s.id)}',this)">✎ ${hasNote ? 'Note' : 'Add note'}</button>
1679
1952
  <div class="sess-notes-area" id="snote-${esc(s.id)}">
@@ -1682,17 +1955,36 @@ async function renderSessions() {
1682
1955
  </div>
1683
1956
  </div>
1684
1957
  </div>`;
1685
- }).join('');
1958
+ }
1959
+
1960
+ let html = '';
1961
+ let flatIdx = 0;
1962
+ for (const grp of GROUP_ORDER) {
1963
+ const items = groups[grp];
1964
+ if (!items || !items.length) continue;
1965
+ const gid = 'sg-' + grp.replace(/\s+/g, '-').toLowerCase();
1966
+ html += `<div class="sg-section"><div class="sg-header" onclick="toggleSessGroup('${gid}')">
1967
+ <span class="sg-title">${grp}</span><span class="sg-count">${items.length}</span><span class="sg-toggle">▾</span>
1968
+ </div><div class="sg-body" id="${gid}">`;
1969
+ for (const s of items) { html += renderSessRow(s, flatIdx++); }
1970
+ html += '</div></div>';
1971
+ }
1972
+ el.innerHTML = html;
1686
1973
  // prepend tag filter bar if there are common tags
1687
1974
  if (allTags.common.size > 1) {
1688
1975
  el.innerHTML = buildTagFilterBar(toShow) + el.innerHTML;
1689
1976
  }
1977
+ buildSessionHeatmap(sessions);
1690
1978
  buildDigest();
1691
1979
  buildWeeklyRecap();
1692
1980
  buildEfficiencyPanel();
1693
1981
  buildActivityHeatmap();
1694
1982
  buildTokenVelocity();
1983
+ buildDailyCostTrend();
1984
+ buildHourlyHeatmap();
1985
+ buildCostHistogram();
1695
1986
  if (leaderboardOpen) renderLeaderboard();
1987
+ syncURLParams();
1696
1988
  // check budget thresholds and fire toasts
1697
1989
  const todayCost = allSessions.filter(s => {
1698
1990
  const t = s.firstTs || s.mtime;
@@ -1716,6 +2008,7 @@ async function renderSessions() {
1716
2008
 
1717
2009
  function jumpToSession(id) {
1718
2010
  switchView('now');
2011
+ history.replaceState(null, '', '?sess=' + encodeURIComponent(id) + '&proj=' + encodeURIComponent(DIR));
1719
2012
  setTimeout(() => {
1720
2013
  const i = allSessions.findIndex(x => x.id === id);
1721
2014
  if (i >= 0) { sessionIdx = i; userScrolled = false; loadFeedForSession(allSessions[i]); }
@@ -1740,10 +2033,10 @@ function toggleSessStarFilter() {
1740
2033
  showStarredOnly = !showStarredOnly;
1741
2034
  const btn = document.getElementById('sess-star-filter');
1742
2035
  btn.classList.toggle('on', showStarredOnly);
1743
- // re-render with filter
1744
2036
  viewRendered['sessions'] = false;
1745
2037
  renderSessions();
1746
2038
  viewRendered['sessions'] = true;
2039
+ syncURLParams();
1747
2040
  }
1748
2041
 
1749
2042
  // ── feature 1: auto-tags ───────────────────────────────────
@@ -1791,6 +2084,7 @@ function setTagFilter(tag) {
1791
2084
  viewRendered['sessions'] = false;
1792
2085
  renderSessions();
1793
2086
  viewRendered['sessions'] = true;
2087
+ syncURLParams();
1794
2088
  }
1795
2089
 
1796
2090
  // ── feature 2: session recap ───────────────────────────────
@@ -2099,8 +2393,21 @@ function buildDigest() {
2099
2393
  ...themes.map(t => `#${t}`),
2100
2394
  ].filter(Boolean);
2101
2395
 
2396
+ // cost forecast: project monthly spend from daily average
2397
+ const today2 = new Date();
2398
+ const daysInMonth = new Date(today2.getFullYear(), today2.getMonth() + 1, 0).getDate();
2399
+ const dayOfMonth = today2.getDate();
2400
+ const monthCostSoFar = allSessions.filter(s => {
2401
+ const t = s.firstTs || s.mtime;
2402
+ if (!t) return false;
2403
+ const d = new Date(typeof t === 'number' ? t : t);
2404
+ return d.getFullYear() === today2.getFullYear() && d.getMonth() === today2.getMonth();
2405
+ }).reduce((a, s) => a + (s.totalCost || 0), 0);
2406
+ const dailyAvg = dayOfMonth > 0 ? monthCostSoFar / dayOfMonth : 0;
2407
+ const projected = dailyAvg * daysInMonth;
2408
+ const forecastHtml = projected > 0.05 ? `<span class="digest-stat" title="Projected monthly spend at current pace" style="color:oklch(72% 0.14 200)">~$${projected.toFixed(2)}/mo</span>` : '';
2102
2409
  document.getElementById('digest-stats').innerHTML =
2103
- stats.map(s => `<span class="digest-stat">${esc(s)}</span>`).join('');
2410
+ stats.map(s => `<span class="digest-stat">${esc(s)}</span>`).join('') + forecastHtml;
2104
2411
  document.getElementById('digest-card').classList.add('show');
2105
2412
  }
2106
2413
 
@@ -2519,6 +2826,42 @@ function toggleLoop(row) {
2519
2826
  row.classList.toggle('open');
2520
2827
  }
2521
2828
 
2829
+ function showLoopForm() {
2830
+ document.getElementById('loop-create-form').style.display = 'block';
2831
+ document.getElementById('btn-new-loop').style.display = 'none';
2832
+ document.getElementById('lcf-prompt').focus();
2833
+ }
2834
+
2835
+ function hideLoopForm() {
2836
+ document.getElementById('loop-create-form').style.display = 'none';
2837
+ document.getElementById('btn-new-loop').style.display = '';
2838
+ }
2839
+
2840
+ async function createLoop() {
2841
+ const prompt = document.getElementById('lcf-prompt').value.trim();
2842
+ if (!prompt) { showToast('Required', 'Prompt is required', 'warn'); return; }
2843
+ const name = document.getElementById('lcf-name').value.trim();
2844
+ const interval = document.getElementById('lcf-interval').value.trim() || '1h';
2845
+ const maxRepsVal = document.getElementById('lcf-maxreps').value;
2846
+ const maxReps = maxRepsVal ? parseInt(maxRepsVal) : null;
2847
+ try {
2848
+ const r = await fetch('/api/loops/create?dir=' + enc(DIR), {
2849
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2850
+ body: JSON.stringify({ name, prompt, interval, maxReps }),
2851
+ });
2852
+ const d = await r.json();
2853
+ if (!d.ok) { showToast('Error', d.error || 'Failed to create loop', 'err'); return; }
2854
+ showToast('Created', `Loop created: ${name || prompt.slice(0, 30)}`, 'ok');
2855
+ hideLoopForm();
2856
+ document.getElementById('lcf-prompt').value = '';
2857
+ document.getElementById('lcf-name').value = '';
2858
+ document.getElementById('lcf-interval').value = '1h';
2859
+ document.getElementById('lcf-maxreps').value = '';
2860
+ viewRendered['loops'] = false;
2861
+ renderLoops();
2862
+ } catch (err) { showToast('Error', err.message, 'err'); }
2863
+ }
2864
+
2522
2865
  // ── feature 19: cache efficiency panel ────────────────────
2523
2866
  function buildEfficiencyPanel() {
2524
2867
  const el = document.getElementById('m-efficiency');
@@ -2610,6 +2953,7 @@ function buildWeeklyRecap() {
2610
2953
  return new Date(typeof t === 'number' ? t : t).toDateString();
2611
2954
  })).size;
2612
2955
  const longestMs = Math.max(...weekSess.map(s => s.totalDurationMs||0));
2956
+ const streak = calcStreak();
2613
2957
  const stats = [
2614
2958
  `${weekSess.length} session${weekSess.length!==1?'s':''}`,
2615
2959
  `${days} day${days!==1?'s':''}`,
@@ -2617,7 +2961,8 @@ function buildWeeklyRecap() {
2617
2961
  totalTools > 0 ? `${totalTools.toLocaleString()} tool calls` : null,
2618
2962
  longestMs > 0 ? `${fmtDur(longestMs)} longest` : null,
2619
2963
  ].filter(Boolean);
2620
- document.getElementById('weekly-stats').innerHTML = stats.map(s => `<span class="digest-stat">${esc(s)}</span>`).join('');
2964
+ const streakHtml = streak >= 2 ? `<span class="streak-chip" title="${streak} consecutive days with sessions">🔥 ${streak}d streak</span>` : '';
2965
+ document.getElementById('weekly-stats').innerHTML = stats.map(s => `<span class="digest-stat">${esc(s)}</span>`).join('') + streakHtml;
2621
2966
  document.getElementById('weekly-card').classList.add('show');
2622
2967
  }
2623
2968
  function dismissWeekly() {
@@ -2724,12 +3069,21 @@ async function renderMemory() {
2724
3069
  ).join('');
2725
3070
  }
2726
3071
 
2727
- const items = allDrawers.map((d, i) =>
2728
- `<div class="drawer-item" data-idx="${i}" data-ns="${esc(d.namespace || 'default')}">
3072
+ const tsValues = allDrawers.map(d => d.timestamp ? new Date(d.timestamp).getTime() : 0).filter(Boolean);
3073
+ const oldestTs = tsValues.length ? Math.min(...tsValues) : 0;
3074
+ const newestTs = tsValues.length ? Math.max(...tsValues) : 0;
3075
+ const tsRange = newestTs - oldestTs || 1;
3076
+ const items = allDrawers.map((d, i) => {
3077
+ const ts = d.timestamp ? new Date(d.timestamp).getTime() : 0;
3078
+ const agePct = ts && oldestTs ? Math.round(((ts - oldestTs) / tsRange) * 100) : 0;
3079
+ const ageBar = ts ? `<div class="dr-age-bar"><div class="dr-age-bar-fill" style="width:${agePct}%"></div></div>` : '';
3080
+ return `<div class="drawer-item" data-idx="${i}" data-ns="${esc(d.namespace || 'default')}">
2729
3081
  <div class="dr-key">${esc(d.key || d.namespace || '—')}</div>
2730
3082
  <div class="dr-val">${esc(String(d.value || d.text || '').slice(0, 300))}</div>
2731
3083
  ${d.timestamp ? `<div class="dr-ts">${relTime(d.timestamp)}</div>` : ''}
2732
- </div>`).join('');
3084
+ ${ageBar}
3085
+ </div>`;
3086
+ }).join('');
2733
3087
  html += `<div class="mem-section" id="drawers-section">
2734
3088
  <div class="mem-title">Drawers (${allDrawers.length})</div>
2735
3089
  <div id="drawers-list">${items}</div>
@@ -3118,7 +3472,8 @@ document.addEventListener('keydown', e => {
3118
3472
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
3119
3473
  if (document.getElementById('cmd-palette').classList.contains('open')) return;
3120
3474
 
3121
- if (e.key === 'Escape') { closeDetail(); closeCmdPalette(); if (document.getElementById('app').classList.contains('ambient')) toggleAmbient(); }
3475
+ if (e.key === 'Escape') { closeDetail(); closeCmdPalette(); closeShortcutHelp(); if (document.getElementById('app').classList.contains('ambient')) toggleAmbient(); }
3476
+ if (e.key === '?') { e.preventDefault(); openShortcutHelp(); return; }
3122
3477
  if (e.key === 'a' || e.key === 'A') { if (currentView === 'now') { e.preventDefault(); toggleAmbient(); } }
3123
3478
 
3124
3479
  if (currentView === 'now') {
@@ -3147,6 +3502,108 @@ document.addEventListener('keydown', e => {
3147
3502
  }
3148
3503
  });
3149
3504
 
3505
+ // ── feature 31: inline session filter ─────────────────────
3506
+ function filterSessions(q) {
3507
+ const rows = document.querySelectorAll('#sess-content .sess-row');
3508
+ const lq = q.toLowerCase().trim();
3509
+ let visible = 0;
3510
+ rows.forEach(row => {
3511
+ const prompt = (row.querySelector('.sr-prompt')?.textContent || '').toLowerCase();
3512
+ const meta = (row.querySelector('.sr-meta')?.textContent || '').toLowerCase();
3513
+ const tags = (row.querySelector('.sr-tags')?.textContent || '').toLowerCase();
3514
+ const match = !lq || prompt.includes(lq) || meta.includes(lq) || tags.includes(lq);
3515
+ row.style.display = match ? '' : 'none';
3516
+ if (match) visible++;
3517
+ });
3518
+ const countEl = document.getElementById('sess-filter-count');
3519
+ if (countEl) countEl.textContent = lq && rows.length ? `${visible} / ${rows.length}` : '';
3520
+ }
3521
+
3522
+ // ── feature 32: keyboard shortcut help modal ──────────────
3523
+ function openShortcutHelp() { document.getElementById('shortcut-modal').classList.add('open'); }
3524
+ function closeShortcutHelp() { document.getElementById('shortcut-modal').classList.remove('open'); }
3525
+
3526
+ // ── feature 33: "currently working on" inference ──────────
3527
+ function updateCurrentActivity(events) {
3528
+ const el = document.getElementById('topbar-activity');
3529
+ if (!el) return;
3530
+ if (!events?.length) { el.textContent = ''; el.classList.remove('loaded'); return; }
3531
+ const recent = [...events].reverse().find(ev => ev.kind === 'tool');
3532
+ if (!recent) { el.textContent = ''; el.classList.remove('loaded'); return; }
3533
+ const name = recent.name || '';
3534
+ let activity = '';
3535
+ if (['Write', 'Edit', 'Read', 'MultiEdit'].includes(name)) {
3536
+ const lbl = recent.label || '';
3537
+ const match = lbl.match(/([^/\\]+\.[a-zA-Z0-9]+)/) ;
3538
+ activity = match ? match[1] : (lbl.split('/').pop() || name);
3539
+ } else if (name === 'Bash') {
3540
+ activity = 'bash: ' + (recent.label || '').slice(0, 24);
3541
+ } else if (name === 'WebSearch' || name === 'WebFetch') {
3542
+ activity = 'web: ' + (recent.label || '').slice(0, 20);
3543
+ } else if (name) {
3544
+ activity = name;
3545
+ }
3546
+ if (activity) { el.textContent = '⤷ ' + activity; el.classList.add('loaded'); }
3547
+ else { el.textContent = ''; el.classList.remove('loaded'); }
3548
+ }
3549
+
3550
+ // ── feature 34: prompt pattern analysis ───────────────────
3551
+ let patternsOpen = false;
3552
+ function togglePatterns() {
3553
+ patternsOpen = !patternsOpen;
3554
+ document.getElementById('btn-patterns').classList.toggle('on', patternsOpen);
3555
+ const p = document.getElementById('patterns-panel');
3556
+ p.style.display = patternsOpen ? '' : 'none';
3557
+ if (patternsOpen) buildPatterns();
3558
+ }
3559
+ function buildPatterns() {
3560
+ const el = document.getElementById('patterns-body');
3561
+ if (!allSessions.length) { el.innerHTML = '<div class="loading-txt">No sessions loaded</div>'; return; }
3562
+ const freq = {};
3563
+ for (const s of allSessions) {
3564
+ const words = (s.lastPrompt || '').toLowerCase().match(/\b[a-z]{4,}\b/g) || [];
3565
+ const seen = new Set();
3566
+ for (const w of words) {
3567
+ if (!STOP_WORDS.has(w) && !seen.has(w)) { freq[w] = (freq[w] || 0) + 1; seen.add(w); }
3568
+ }
3569
+ }
3570
+ const sorted = Object.entries(freq).filter(([,c]) => c >= 2).sort((a, b) => b[1] - a[1]).slice(0, 20);
3571
+ if (!sorted.length) { el.innerHTML = '<div class="loading-txt">Not enough prompt data</div>'; return; }
3572
+ const maxCount = sorted[0][1];
3573
+ const rows = sorted.map(([word, count], i) => {
3574
+ const barW = Math.round((count / maxCount) * 100);
3575
+ return `<tr><td class="lb-rank">${i + 1}</td>
3576
+ <td style="font-size:12px;color:var(--text-mid)">${esc(word)}</td>
3577
+ <td style="width:100px;padding:4px 6px"><div style="height:4px;background:var(--surface-hi);border-radius:2px;overflow:hidden">
3578
+ <div style="height:100%;width:${barW}%;background:oklch(70% 0.15 300);border-radius:2px"></div></div></td>
3579
+ <td class="lb-cost" style="color:var(--text-mid)">${count}</td></tr>`;
3580
+ }).join('');
3581
+ el.innerHTML = `<table class="lb-table"><thead><tr>
3582
+ <th class="lb-rank">#</th><th>Term</th><th></th><th class="lb-cost">Sessions</th>
3583
+ </tr></thead><tbody>${rows}</tbody></table>`;
3584
+ }
3585
+
3586
+ // ── feature 35: session streak tracker ────────────────────
3587
+ function calcStreak() {
3588
+ const dates = new Set(allSessions.map(s => {
3589
+ const t = s.firstTs || s.mtime;
3590
+ if (!t) return null;
3591
+ return new Date(typeof t === 'number' ? t : t).toDateString();
3592
+ }).filter(Boolean));
3593
+ let streak = 0;
3594
+ const today = new Date();
3595
+ for (let i = 0; i <= 365; i++) {
3596
+ const d = new Date(today);
3597
+ d.setDate(d.getDate() - i);
3598
+ if (dates.has(d.toDateString())) {
3599
+ streak++;
3600
+ } else if (i > 0) {
3601
+ break;
3602
+ }
3603
+ }
3604
+ return streak;
3605
+ }
3606
+
3150
3607
  // ── feature 25: notification toasts ──────────────────────
3151
3608
  let _toastLastBudgetKey = '';
3152
3609
  function showToast(title, msg, type = 'info', duration = 5000) {
@@ -3187,14 +3644,169 @@ function checkBudgetToast(todayCost, monthCost) {
3187
3644
  }
3188
3645
  }
3189
3646
 
3647
+ // ── feature 39: cost period toggle ────────────────────────
3648
+ let activePeriod = 'day';
3649
+ let heatmapDateFilter = null;
3650
+
3651
+ function setPeriod(p) {
3652
+ activePeriod = p;
3653
+ document.querySelectorAll('.period-btn').forEach(b => b.classList.toggle('active', b.dataset.period === p));
3654
+ buildTokenVelocity();
3655
+ syncURLParams();
3656
+ }
3657
+
3658
+ function periodFilteredSessions() {
3659
+ const now = Date.now();
3660
+ const DAY = 86400000;
3661
+ const windows = { day: DAY, week: 7 * DAY, month: 30 * DAY, all: Infinity };
3662
+ const w = windows[activePeriod] || DAY;
3663
+ if (w === Infinity) return allSessions;
3664
+ return allSessions.filter(s => {
3665
+ const t = s.firstTs || s.mtime; if (!t) return false;
3666
+ const ts = typeof t === 'number' ? t : new Date(t).getTime();
3667
+ return (now - ts) <= w;
3668
+ });
3669
+ }
3670
+
3671
+ // ── feature 40: session heatmap ────────────────────────────
3672
+ function buildSessionHeatmap(sessions) {
3673
+ const el = document.getElementById('shm-grid');
3674
+ const wrap = document.getElementById('sess-heatmap');
3675
+ if (!el || !wrap || !sessions.length) return;
3676
+ wrap.style.display = 'block';
3677
+ const DAY = 86400000;
3678
+ const now = Date.now();
3679
+ const WEEKS = 12; const DAYS = WEEKS * 7;
3680
+ const buckets = new Array(DAYS).fill(null).map(() => ({ count:0, date:null }));
3681
+ for (let i = 0; i < DAYS; i++) {
3682
+ buckets[i].date = new Date(now - (DAYS - 1 - i) * DAY).toDateString();
3683
+ }
3684
+ for (const s of sessions) {
3685
+ const ts = s.lastTs || s.mtime; if (!ts) continue;
3686
+ const age = now - (typeof ts === 'number' ? ts : new Date(ts).getTime());
3687
+ const idx = DAYS - 1 - Math.floor(age / DAY);
3688
+ if (idx >= 0 && idx < DAYS) buckets[idx].count++;
3689
+ }
3690
+ const max = Math.max(...buckets.map(b => b.count), 1);
3691
+ el.innerHTML = buckets.map(b => {
3692
+ const level = b.count === 0 ? 0 : Math.min(4, Math.ceil(b.count / max * 4));
3693
+ const isActive = b.date === heatmapDateFilter;
3694
+ return `<div class="shm-cell shm-${level}${isActive ? ' shm-active' : ''}" title="${b.date}: ${b.count} session${b.count !== 1 ? 's' : ''}" onclick="setHeatmapFilter('${b.date}',${b.count})"></div>`;
3695
+ }).join('');
3696
+ }
3697
+
3698
+ function setHeatmapFilter(dateStr, count) {
3699
+ if (!count) return;
3700
+ heatmapDateFilter = heatmapDateFilter === dateStr ? null : dateStr;
3701
+ const clearBtn = document.getElementById('shm-clear');
3702
+ if (clearBtn) clearBtn.classList.toggle('show', !!heatmapDateFilter);
3703
+ viewRendered['sessions'] = false;
3704
+ renderSessions();
3705
+ syncURLParams();
3706
+ }
3707
+
3708
+ function clearHeatmapFilter() {
3709
+ heatmapDateFilter = null;
3710
+ const clearBtn = document.getElementById('shm-clear');
3711
+ if (clearBtn) clearBtn.classList.remove('show');
3712
+ viewRendered['sessions'] = false;
3713
+ renderSessions();
3714
+ }
3715
+
3716
+ function toggleSessGroup(id) {
3717
+ const body = document.getElementById(id);
3718
+ if (!body) return;
3719
+ body.classList.toggle('collapsed');
3720
+ const hdr = body.previousElementSibling;
3721
+ if (hdr) { const tog = hdr.querySelector('.sg-toggle'); if (tog) tog.textContent = body.classList.contains('collapsed') ? '▸' : '▾'; }
3722
+ }
3723
+
3724
+ // ── feature 41: bulk session actions ───────────────────────
3725
+ let bulkSelected = new Set();
3726
+ let lastClickedSessIdx = null;
3727
+
3728
+ function handleSessRowClick(evt, row, sessId) {
3729
+ if (diffMode) { diffSelectSession(JSON.parse(row.dataset.sessData || '{}'), row); return; }
3730
+ if (evt.shiftKey && lastClickedSessIdx !== null) {
3731
+ // range-select
3732
+ const rows = [...document.querySelectorAll('#sess-content .sess-row')];
3733
+ const curIdx = rows.indexOf(row);
3734
+ const lo = Math.min(lastClickedSessIdx, curIdx);
3735
+ const hi = Math.max(lastClickedSessIdx, curIdx);
3736
+ for (let i = lo; i <= hi; i++) {
3737
+ const r = rows[i]; if (!r) continue;
3738
+ const id = r.dataset.sessId;
3739
+ if (id) { bulkSelected.add(id); r.classList.add('bulk-sel'); }
3740
+ }
3741
+ updateBulkToolbar();
3742
+ return;
3743
+ }
3744
+ if (bulkSelected.size > 0) {
3745
+ // toggle this row in bulk selection
3746
+ if (bulkSelected.has(sessId)) { bulkSelected.delete(sessId); row.classList.remove('bulk-sel'); }
3747
+ else { bulkSelected.add(sessId); row.classList.add('bulk-sel'); }
3748
+ const rows = [...document.querySelectorAll('#sess-content .sess-row')];
3749
+ lastClickedSessIdx = rows.indexOf(row);
3750
+ updateBulkToolbar();
3751
+ return;
3752
+ }
3753
+ lastClickedSessIdx = [...document.querySelectorAll('#sess-content .sess-row')].indexOf(row);
3754
+ jumpToSession(sessId);
3755
+ }
3756
+
3757
+ function updateBulkToolbar() {
3758
+ const tb = document.getElementById('bulk-toolbar');
3759
+ const cnt = document.getElementById('bulk-count');
3760
+ if (!tb) return;
3761
+ tb.classList.toggle('show', bulkSelected.size > 0);
3762
+ if (cnt) cnt.textContent = bulkSelected.size + ' selected';
3763
+ }
3764
+
3765
+ function clearBulkSelection() {
3766
+ bulkSelected.clear();
3767
+ document.querySelectorAll('#sess-content .sess-row.bulk-sel').forEach(r => r.classList.remove('bulk-sel'));
3768
+ updateBulkToolbar();
3769
+ }
3770
+
3771
+ function bulkBookmark() {
3772
+ for (const id of bulkSelected) bookmarks.add(id);
3773
+ localStorage.setItem('mm-bookmarks', JSON.stringify([...bookmarks]));
3774
+ clearBulkSelection();
3775
+ showToast('Bookmarked', `${bulkSelected.size || 'Selected'} sessions bookmarked`, 'ok');
3776
+ viewRendered['sessions'] = false;
3777
+ renderSessions();
3778
+ }
3779
+
3780
+ function bulkExport() {
3781
+ const toExport = allSessions.filter(s => bulkSelected.has(s.id));
3782
+ if (!toExport.length) return;
3783
+ const headers = ['Date', 'Session ID', 'Prompt', 'Cost ($)', 'Duration (s)', 'Tool Calls', 'Files Touched'];
3784
+ const rows = toExport.map(s => {
3785
+ const dt = new Date(s.firstTs || s.mtime || 0).toISOString().slice(0, 19).replace('T', ' ');
3786
+ const cost = typeof s.totalCost === 'number' ? s.totalCost.toFixed(4) : '';
3787
+ const dur = s.totalDurationMs ? Math.round(s.totalDurationMs / 1000) : '';
3788
+ const prompt = (s.lastPrompt || '').replace(/"/g, '""');
3789
+ const files = (s.filesTouched || []).join(';');
3790
+ return [dt, s.id, prompt, cost, dur, s.toolCalls || '', files];
3791
+ });
3792
+ const csv = [headers, ...rows].map(r => r.map(c => `"${c}"`).join(',')).join('\n');
3793
+ const blob = new Blob([csv], { type: 'text/csv' });
3794
+ const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
3795
+ a.download = `sessions-bulk-${new Date().toISOString().slice(0,10)}.csv`; a.click();
3796
+ URL.revokeObjectURL(a.href);
3797
+ showToast('Exported', `${toExport.length} sessions saved`, 'ok');
3798
+ clearBulkSelection();
3799
+ }
3800
+
3190
3801
  // ── feature 26: token velocity sparkline ──────────────────
3191
3802
  function buildTokenVelocity() {
3192
3803
  const el = document.getElementById('m-velocity');
3193
3804
  if (!el || !allSessions.length) return;
3194
3805
  const now = Date.now();
3195
3806
  const HOUR = 3600000;
3807
+ const filtered = periodFilteredSessions();
3196
3808
  const buckets = new Array(24).fill(0);
3197
- for (const s of allSessions) {
3809
+ for (const s of filtered) {
3198
3810
  const t = s.firstTs || s.mtime;
3199
3811
  if (!t) continue;
3200
3812
  const ts = typeof t === 'number' ? t : new Date(t).getTime();
@@ -3212,10 +3824,11 @@ function buildTokenVelocity() {
3212
3824
  const hrsAgo = 23 - i;
3213
3825
  return `<div class="vel-bar ${cls}" style="height:${h}px" title="${fmt(v)} tokens — ${hrsAgo}h ago"></div>`;
3214
3826
  }).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>
3827
+ const totalCost = filtered.reduce((a, s) => a + (s.totalCost || 0), 0);
3828
+ const periodLabel = { day:'24h', week:'7d', month:'30d', all:'all time' }[activePeriod] || '24h';
3829
+ el.innerHTML = `<div class="m-group-title">Token Velocity <span style="font-size:10px;color:var(--text-xs);font-weight:400">${periodLabel} · ${fmt(totalTok)}</span></div>
3217
3830
  <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>`;
3831
+ <div style="font-size:10px;color:var(--text-xs);margin-top:4px">Cost <span style="color:oklch(78% 0.18 75)">$${totalCost.toFixed(2)}</span> <span style="color:var(--text-xs)">· ${filtered.length} sessions</span></div>`;
3219
3832
  }
3220
3833
 
3221
3834
  // ── feature 27: export sessions CSV ───────────────────────
@@ -3317,6 +3930,254 @@ async function loadProjCosts() {
3317
3930
  }
3318
3931
  }
3319
3932
 
3933
+ // ── feature 47: 30-day daily cost trend ───────────────────
3934
+ function buildDailyCostTrend() {
3935
+ const el = document.getElementById('m-daily-trend');
3936
+ if (!el || !allSessions.length) return;
3937
+ const now = Date.now(); const DAY = 86400000;
3938
+ const buckets = new Array(30).fill(0);
3939
+ const dates = new Array(30).fill(null).map((_, i) => new Date(now - (29 - i) * DAY).toDateString());
3940
+ for (const s of allSessions) {
3941
+ const t = s.firstTs || s.mtime; if (!t) continue;
3942
+ const ts = typeof t === 'number' ? t : new Date(t).getTime();
3943
+ const daysAgo = Math.floor((now - ts) / DAY);
3944
+ const idx = 29 - daysAgo;
3945
+ if (idx >= 0 && idx < 30) buckets[idx] += s.totalCost || 0;
3946
+ }
3947
+ const maxCost = Math.max(0.001, ...buckets);
3948
+ const totalCost = buckets.reduce((a, b) => a + b, 0);
3949
+ const bars = buckets.map((v, i) => {
3950
+ const h = Math.max(2, Math.round((v / maxCost) * 38));
3951
+ const isActive = dates[i] === heatmapDateFilter;
3952
+ const label = i === 29 ? 'Today' : (i === 28 ? 'Yday' : '');
3953
+ return `<div class="dt-bar${isActive ? ' active' : ''}" style="height:${h}px" title="${dates[i]}: $${v.toFixed(3)}" onclick="setDailyTrendFilter('${dates[i]}',${v})"></div>`;
3954
+ }).join('');
3955
+ el.innerHTML = `<div class="m-group-title">30-Day Cost <span style="font-size:10px;color:var(--text-xs);font-weight:400">$${totalCost.toFixed(2)} total</span></div>
3956
+ <div id="daily-trend-chart">${bars}</div>
3957
+ <div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-xs);margin-top:2px"><span>30d ago</span><span>Today</span></div>`;
3958
+ }
3959
+
3960
+ function setDailyTrendFilter(dateStr, cost) {
3961
+ if (!cost) return;
3962
+ heatmapDateFilter = heatmapDateFilter === dateStr ? null : dateStr;
3963
+ const clearBtn = document.getElementById('shm-clear');
3964
+ if (clearBtn) clearBtn.classList.toggle('show', !!heatmapDateFilter);
3965
+ viewRendered['sessions'] = false;
3966
+ renderSessions();
3967
+ syncURLParams();
3968
+ }
3969
+
3970
+ // ── feature 48: live cost ticker ──────────────────────────
3971
+ let _liveTickerCost = 0;
3972
+ let _liveTickerPrev = 0;
3973
+
3974
+ function updateLiveTicker(cost) {
3975
+ const el = document.getElementById('live-cost-ticker');
3976
+ if (!el) return;
3977
+ if (cost == null || cost === 0) { el.classList.remove('show'); return; }
3978
+ _liveTickerPrev = _liveTickerCost;
3979
+ _liveTickerCost = cost;
3980
+ const delta = _liveTickerCost - _liveTickerPrev;
3981
+ const deltaHtml = delta > 0.0001 ? `<span class="lct-change">+$${delta.toFixed(4)}</span>` : '';
3982
+ el.innerHTML = `$${cost.toFixed(3)}${deltaHtml}`;
3983
+ el.classList.add('show');
3984
+ }
3985
+
3986
+ // ── feature 49: hourly productivity heatmap ───────────────
3987
+ function buildHourlyHeatmap() {
3988
+ const el = document.getElementById('m-hourly-heatmap');
3989
+ if (!el || !allSessions.length) return;
3990
+ // 24 hours × 7 days-of-week matrix
3991
+ const grid = Array.from({ length: 7 }, () => new Array(24).fill(0));
3992
+ for (const s of allSessions) {
3993
+ const t = s.firstTs || s.mtime; if (!t) continue;
3994
+ const d = new Date(typeof t === 'number' ? t : t);
3995
+ const dow = d.getDay(); // 0=Sun
3996
+ const hour = d.getHours();
3997
+ grid[dow][hour]++;
3998
+ }
3999
+ const maxVal = Math.max(1, ...grid.flatMap(r => r));
4000
+ const dayNames = ['Su','Mo','Tu','We','Th','Fr','Sa'];
4001
+ let html = '<div id="hourly-heatmap-grid">';
4002
+ // Header row: empty corner + hour labels (0,6,12,18,23)
4003
+ html += '<div></div>';
4004
+ for (let h = 0; h < 24; h++) {
4005
+ const lbl = h % 6 === 0 ? String(h) : '';
4006
+ html += `<div class="hh-hour-lbl">${lbl}</div>`;
4007
+ }
4008
+ // Data rows
4009
+ for (let d = 0; d < 7; d++) {
4010
+ html += `<div class="hh-day-lbl">${dayNames[d]}</div>`;
4011
+ for (let h = 0; h < 24; h++) {
4012
+ const v = grid[d][h];
4013
+ const level = v === 0 ? 0 : Math.min(4, Math.ceil(v / maxVal * 4));
4014
+ html += `<div class="hh-cell hh-${level}" title="${dayNames[d]} ${h}:00 — ${v} session${v !== 1 ? 's' : ''}"></div>`;
4015
+ }
4016
+ }
4017
+ html += '</div>';
4018
+ const peakHour = grid.flatMap((r, d) => r.map((v, h) => ({ d, h, v }))).sort((a, b) => b.v - a.v)[0];
4019
+ const peakLabel = peakHour ? `${dayNames[peakHour.d]} ${peakHour.h}:00` : '';
4020
+ el.innerHTML = `<div class="m-group-title">Peak Work Hours${peakLabel ? `<span style="font-size:10px;color:var(--text-xs);font-weight:400;margin-left:4px">peak: ${peakLabel}</span>` : ''}</div>${html}`;
4021
+ }
4022
+
4023
+ // ── feature 50: custom tag editor ─────────────────────────
4024
+ const _customTagsKey = 'mm-custom-tags';
4025
+ let _customTagsMap = new Map(Object.entries(JSON.parse(localStorage.getItem(_customTagsKey) || '{}')));
4026
+
4027
+ function getCustomTags(sessId) {
4028
+ return _customTagsMap.get(sessId) || [];
4029
+ }
4030
+
4031
+ function saveCustomTags(sessId, tags) {
4032
+ if (tags.length === 0) _customTagsMap.delete(sessId);
4033
+ else _customTagsMap.set(sessId, tags);
4034
+ localStorage.setItem(_customTagsKey, JSON.stringify(Object.fromEntries(_customTagsMap)));
4035
+ }
4036
+
4037
+ function addCustomTag(sessId, tag, event) {
4038
+ if (event) event.stopPropagation();
4039
+ const t = tag.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
4040
+ if (!t) return;
4041
+ const tags = getCustomTags(sessId);
4042
+ if (!tags.includes(t)) { tags.push(t); saveCustomTags(sessId, tags); }
4043
+ const wrap = document.querySelector(`.sr-custom-tags[data-sess="${CSS.escape(sessId)}"]`);
4044
+ if (wrap) wrap.outerHTML = renderCustomTagsInline(sessId, tags);
4045
+ // rebuild tag filter if this tag is new globally
4046
+ initTags(); buildTagFilterBar(allSessions);
4047
+ }
4048
+
4049
+ function removeCustomTag(sessId, tag, event) {
4050
+ if (event) event.stopPropagation();
4051
+ const tags = getCustomTags(sessId).filter(t => t !== tag);
4052
+ saveCustomTags(sessId, tags);
4053
+ const wrap = document.querySelector(`.sr-custom-tags[data-sess="${CSS.escape(sessId)}"]`);
4054
+ if (wrap) wrap.outerHTML = renderCustomTagsInline(sessId, tags);
4055
+ }
4056
+
4057
+ function showCustomTagInput(sessId, event) {
4058
+ if (event) event.stopPropagation();
4059
+ const wrap = document.querySelector(`.sr-custom-tags[data-sess="${CSS.escape(sessId)}"]`);
4060
+ if (!wrap) return;
4061
+ const existing = wrap.querySelector('.ctag-input-wrap');
4062
+ if (existing) { existing.remove(); return; }
4063
+ const iw = document.createElement('div');
4064
+ iw.className = 'ctag-input-wrap';
4065
+ iw.onclick = e => e.stopPropagation();
4066
+ iw.innerHTML = `<input class="ctag-input" type="text" placeholder="tag name…" maxlength="20" autofocus>
4067
+ <button class="ctag-ok" onclick="(()=>{const inp=this.previousSibling;addCustomTag('${esc(sessId)}',inp.value,event);inp.value='';})()">Add</button>`;
4068
+ wrap.appendChild(iw);
4069
+ const inp = iw.querySelector('input');
4070
+ inp.focus();
4071
+ inp.addEventListener('keydown', e => {
4072
+ if (e.key === 'Enter') { addCustomTag(sessId, inp.value, e); inp.value = ''; }
4073
+ if (e.key === 'Escape') iw.remove();
4074
+ });
4075
+ }
4076
+
4077
+ function renderCustomTagsInline(sessId, tags) {
4078
+ const tagHtml = tags.map(t =>
4079
+ `<span class="sr-ctag">${esc(t)}<span class="ctag-del" onclick="removeCustomTag('${esc(sessId)}','${esc(t)}',event)" title="Remove tag">✕</span></span>`
4080
+ ).join('');
4081
+ return `<div class="sr-custom-tags" data-sess="${esc(sessId)}" onclick="event.stopPropagation()">
4082
+ ${tagHtml}
4083
+ <button class="ctag-add-btn" onclick="showCustomTagInput('${esc(sessId)}',event)" title="Add tag">+ tag</button>
4084
+ </div>`;
4085
+ }
4086
+
4087
+ // ── feature 51: tool error drawer ─────────────────────────
4088
+ async function toggleErrDrawer(sessId, event) {
4089
+ if (event) event.stopPropagation();
4090
+ const drawer = document.getElementById('err-drawer-' + sessId);
4091
+ if (!drawer) return;
4092
+ if (drawer.classList.contains('open')) { drawer.classList.remove('open'); drawer.innerHTML = ''; return; }
4093
+ drawer.classList.add('open');
4094
+ drawer.innerHTML = '<div class="err-drawer-head">Loading errors…</div>';
4095
+ try {
4096
+ const data = await apiFetch('/api/session-errors?dir=' + enc(DIR) + '&id=' + enc(sessId));
4097
+ if (!data.errors?.length) {
4098
+ drawer.innerHTML = `<div class="err-drawer-head"><span>No error details found</span><button class="err-close" onclick="toggleErrDrawer('${esc(sessId)}',event)">✕</button></div>`;
4099
+ return;
4100
+ }
4101
+ const items = data.errors.map(e => `<div class="err-item">${esc(e.text)}</div>`).join('');
4102
+ drawer.innerHTML = `<div class="err-drawer-head"><span>${data.errors.length} error${data.errors.length !== 1 ? 's' : ''}</span><button class="err-close" onclick="toggleErrDrawer('${esc(sessId)}',event)">✕</button></div>
4103
+ <div class="err-drawer-body">${items}</div>`;
4104
+ } catch (err) {
4105
+ drawer.innerHTML = `<div class="err-drawer-head"><span>Could not load: ${esc(err.message)}</span><button class="err-close" onclick="toggleErrDrawer('${esc(sessId)}',event)">✕</button></div>`;
4106
+ }
4107
+ }
4108
+
4109
+ // ── feature 52: prompt copy button ────────────────────────
4110
+ function copyPrompt(text, event) {
4111
+ if (event) event.stopPropagation();
4112
+ const btn = event?.currentTarget || event?.target;
4113
+ navigator.clipboard.writeText(text).then(() => {
4114
+ if (btn) { btn.textContent = '✓'; btn.classList.add('copied'); setTimeout(() => { btn.textContent = '⎘'; btn.classList.remove('copied'); }, 1500); }
4115
+ }).catch(() => {});
4116
+ }
4117
+
4118
+ // ── feature 53: session cost histogram ────────────────────
4119
+ function buildCostHistogram() {
4120
+ const el = document.getElementById('cost-histogram-panel');
4121
+ if (!el) return;
4122
+ const costs = allSessions.map(s => s.totalCost || 0).filter(c => c > 0);
4123
+ if (costs.length < 2) { el.style.display = 'none'; return; }
4124
+ el.style.display = 'block';
4125
+ const minC = Math.min(...costs); const maxC = Math.max(...costs);
4126
+ const BUCKETS = 10;
4127
+ const range = maxC - minC || 0.01;
4128
+ const bucketSize = range / BUCKETS;
4129
+ const counts = new Array(BUCKETS).fill(0);
4130
+ for (const c of costs) { const i = Math.min(BUCKETS - 1, Math.floor((c - minC) / bucketSize)); counts[i]++; }
4131
+ const maxCount = Math.max(1, ...counts);
4132
+ const fmt = v => v < 0.01 ? v.toFixed(4) : v < 1 ? '$' + v.toFixed(2) : '$' + v.toFixed(1);
4133
+ const bars = counts.map((n, i) => {
4134
+ const h = Math.max(2, Math.round((n / maxCount) * 46));
4135
+ const lo = minC + i * bucketSize; const hi = lo + bucketSize;
4136
+ return `<div class="ch-bar-wrap" title="${fmt(lo)}–${fmt(hi)}: ${n} session${n !== 1 ? 's' : ''}">
4137
+ <div class="ch-cnt">${n || ''}</div>
4138
+ <div class="ch-bar" style="height:${h}px"></div>
4139
+ <div class="ch-lbl">${i === 0 ? fmt(lo) : i === BUCKETS - 1 ? fmt(hi) : ''}</div>
4140
+ </div>`;
4141
+ }).join('');
4142
+ el.innerHTML = `<div class="ch-title">Cost Distribution — ${costs.length} sessions</div>
4143
+ <div class="ch-bars">${bars}</div>`;
4144
+ }
4145
+
4146
+ // ── feature 54: persistent filter URL params ──────────────
4147
+ function syncURLParams() {
4148
+ const p = new URLSearchParams(window.location.search);
4149
+ if (DIR) p.set('proj', DIR); else p.delete('proj');
4150
+ if (activePeriod && activePeriod !== 'day') p.set('period', activePeriod); else p.delete('period');
4151
+ if (activeTagFilter) p.set('tag', activeTagFilter); else p.delete('tag');
4152
+ if (heatmapDateFilter) p.set('date', heatmapDateFilter); else p.delete('date');
4153
+ if (showStarredOnly) p.set('starred', '1'); else p.delete('starred');
4154
+ const newUrl = window.location.pathname + (p.toString() ? '?' + p.toString() : '');
4155
+ history.replaceState(null, '', newUrl);
4156
+ }
4157
+
4158
+ function restoreURLParams() {
4159
+ const p = new URLSearchParams(window.location.search);
4160
+ const period = p.get('period');
4161
+ if (period && ['day','week','month','all'].includes(period)) {
4162
+ activePeriod = period;
4163
+ document.querySelectorAll('.period-btn').forEach(b => b.classList.toggle('active', b.dataset.period === period));
4164
+ }
4165
+ const tag = p.get('tag');
4166
+ if (tag) activeTagFilter = tag;
4167
+ const date = p.get('date');
4168
+ if (date) {
4169
+ heatmapDateFilter = date;
4170
+ const clearBtn = document.getElementById('shm-clear');
4171
+ if (clearBtn) clearBtn.classList.add('show');
4172
+ }
4173
+ const starred = p.get('starred');
4174
+ if (starred === '1') {
4175
+ showStarredOnly = true;
4176
+ const btn = document.getElementById('sess-star-filter');
4177
+ if (btn) btn.classList.add('on');
4178
+ }
4179
+ }
4180
+
3320
4181
  // ── helpers ────────────────────────────────────────────────
3321
4182
  function enc(s) { return encodeURIComponent(s); }
3322
4183
  function esc(s) {