claude-code-memory-explorer 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -4,6 +4,7 @@ let projectData = null;
4
4
  let stackData = [];
5
5
  let summaryData = null;
6
6
  let selectedFileId = null;
7
+ let healthViewOpen = true; // health dashboard is the default view; selecting a file exits it
7
8
 
8
9
  // #endregion STATE
9
10
 
@@ -58,7 +59,7 @@ function highlightSource(text, fileName) {
58
59
  if (fm) {
59
60
  const fmHtml = hljs.highlight(fm[1], { language: 'yaml' }).value;
60
61
  const bodyHtml = hljs.highlight(fm[2], { language: 'markdown' }).value;
61
- return `<span class="hl-frontmatter">---</span>\n${fmHtml}\n<span class="hl-frontmatter">---</span>\n${bodyHtml}`;
62
+ return `<span class="hl-fm-block"><span class="hl-frontmatter">---</span>\n${fmHtml}\n<span class="hl-frontmatter">---</span></span>\n${bodyHtml}`;
62
63
  }
63
64
  }
64
65
  return hljs.highlight(text, { language: lang }).value;
@@ -304,6 +305,13 @@ function toggleHelpModal() {
304
305
  document.getElementById('helpModal').classList.toggle('open');
305
306
  }
306
307
 
308
+ function openHealthView() {
309
+ healthViewOpen = !healthViewOpen;
310
+ document.getElementById('analyzeBtn')?.classList.toggle('active', healthViewOpen);
311
+ renderPreview();
312
+ if (healthViewOpen) refreshAnalysis();
313
+ }
314
+
307
315
  function bindModalKeys(inputId, modalId, submitFn) {
308
316
  document.getElementById(inputId).addEventListener('keydown', (e) => {
309
317
  if (e.key === 'Enter') {
@@ -321,12 +329,13 @@ function bindModalKeys(inputId, modalId, submitFn) {
321
329
 
322
330
  // #region RENDER_TREE
323
331
 
324
- const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'agent-memory'];
332
+ const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'skill', 'agent-memory'];
325
333
  const SCOPE_LABELS = {
326
334
  policy: 'Managed Policy',
327
335
  user: 'User',
328
336
  project: 'Project',
329
337
  rule: 'Rules',
338
+ skill: 'Skills',
330
339
  memory: 'Auto Memory',
331
340
  'agent-memory': 'Agent Memory',
332
341
  };
@@ -335,6 +344,7 @@ const LOAD_ICONS = {
335
344
  startup: '\u25D2',
336
345
  conditional: '\u25CB',
337
346
  ondemand: '\u25CC',
347
+ tree: '\u25CE',
338
348
  import: '@',
339
349
  link: '\u2197',
340
350
  };
@@ -343,6 +353,7 @@ const LOAD_TITLES = {
343
353
  startup: 'Loaded at startup (partial)',
344
354
  conditional: 'Conditional (path-scoped)',
345
355
  ondemand: 'On-demand',
356
+ tree: 'Loaded progressively (when Claude works in its directory)',
346
357
  import: 'Imported by parent file',
347
358
  link: 'Referenced via markdown link (not auto-loaded)',
348
359
  };
@@ -390,7 +401,8 @@ function renderTree() {
390
401
  const loadIcon = LOAD_ICONS[item.load] || '';
391
402
  const loadTitle = LOAD_TITLES[item.load] || item.load;
392
403
  const meta = `${item.lines}L`;
393
- const isConditional = item.load === 'conditional' || item.load === 'ondemand' || item.load === 'link';
404
+ const isConditional =
405
+ item.load === 'conditional' || item.load === 'ondemand' || item.load === 'link' || item.load === 'tree';
394
406
  const pad = indent ? ` style="padding-left:${12 + indent * 16}px"` : '';
395
407
  const muted = item.scope === 'agent-memory' ? ' tree-agent-item' : '';
396
408
  const children = childrenOf[item.id];
@@ -398,10 +410,19 @@ function renderTree() {
398
410
  const chevron = children
399
411
  ? `<span class="tree-chevron" onclick="_toggleItem(event,'${escAttrJs(item.id)}')" title="${children.length} referenced">${expanded ? '▾' : '▸'}</span>`
400
412
  : '<span class="tree-chevron-spacer"></span>';
401
- let h = `<div class="tree-item${sel}${indent ? ' tree-child' : ''}${isConditional ? ' tree-conditional' : ''}${muted}" data-id="${esc(item.id)}" title="${esc(item.path)}" onclick="selectFile('${escAttrJs(item.id)}')"${pad}>`;
413
+ const fp = footprintHighlight && footprintHit(item) ? ' fp-hit' : '';
414
+ let h = `<div class="tree-item${sel}${indent ? ' tree-child' : ''}${isConditional ? ' tree-conditional' : ''}${muted}${fp}" data-id="${esc(item.id)}" title="${esc(item.path)}" onclick="selectFile('${escAttrJs(item.id)}')"${pad}>`;
402
415
  h += chevron;
403
416
  h += `<span class="load-icon" title="${loadTitle}" style="color:var(--scope-${item.scope})">${loadIcon}</span>`;
404
417
  h += `<span class="file-name">${esc(item.name)}</span>`;
418
+ const hs = treeHealth.get(item.id);
419
+ if (hs) {
420
+ const sev = hs.high ? 'high' : hs.med ? 'med' : 'low';
421
+ const n = hs.high || hs.med || hs.low;
422
+ h += `<span class="tree-health th-${esc(sev)}" title="${esc(hs.high)} high · ${esc(hs.med)} med · ${esc(hs.low)} low — open Health for details">${n}</span>`;
423
+ } else if (auditedIds.has(item.id)) {
424
+ h += '<span class="tree-health th-clean" title="Audited — no findings"></span>';
425
+ }
405
426
  h += `<span class="file-meta">${meta}</span>`;
406
427
  h += '</div>';
407
428
  if (expanded) {
@@ -502,6 +523,10 @@ function pushFileState(id) {
502
523
  }
503
524
 
504
525
  function selectFile(id, pushState = true) {
526
+ if (healthViewOpen) {
527
+ healthViewOpen = false;
528
+ document.getElementById('analyzeBtn')?.classList.remove('active');
529
+ }
505
530
  selectedFileId = selectedFileId === id ? null : id;
506
531
  if (selectedFileId) {
507
532
  const item = stackData.find((s) => s.id === selectedFileId);
@@ -637,6 +662,12 @@ function renderMemoryIndexTable(entries) {
637
662
 
638
663
  async function renderPreview() {
639
664
  const panel = document.getElementById('previewPanel');
665
+ if (healthViewOpen) {
666
+ document.getElementById('analyzeBtn')?.classList.add('active');
667
+ panel.innerHTML = '<div class="health-view"><div id="analysisSection"></div></div>';
668
+ if (lastAnalysisSt) rerenderAnalysis();
669
+ return;
670
+ }
640
671
  const source = stackData.find((s) => s.id === selectedFileId);
641
672
  if (!source) {
642
673
  panel.innerHTML =
@@ -667,14 +698,30 @@ async function renderPreview() {
667
698
  html += `<button class="action-btn small" onclick="_confirmDeleteFile('${escAttrJs(source.path)}','${escAttrJs(source.name)}')" title="Delete file"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>`;
668
699
  html += '</div></div>';
669
700
 
701
+ // Description gets its own full-width line; the rest stay as key:value chips
702
+ const fmDesc =
703
+ fileData.frontmatter && typeof fileData.frontmatter.description === 'string'
704
+ ? fileData.frontmatter.description.replace(/\s+/g, ' ')
705
+ : '';
706
+ if (fmDesc)
707
+ html += `<div class="preview-desc" title="Click to expand" onclick="this.classList.toggle('expanded')">${esc(fmDesc)}</div>`;
708
+
670
709
  // Badges row
671
710
  html += '<div class="preview-badges">';
672
711
  html += `<span class="load-badge load-${source.load}">${esc(source.load)}</span>`;
673
712
  html += `<span class="tag-badge">${source.lines}L / ${formatBytes(source.bytes)}</span>`;
674
713
  if (fileData.frontmatter) {
675
714
  for (const [k, v] of Object.entries(fileData.frontmatter)) {
676
- const val = Array.isArray(v) ? v.join(', ') : v;
677
- html += `<span class="tag-badge">${esc(k)}: ${esc(String(val))}</span>`;
715
+ if (k === 'description' && fmDesc) continue;
716
+ let val;
717
+ if (Array.isArray(v)) val = v.join(', ');
718
+ else if (v && typeof v === 'object')
719
+ val = Object.entries(v)
720
+ .map(([ck, cv]) => `${ck}=${cv}`)
721
+ .join(', ');
722
+ else val = String(v).replace(/\s+/g, ' ');
723
+ const shown = val.length > 72 ? `${val.slice(0, 69)}…` : val;
724
+ html += `<span class="tag-badge" title="${esc(`${k}: ${val}`)}"><span class="tag-k">${esc(k)}</span>${esc(shown)}</span>`;
678
725
  }
679
726
  }
680
727
  html += '</div>';
@@ -696,14 +743,17 @@ async function renderPreview() {
696
743
  html += '</div>';
697
744
  }
698
745
 
746
+ // File path lives inside the header as a muted line instead of its own band
747
+ html += `<div class="preview-filepath" title="${esc(source.path)}">${esc(source.path)}</div>`;
699
748
  html += '</div>';
700
749
 
701
- // File path
702
- html += `<div class="preview-filepath">${esc(source.path)}</div>`;
750
+ html += renderFileFindings(source);
703
751
 
704
752
  const hl = (text) => {
705
753
  const { text: processed, placeholders } = linkifyImports(text, source.id);
706
- return restorePlaceholders(highlightSource(processed, source.name), placeholders);
754
+ // Skill names aren't filenames (e.g. "aspire") — derive the language from the real file
755
+ const fileName = source.path ? source.path.split(/[\\/]/).pop() : source.name;
756
+ return restorePlaceholders(highlightSource(processed, fileName), placeholders);
707
757
  };
708
758
 
709
759
  if (showMemoryTable) {
@@ -848,41 +898,941 @@ async function openInEditor(filePath) {
848
898
 
849
899
  // #endregion RENDER_PREVIEW
850
900
 
901
+ // #region ANALYZER
902
+
903
+ let analysisPollTimer = null;
904
+ let lastAnalysisSt = null;
905
+ let analysisFindings = []; // findings of the rendered result, addressed by index from handlers
906
+ let analysisFilter = 'all';
907
+ // Server persists dismissals per scope: project findings (cross included) in the
908
+ // project entry, user-scope findings in the shared user entry (visible from every
909
+ // project). Keys are `${kind}|${title}`.
910
+ const dismissedByScope = { project: new Set(), user: new Set() };
911
+ function dismissScope(f) {
912
+ return f.scope === 'user' ? 'user' : 'project';
913
+ }
914
+ function isDismissed(f) {
915
+ return dismissedByScope[dismissScope(f)].has(findingKey(f));
916
+ }
917
+ function dismissedCount() {
918
+ return dismissedByScope.project.size + dismissedByScope.user.size;
919
+ }
920
+ let dismissWrites = 0; // dismiss POSTs in flight — polls must not clobber the local set meanwhile
921
+ let lastPollKey = null; // fingerprint of the last rendered analysis state
922
+ let analysisTs = null; // reset filters when a new result lands
923
+ let analysisRunIdx = -1; // shown run: -1 = merged current state (default), 0+ = single run
924
+ let fixSelected = new Set(); // finding keys queued for the copyable fix plan
925
+ let analysisFileFilter = null; // {id, name} from a memory-map cell click; narrows the findings list
926
+ let treeHealth = new Map(); // source id -> {high, med, low} from the latest run
927
+ let auditedIds = new Set(); // ids covered by the latest run (clean files get a hollow dot)
928
+
929
+ // Identity of a finding, used by dismiss/copy/render alike
930
+ function findingKey(f) {
931
+ return `${f.kind}|${f.title}`;
932
+ }
933
+
934
+ function resetAnalysisView(runIdx = -1) {
935
+ analysisRunIdx = runIdx;
936
+ analysisFilter = 'all';
937
+ fixSelected = new Set();
938
+ analysisFileFilter = null;
939
+ }
940
+
941
+ // Findings name files by bare name while map cells carry display names (e.g.
942
+ // skills), so matching goes through ids — the same resolution as findingFileIds.
943
+ function _setFileFilter(id, name) {
944
+ analysisFileFilter = analysisFileFilter?.id === id ? null : { id, name };
945
+ rerenderAnalysis();
946
+ }
947
+
948
+ function findingMatchesFileFilter(f) {
949
+ if (!analysisFileFilter) return true;
950
+ return (f.files || []).some(
951
+ (n) => n === analysisFileFilter.name || findingFileIds(n, f.scope).includes(analysisFileFilter.id),
952
+ );
953
+ }
954
+
955
+ // Health annotations on the tree always reflect the merged current state,
956
+ // regardless of which historical run the health view is showing.
957
+ function computeTreeHealth(st) {
958
+ treeHealth = new Map();
959
+ auditedIds = new Set();
960
+ const run = mergedRunView(st);
961
+ if (!run) return;
962
+ for (const s of runAuditedSources(run)) auditedIds.add(s.id);
963
+ for (const f of run.result?.findings || []) {
964
+ if (isDismissed(f)) continue;
965
+ for (const name of f.files || []) {
966
+ for (const id of findingFileIds(name, f.scope)) {
967
+ const cur = treeHealth.get(id) || { high: 0, med: 0, low: 0 };
968
+ cur[f.severity] = (cur[f.severity] || 0) + 1;
969
+ treeHealth.set(id, cur);
970
+ }
971
+ }
972
+ }
973
+ }
974
+
975
+ // Dismissals change what the tree badges should show — recompute and repaint.
976
+ function syncTreeHealth() {
977
+ computeTreeHealth(lastAnalysisSt);
978
+ renderTree();
979
+ }
980
+ let scopePanelOpen = false;
981
+ let scopeChecked = null; // Set of source ids; seeded with the default scope on first open
982
+ const scopeCollapsed = new Set();
983
+ const ANALYSIS_MODELS = ['sonnet', 'opus', 'fable'];
984
+ let analysisModel = ANALYSIS_MODELS.includes(localStorage.getItem('analysisModel'))
985
+ ? localStorage.getItem('analysisModel')
986
+ : 'sonnet';
987
+
988
+ function _setAnalysisModel(m) {
989
+ if (!ANALYSIS_MODELS.includes(m)) return;
990
+ analysisModel = m;
991
+ try {
992
+ localStorage.setItem('analysisModel', m);
993
+ } catch {}
994
+ rerenderAnalysis();
995
+ }
996
+
997
+ async function _runAnalysis(force, ids) {
998
+ try {
999
+ const body = { force: !!force, model: analysisModel };
1000
+ if (Array.isArray(ids) && ids.length) body.ids = ids;
1001
+ const res = await fetch('/api/memory/analyze', {
1002
+ method: 'POST',
1003
+ headers: { 'Content-Type': 'application/json' },
1004
+ body: JSON.stringify(body),
1005
+ });
1006
+ if (!res.ok) {
1007
+ const msg = await res
1008
+ .json()
1009
+ .then((e) => e.error)
1010
+ .catch(() => `Analyze failed (${res.status})`);
1011
+ showToast(msg, 'error');
1012
+ return;
1013
+ }
1014
+ const data = await res.json().catch(() => ({}));
1015
+ if (data.cached) showToast('Memory unchanged — showing cached analysis. Use Re-run to force.', 'info');
1016
+ } catch (err) {
1017
+ showToast(err.message, 'error');
1018
+ return;
1019
+ }
1020
+ refreshAnalysis();
1021
+ }
1022
+
1023
+ async function refreshAnalysis() {
1024
+ let st;
1025
+ try {
1026
+ st = await fetchJSON('/api/memory/analysis');
1027
+ } catch {
1028
+ return;
1029
+ }
1030
+ const tsKey = `${st.ts}|${st.user?.ts || ''}`;
1031
+ const isNew = tsKey !== analysisTs;
1032
+ if (isNew) {
1033
+ analysisTs = tsKey;
1034
+ resetAnalysisView();
1035
+ }
1036
+ // A dismiss POST in flight owns the set — adopting the server's (older) copy here
1037
+ // would briefly resurrect the finding the user just dismissed.
1038
+ if (!dismissWrites) {
1039
+ dismissedByScope.project = new Set(st.dismissed || []);
1040
+ dismissedByScope.user = new Set(st.user?.dismissed || []);
1041
+ }
1042
+ lastAnalysisSt = st;
1043
+ // Polls where only the banner clock changed must not rebuild the review DOM —
1044
+ // that would tear down the cards mid-interaction every 2.5s while a run is in flight.
1045
+ const pollKey = JSON.stringify([
1046
+ tsKey,
1047
+ st.error,
1048
+ [...dismissedByScope.project, ...dismissedByScope.user],
1049
+ (st.pending || []).map((p) => `${p.id}${p.stalled ? ':stalled' : ''}`),
1050
+ ]);
1051
+ const banner = document.getElementById('runningBanner');
1052
+ if (pollKey === lastPollKey && banner) {
1053
+ banner.innerHTML = renderRunningBanner(st);
1054
+ } else {
1055
+ computeTreeHealth(st);
1056
+ renderTree();
1057
+ if (isNew && !healthViewOpen && selectedFileId) renderPreview();
1058
+ const el = document.getElementById('analysisSection');
1059
+ if (el) {
1060
+ el.innerHTML = renderAnalysis(st);
1061
+ applyScopeIndeterminate(el);
1062
+ }
1063
+ }
1064
+ lastPollKey = pollKey;
1065
+ clearTimeout(analysisPollTimer);
1066
+ // Stalled-only pendings keep a slow poll alive — the banner promises a late
1067
+ // completion will surface, so it must actually be able to.
1068
+ const pend = st.pending || [];
1069
+ if (pend.some((p) => !p.stalled)) analysisPollTimer = setTimeout(refreshAnalysis, 2500);
1070
+ else if (pend.length) analysisPollTimer = setTimeout(refreshAnalysis, 30000);
1071
+ }
1072
+
1073
+ // Re-render from the cached state — used by scope/filter/dismiss handlers so a
1074
+ // checkbox click never triggers a server round-trip.
1075
+ function rerenderAnalysis() {
1076
+ const el = document.getElementById('analysisSection');
1077
+ if (!el || !lastAnalysisSt) return;
1078
+ el.innerHTML = renderAnalysis(lastAnalysisSt);
1079
+ applyScopeIndeterminate(el);
1080
+ }
1081
+
1082
+ // `indeterminate` is a DOM property, not an attribute — set it after every innerHTML render.
1083
+ function applyScopeIndeterminate(el) {
1084
+ for (const box of el.querySelectorAll('input[data-ind]')) box.indeterminate = true;
1085
+ }
1086
+
1087
+ // #region ANALYZER_SCOPE
1088
+
1089
+ const CLAUDE_MD_SCOPES = new Set(['project', 'local']);
1090
+
1091
+ function analysisScopeGroups() {
1092
+ return [
1093
+ {
1094
+ key: 'memory',
1095
+ label: 'Auto memory',
1096
+ cls: 't-memory',
1097
+ tag: 'memory',
1098
+ items: stackData.filter((s) => s.scope === 'memory'),
1099
+ },
1100
+ {
1101
+ key: 'skill',
1102
+ label: 'Skills',
1103
+ cls: 't-skill',
1104
+ tag: 'skill',
1105
+ items: stackData.filter((s) => s.scope === 'skill' && !s.parentId),
1106
+ },
1107
+ {
1108
+ key: 'claudemd',
1109
+ label: 'CLAUDE.md',
1110
+ cls: 't-claudemd',
1111
+ tag: 'md',
1112
+ items: stackData.filter((s) => CLAUDE_MD_SCOPES.has(s.scope) && !s.parentId),
1113
+ },
1114
+ {
1115
+ key: 'agent',
1116
+ label: 'Agent memory',
1117
+ cls: 't-agent',
1118
+ tag: 'agent',
1119
+ items: stackData.filter((s) => s.scope === 'agent-memory'),
1120
+ },
1121
+ {
1122
+ key: 'user',
1123
+ label: 'User CLAUDE.md',
1124
+ cls: 't-user',
1125
+ tag: 'user',
1126
+ items: stackData.filter((s) => s.scope === 'user' && !s.parentId),
1127
+ },
1128
+ ].filter((g) => g.items.length);
1129
+ }
1130
+
1131
+ function _toggleScopePanel() {
1132
+ scopePanelOpen = !scopePanelOpen;
1133
+ if (scopePanelOpen && !scopeChecked) {
1134
+ // default scope = auto memory only (skills, agent memory, CLAUDE.md are opt-in)
1135
+ scopeChecked = new Set(stackData.filter((s) => s.scope === 'memory').map((s) => s.id));
1136
+ }
1137
+ rerenderAnalysis();
1138
+ }
1139
+
1140
+ function _toggleScopeGroup(key) {
1141
+ const g = analysisScopeGroups().find((x) => x.key === key);
1142
+ if (!g) return;
1143
+ const allOn = g.items.every((s) => scopeChecked.has(s.id));
1144
+ for (const s of g.items) {
1145
+ if (allOn) scopeChecked.delete(s.id);
1146
+ else scopeChecked.add(s.id);
1147
+ }
1148
+ rerenderAnalysis();
1149
+ }
1150
+
1151
+ function _toggleScopeLeaf(id) {
1152
+ if (scopeChecked.has(id)) scopeChecked.delete(id);
1153
+ else scopeChecked.add(id);
1154
+ rerenderAnalysis();
1155
+ }
1156
+
1157
+ function _toggleScopeCollapse(key, e) {
1158
+ e.stopPropagation();
1159
+ if (scopeCollapsed.has(key)) scopeCollapsed.delete(key);
1160
+ else scopeCollapsed.add(key);
1161
+ rerenderAnalysis();
1162
+ }
1163
+
1164
+ function _runScopedAnalysis() {
1165
+ if (!scopeChecked?.size) return;
1166
+ scopePanelOpen = false;
1167
+ _runAnalysis(true, [...scopeChecked]);
1168
+ }
1169
+
1170
+ function renderScopePanel() {
1171
+ if (!scopePanelOpen || !scopeChecked) return '';
1172
+ let html = '<div class="scope-panel open">';
1173
+ html += '<div class="scope-panel-title">Select what to analyze</div>';
1174
+ html += '<div class="scope-tree">';
1175
+ for (const g of analysisScopeGroups()) {
1176
+ const on = g.items.filter((s) => scopeChecked.has(s.id)).length;
1177
+ const collapsed = scopeCollapsed.has(g.key);
1178
+ html += `<div class="scope-group${collapsed ? ' collapsed' : ''}">`;
1179
+ html += `<div class="scope-row" onclick="_toggleScopeGroup('${escAttrJs(g.key)}')">`;
1180
+ html += `<span class="scope-caret" onclick="_toggleScopeCollapse('${escAttrJs(g.key)}', event)">${collapsed ? '▸' : '▾'}</span>`;
1181
+ html += `<input type="checkbox" ${on === g.items.length ? 'checked' : ''} ${on > 0 && on < g.items.length ? 'data-ind="1"' : ''} onclick="event.preventDefault()">`;
1182
+ html += `<span class="scope-tag ${esc(g.cls)}">${g.tag}</span>`;
1183
+ html += `<span>${esc(g.label)}</span>`;
1184
+ html += `<span class="scope-meta">${on}/${g.items.length}</span>`;
1185
+ html += '</div>';
1186
+ html += '<div class="scope-children">';
1187
+ for (const s of g.items) {
1188
+ html += `<div class="scope-row" onclick="_toggleScopeLeaf('${escAttrJs(s.id)}')">`;
1189
+ html += `<input type="checkbox" ${scopeChecked.has(s.id) ? 'checked' : ''} onclick="event.preventDefault()">`;
1190
+ html += `<span>${esc(s.name)}</span>`;
1191
+ html += `<span class="scope-meta">${s.lines}L</span>`;
1192
+ html += '</div>';
1193
+ }
1194
+ html += '</div></div>';
1195
+ }
1196
+ html += '</div>';
1197
+ const n = scopeChecked.size;
1198
+ html += '<div class="scope-footer">';
1199
+ html += `<span class="scope-hint">${n} file${n === 1 ? '' : 's'} selected</span>`;
1200
+ html += '<div class="scope-model" title="Model for this analysis">';
1201
+ for (const m of ANALYSIS_MODELS)
1202
+ html += `<button class="scope-model-opt${m === analysisModel ? ' active' : ''}" onclick="_setAnalysisModel('${escAttrJs(m)}')">${m}</button>`;
1203
+ html += '</div>';
1204
+ html += '<button class="action-btn small" onclick="_toggleScopePanel()">Cancel</button>';
1205
+ html += `<button class="action-btn small primary" ${n ? '' : 'disabled'} onclick="_runScopedAnalysis()">Run analysis (${n})</button>`;
1206
+ html += '</div></div>';
1207
+ return html;
1208
+ }
1209
+
1210
+ // #endregion ANALYZER_SCOPE
1211
+
1212
+ // #region ANALYZER_ACTIONS
1213
+
1214
+ function memoryDirPath() {
1215
+ const idx = stackData.find((s) => s.scope === 'memory' && s.name === 'MEMORY.md');
1216
+ return idx ? idx.path.replace(/[\\/][^\\/]+$/, '') : null;
1217
+ }
1218
+
1219
+ function findingPrompt(f) {
1220
+ let header;
1221
+ if (f.scope === 'user') {
1222
+ const path = stackData.find((s) => s.id === 'user-claude-md')?.path || '~/.claude/CLAUDE.md';
1223
+ header = `In the user-level Claude Code instructions at ${path}, apply this fix:`;
1224
+ } else {
1225
+ const dir = memoryDirPath();
1226
+ header = dir
1227
+ ? `In the Claude Code auto-memory at ${dir}, apply this fix:`
1228
+ : "In this project's Claude Code auto-memory, apply this fix:";
1229
+ }
1230
+ const lines = [header, '', `Finding (${f.kind}, ${f.severity}): ${f.title}`, f.detail, '', `Action: ${f.suggestion}`];
1231
+ if ((f.files || []).length) lines.push(`Files: ${f.files.join(', ')}`);
1232
+ if (f.scope !== 'user') lines.push('', 'Update MEMORY.md index lines if files are added, renamed, or removed.');
1233
+ return lines.join('\n');
1234
+ }
1235
+
1236
+ // Badge shown next to the kind for non-project findings; project scope is the
1237
+ // default and carries no badge. Reuses the scope-tag palette from the picker.
1238
+ function scopeBadge(f) {
1239
+ if (!f.scope || f.scope === 'project') return '';
1240
+ const title =
1241
+ f.scope === 'user'
1242
+ ? 'User-scope finding — shared across projects'
1243
+ : 'Cross-scope finding — cites both user and project instructions';
1244
+ return `<span class="scope-tag t-${esc(f.scope)}" title="${esc(title)}">${esc(f.scope)}</span>`;
1245
+ }
1246
+
1247
+ function copyText(text, okMsg) {
1248
+ navigator.clipboard
1249
+ .writeText(text)
1250
+ .then(() => showToast(okMsg, 'info'))
1251
+ .catch(() => showToast('Clipboard unavailable', 'error'));
1252
+ }
1253
+
1254
+ function _copyFindingPrompt(idx) {
1255
+ const f = analysisFindings[idx];
1256
+ if (f) copyText(findingPrompt(f), 'Prompt copied — paste into any Claude Code session');
1257
+ }
1258
+
1259
+ function _copyAllFindings() {
1260
+ const live = analysisFindings.filter((f) => !isDismissed(f));
1261
+ if (!live.length) return;
1262
+ copyText(live.map(findingPrompt).join('\n\n---\n\n'), `${live.length} prompts copied`);
1263
+ }
1264
+
1265
+ // The local set already changed, so the UI never waits; the response's authoritative
1266
+ // list is adopted once no other write is racing it.
1267
+ function persistDismissed(scope, body) {
1268
+ dismissWrites++;
1269
+ fetch('/api/memory/analysis/dismiss', {
1270
+ method: 'POST',
1271
+ headers: { 'Content-Type': 'application/json' },
1272
+ body: JSON.stringify({ ...body, scope }),
1273
+ })
1274
+ .then((r) => r.json())
1275
+ .then((d) => {
1276
+ if (dismissWrites === 1 && Array.isArray(d.dismissed)) dismissedByScope[scope] = new Set(d.dismissed);
1277
+ })
1278
+ .catch(() => {})
1279
+ .finally(() => dismissWrites--);
1280
+ }
1281
+
1282
+ // Shared by the health view and the file-preview cards — collapse the card,
1283
+ // persist the dismissal, then repaint whichever surface hosted it.
1284
+ function dismissFinding(btn, f, repaint) {
1285
+ const card = btn.closest('.analysis-card');
1286
+ card.style.maxHeight = `${card.offsetHeight}px`;
1287
+ requestAnimationFrame(() => card.classList.add('leaving'));
1288
+ setTimeout(() => {
1289
+ // User-scope dismissals go to the shared user entry so they hold across projects.
1290
+ dismissedByScope[dismissScope(f)].add(findingKey(f));
1291
+ persistDismissed(dismissScope(f), { key: findingKey(f) });
1292
+ fixSelected.delete(findingKey(f)); // a dismissed finding can't stay queued in the fix plan
1293
+ syncTreeHealth();
1294
+ repaint();
1295
+ }, 280);
1296
+ }
1297
+
1298
+ function _dismissFinding(btn, idx) {
1299
+ const f = analysisFindings[idx];
1300
+ if (f) dismissFinding(btn, f, rerenderAnalysis);
1301
+ }
1302
+
1303
+ function _restoreDismissed() {
1304
+ for (const scope of Object.keys(dismissedByScope)) {
1305
+ if (dismissedByScope[scope].size) persistDismissed(scope, { all: true });
1306
+ dismissedByScope[scope] = new Set();
1307
+ }
1308
+ syncTreeHealth();
1309
+ rerenderAnalysis();
1310
+ }
1311
+
1312
+ function _setAnalysisFilter(sev) {
1313
+ analysisFilter = sev;
1314
+ rerenderAnalysis();
1315
+ }
1316
+
1317
+ // #endregion ANALYZER_ACTIONS
1318
+
1319
+ function analysisTimeAgo(ts) {
1320
+ const min = Math.round((Date.now() - ts) / 60000);
1321
+ if (min < 1) return 'just now';
1322
+ if (min < 60) return `${min}m ago`;
1323
+ const h = Math.round(min / 60);
1324
+ return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
1325
+ }
1326
+
1327
+ const COPY_ICON =
1328
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
1329
+ const DISMISS_ICON =
1330
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>';
1331
+ const FILE_ICON =
1332
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>';
1333
+
1334
+ function fileChipScope(source) {
1335
+ if (!source) return '';
1336
+ return source.scope === 'agent-memory' ? 'agent' : source.scope;
1337
+ }
1338
+
1339
+ // Mirrors the server's analyzableSources: rules stay reference-only context for
1340
+ // the analyzer; the user CLAUDE.md is auditable (project-blind reviewer, results
1341
+ // shared across projects via the user-scope entry).
1342
+ function analyzableSource(s) {
1343
+ return (
1344
+ s.scope === 'memory' ||
1345
+ s.scope === 'agent-memory' ||
1346
+ ((s.scope === 'skill' || s.scope === 'user' || s.scope === 'project' || s.scope === 'local') && !s.parentId)
1347
+ );
1348
+ }
1349
+
1350
+ // Files covered by a run. Runs carry an `audited` snapshot taken at analysis
1351
+ // time — the authoritative list even after the stack changes. Older runs
1352
+ // predate the snapshot and fall back to re-deriving from the live stack.
1353
+ function runAuditedSources(run) {
1354
+ if (!run) return [];
1355
+ if (run.audited) return run.audited;
1356
+ // Soft-linked (@import) children never go into the analysis, so keep them out
1357
+ // of every health annotation even when their parent's id is in the run scope.
1358
+ const own = stackData.filter((s) => !s.parentId && analyzableSource(s));
1359
+ return run.ids ? own.filter((s) => run.ids.includes(s.id)) : own.filter((s) => s.scope === 'memory');
1360
+ }
1361
+
1362
+ // Findings name files by bare name, which collides across scopes (user vs
1363
+ // project CLAUDE.md) — the finding's `scope` field breaks the tie: user-scope
1364
+ // findings map only to user sources, project-scope ones exclude them ("cross"
1365
+ // keeps both sides). Attribute to audit-eligible sources first; fall back to
1366
+ // any source with that name only when none are eligible — the agent may report
1367
+ // out-of-scope files it read recursively, and those still deserve a badge.
1368
+ function findingFileIds(name, scope) {
1369
+ // The agent sometimes reports a relative path instead of the display name
1370
+ // (e.g. "dev-tools/bonya/.claude/skills/bonya/SKILL.md" for skill
1371
+ // "dev-tools/bonya:bonya") — match those by path suffix. Only slash-bearing
1372
+ // names qualify: a bare "SKILL.md" would suffix-match every skill.
1373
+ // The agent sometimes copies the prompt's scope marker into the name
1374
+ // ("CLAUDE.md (user)") — strip it, the scope field already carries it.
1375
+ const n = name.replace(/\s*\((user|project|local)\)$/, '').replace(/\\/g, '/');
1376
+ const byPath = (s) => {
1377
+ if (!n.includes('/')) return false;
1378
+ const p = (s.path || '').replace(/\\/g, '/');
1379
+ return p === n || p.endsWith(`/${n}`);
1380
+ };
1381
+ let matches = stackData.filter((s) => s.name === n || byPath(s));
1382
+ if (scope === 'user') matches = matches.filter((s) => s.scope === 'user');
1383
+ else if (scope === 'project') matches = matches.filter((s) => s.scope !== 'user');
1384
+ const eligible = matches.filter((s) => !s.parentId && analyzableSource(s));
1385
+ return (eligible.length ? eligible : matches).map((s) => s.id);
1386
+ }
1387
+
1388
+ // Project runs and shared user-scope runs interleave into one timeline —
1389
+ // run chips, the trend, and the merged view all address runs through this.
1390
+ function allAnalysisRuns(st) {
1391
+ const proj = (st?.runs || []).map((run) => ({ run, scope: 'project' }));
1392
+ const user = (st?.user?.runs || []).map((run) => ({ run, scope: 'user' }));
1393
+ return [...proj, ...user].sort((a, b) => b.run.ts - a.run.ts);
1394
+ }
1395
+
1396
+ // The default health view merges runs: each file carries the verdict of the
1397
+ // newest run that audited it, so a narrow re-run (say, one skill) overlays the
1398
+ // wider picture instead of replacing it. Run chips still show single runs.
1399
+ function mergedRunView(st) {
1400
+ const runs = allAnalysisRuns(st).map((e) => e.run);
1401
+ if (runs.length <= 1) return runs[0] || null;
1402
+ const owner = new Map(); // file id -> newest run that audited it
1403
+ const audited = [];
1404
+ for (const run of runs) {
1405
+ for (const s of runAuditedSources(run)) {
1406
+ if (owner.has(s.id)) continue;
1407
+ owner.set(s.id, run);
1408
+ audited.push(s);
1409
+ }
1410
+ }
1411
+ const findings = [];
1412
+ const seen = new Set();
1413
+ for (const run of runs) {
1414
+ for (const f of run.result?.findings || []) {
1415
+ if (seen.has(findingKey(f))) continue;
1416
+ const ids = (f.files || []).flatMap((n) => findingFileIds(n, f.scope));
1417
+ // A finding survives while its run still owns a file it touches — a newer
1418
+ // run that re-audited the file supersedes the old verdict. File-less
1419
+ // findings (suggested new memories) belong to the latest run only.
1420
+ const owns = ids.length ? ids.some((id) => owner.get(id) === run) : run === runs[0];
1421
+ if (!owns) continue;
1422
+ seen.add(findingKey(f));
1423
+ findings.push(f);
1424
+ }
1425
+ }
1426
+ const latest = runs[0];
1427
+ return {
1428
+ ...latest,
1429
+ audited,
1430
+ // The latest run's summary describes only its own scope — misleading when
1431
+ // merged, so it is dropped here.
1432
+ result: { ...latest.result, findings, summary: null, scopeDesc: `${runs.length} runs merged` },
1433
+ };
1434
+ }
1435
+
1436
+ const SEV_WEIGHT = { high: 10, med: 3, low: 1 };
1437
+
1438
+ // Start at 100, subtract per finding by severity. Blunt but explainable.
1439
+ function healthScore(findings) {
1440
+ const penalty = findings.reduce((s, f) => s + (SEV_WEIGHT[f.severity] || 1), 0);
1441
+ return Math.max(2, 100 - penalty);
1442
+ }
1443
+
1444
+ function sevCounts(findings) {
1445
+ const c = { high: 0, med: 0, low: 0 };
1446
+ for (const f of findings) c[f.severity] = (c[f.severity] || 0) + 1;
1447
+ return c;
1448
+ }
1449
+
1450
+ function renderHealthDash(st, shown) {
1451
+ // Score, tiles, and clean count track the live (non-dismissed) list they sit above.
1452
+ const findings = (shown.result?.findings || []).filter((f) => !isDismissed(f));
1453
+ const c = sevCounts(findings);
1454
+ const audited = runAuditedSources(shown);
1455
+ const dirty = new Set(findings.flatMap((f) => (f.files || []).flatMap((n) => findingFileIds(n, f.scope))));
1456
+ const clean = audited.filter((s) => !dirty.has(s.id)).length;
1457
+ const score = healthScore(findings);
1458
+ const scoreColor = score >= 80 ? 'var(--success)' : score >= 50 ? 'var(--warning)' : 'var(--error)';
1459
+ const CIRC = 163.4; // 2πr for r=26
1460
+ let html = '<div class="hv-tiles">';
1461
+ html += '<div class="hv-tile hv-ring-tile">';
1462
+ html += `<div class="hv-ring"><svg width="64" height="64" viewBox="0 0 64 64"><circle cx="32" cy="32" r="26" fill="none" stroke="var(--bg-hover)" stroke-width="6"/><circle cx="32" cy="32" r="26" fill="none" stroke="${esc(scoreColor)}" stroke-width="6" stroke-linecap="round" transform="rotate(-90 32 32)" stroke-dasharray="${CIRC}" stroke-dashoffset="${esc((CIRC * (100 - score)) / 100)}"/></svg><span class="hv-ring-val" style="color:${scoreColor}">${score}</span></div>`;
1463
+ html += `<div class="hv-tile-txt" title="Score = 100 − 10/high − 3/med − 1/low"><span class="hv-lbl">Health</span><span class="hv-sub">of 100</span></div></div>`;
1464
+ for (const [sev, label] of [
1465
+ ['high', 'High'],
1466
+ ['med', 'Medium'],
1467
+ ['low', 'Low'],
1468
+ ]) {
1469
+ html += `<div class="hv-tile"><span class="hv-lbl"><span class="dot sev-dot-${esc(sev)}"></span>${label}</span><span class="hv-num hv-num-${esc(sev)}">${c[sev]}</span></div>`;
1470
+ }
1471
+ html += `<div class="hv-tile"><span class="hv-lbl">Clean</span><span class="hv-num hv-num-clean">${clean}<i>/${audited.length}</i></span><span class="hv-sub">audited files</span></div>`;
1472
+ html += renderTrend(st);
1473
+ html += '</div>';
1474
+ return html;
1475
+ }
1476
+
1477
+ // Findings per run as stacked mini-columns, oldest to newest; click switches the shown run.
1478
+ function renderTrend(st) {
1479
+ const runs = allAnalysisRuns(st);
1480
+ if (runs.length < 2) return '';
1481
+ let html = '<div class="hv-tile hv-trend-tile"><span class="hv-lbl">Trend</span><div class="hv-trend">';
1482
+ for (let i = runs.length - 1; i >= 0; i--) {
1483
+ const c = sevCounts(runs[i].run.result?.findings || []);
1484
+ const px = (n) => (n ? Math.max(3, n * 4) : 0);
1485
+ html += `<div class="hv-trun${i === analysisRunIdx ? ' cur' : ''}" onclick="_setAnalysisRun(${i})" title="${esc(analysisTimeAgo(runs[i].run.ts))}${runs[i].scope === 'user' ? ' · user' : ''} — ${c.high} high · ${c.med} med · ${c.low} low">`;
1486
+ if (c.low) html += `<i class="tl" style="height:${px(c.low)}px"></i>`;
1487
+ if (c.med) html += `<i class="tm" style="height:${px(c.med)}px"></i>`;
1488
+ if (c.high) html += `<i class="th" style="height:${px(c.high)}px"></i>`;
1489
+ if (!c.high && !c.med && !c.low) html += '<i class="tz"></i>';
1490
+ html += '</div>';
1491
+ }
1492
+ html += '</div></div>';
1493
+ return html;
1494
+ }
1495
+
1496
+ // Every audited file sized by line count, colored by its worst finding.
1497
+ function renderMemoryMap(shown) {
1498
+ const audited = runAuditedSources(shown);
1499
+ if (!audited.length) return '';
1500
+ const findings = (shown.result?.findings || []).filter((f) => !isDismissed(f));
1501
+ const perFile = new Map();
1502
+ for (const f of findings) {
1503
+ for (const name of f.files || []) {
1504
+ for (const id of findingFileIds(name, f.scope)) {
1505
+ const cur = perFile.get(id) || { high: 0, med: 0, low: 0 };
1506
+ cur[f.severity]++;
1507
+ perFile.set(id, cur);
1508
+ }
1509
+ }
1510
+ }
1511
+ const sevRank = { high: 0, med: 1, low: 2, clean: 3 };
1512
+ const cellSev = (s) => {
1513
+ const c = perFile.get(s.id);
1514
+ return c ? (c.high ? 'high' : c.med ? 'med' : 'low') : 'clean';
1515
+ };
1516
+ let html = '<div class="hv-map-label">Memory map — colored by worst finding</div><div class="hv-map">';
1517
+ for (const s of [...audited].sort(
1518
+ (a, b) => sevRank[cellSev(a)] - sevRank[cellSev(b)] || a.name.localeCompare(b.name),
1519
+ )) {
1520
+ const c = perFile.get(s.id);
1521
+ const sev = cellSev(s);
1522
+ const n = c ? c.high + c.med + c.low : 0;
1523
+ const title = `${s.name} — ${s.lines}L${n ? ` · ${n} finding${n > 1 ? 's' : ''} (worst: ${sev})` : ' · clean'}`;
1524
+ html += `<div class="hv-cell hv-${esc(sev)}${analysisFileFilter?.id === s.id ? ' active' : ''}" onclick="_setFileFilter('${escAttrJs(s.id)}','${escAttrJs(s.name)}')" title="${esc(title)} — click to filter findings">`;
1525
+ if (n) html += `<span class="hv-cell-n hv-cn-${esc(sev)}">${n}</span>`;
1526
+ html += `<span class="hv-cell-name">${esc(s.name)}</span><span class="hv-cell-meta">${s.lines}L</span></div>`;
1527
+ }
1528
+ html += '</div>';
1529
+ return html;
1530
+ }
1531
+
1532
+ // Findings for one file from the merged current state, shown inline in the file preview.
1533
+ function renderFileFindings(source) {
1534
+ const run = mergedRunView(lastAnalysisSt);
1535
+ if (!run) return '';
1536
+ const hits = (run.result?.findings || [])
1537
+ .map((f, i) => [f, i])
1538
+ .filter(([f]) => (f.files || []).some((n) => findingFileIds(n, f.scope).includes(source.id)) && !isDismissed(f));
1539
+ if (!hits.length) return '';
1540
+ let html = `<div class="preview-findings"><div class="pf-label">Claude analysis — ${hits.length} finding${hits.length > 1 ? 's' : ''} for this file</div>`;
1541
+ for (const [f, i] of hits) {
1542
+ html += `<div class="analysis-card pf-card ${esc(`sev-${f.severity}`)}">`;
1543
+ html += `<div class="analysis-card-head" onclick="this.parentElement.classList.toggle('expanded')">`;
1544
+ html += `<span class="kind-badge ${esc(`kind-${f.kind}`)}">${esc(f.kind)}</span>`;
1545
+ html += scopeBadge(f);
1546
+ html += `<span class="analysis-card-title">${esc(f.title)}</span>`;
1547
+ html += '<span class="card-actions">';
1548
+ html += `<button class="card-btn" onclick="event.stopPropagation();_copyLatestFindingPrompt(${i})" title="Copy an agent-ready instruction for this finding">${COPY_ICON}Copy prompt</button>`;
1549
+ html += `<button class="card-btn" onclick="event.stopPropagation();_dismissLatestFinding(this, ${i})" title="Drop this finding from the list">${DISMISS_ICON}Dismiss</button>`;
1550
+ html += '</span></div>';
1551
+ html += `<div class="analysis-card-detail">${esc(f.detail)}</div>`;
1552
+ html += `<div class="analysis-card-suggestion">${esc(f.suggestion)}</div>`;
1553
+ html += '</div>';
1554
+ }
1555
+ html += '</div>';
1556
+ return html;
1557
+ }
1558
+
1559
+ // Indexes into the merged current state — the same list renderFileFindings
1560
+ // draws from, unlike the health view, which can show a historical run.
1561
+ function _copyLatestFindingPrompt(i) {
1562
+ const f = mergedRunView(lastAnalysisSt)?.result?.findings?.[i];
1563
+ if (f) copyText(findingPrompt(f), 'Prompt copied — paste into any Claude Code session');
1564
+ }
1565
+
1566
+ // Dismissals share the health view's set, so a finding dropped here
1567
+ // disappears there too (and vice versa).
1568
+ function _dismissLatestFinding(btn, i) {
1569
+ const f = mergedRunView(lastAnalysisSt)?.result?.findings?.[i];
1570
+ if (f) dismissFinding(btn, f, renderPreview);
1571
+ }
1572
+
1573
+ function _toggleFixSelect(idx) {
1574
+ const f = analysisFindings[idx];
1575
+ if (!f) return;
1576
+ const key = findingKey(f);
1577
+ if (fixSelected.has(key)) fixSelected.delete(key);
1578
+ else fixSelected.add(key);
1579
+ rerenderAnalysis();
1580
+ }
1581
+
1582
+ function _copyFixPlan() {
1583
+ const order = { high: 0, med: 1, low: 2 };
1584
+ const picked = analysisFindings
1585
+ .filter((f) => fixSelected.has(findingKey(f)))
1586
+ .sort((a, b) => order[a.severity] - order[b.severity]);
1587
+ if (!picked.length) return;
1588
+ const plan = [
1589
+ `Fix plan — ${picked.length} finding${picked.length > 1 ? 's' : ''} from a Claude Code memory audit, ordered by severity. Apply each in order.`,
1590
+ '',
1591
+ ...picked.map((f, i) => `## ${i + 1}. ${findingPrompt(f)}`),
1592
+ ].join('\n\n');
1593
+ copyText(
1594
+ plan,
1595
+ `Fix plan copied — ${picked.length} finding${picked.length > 1 ? 's' : ''}, paste into any Claude Code session`,
1596
+ );
1597
+ }
1598
+
1599
+ function renderAnalysisHead(st, r, ts) {
1600
+ const meta = [];
1601
+ if (ts) meta.push(analysisTimeAgo(ts));
1602
+ if (r?.costUsd != null) meta.push(`$${r.costUsd.toFixed(2)}`);
1603
+ if (r?.durationMs != null) meta.push(`${Math.round(r.durationMs / 1000)}s`);
1604
+ if (r?.model) meta.push(r.model);
1605
+ if (r?.scopeDesc) meta.push(`scope: ${r.scopeDesc}`);
1606
+ if (st.stale && analysisRunIdx <= 0) meta.push('memory changed since');
1607
+ if (st.user?.stale && analysisRunIdx < 0) meta.push('user CLAUDE.md changed since');
1608
+ let html = '<div class="analysis-head">';
1609
+ html += '<span class="analysis-title">Claude analysis</span>';
1610
+ html += `<span class="analysis-meta">${esc(meta.join(' · '))}</span>`;
1611
+ if (r && (r.findings || []).length)
1612
+ html += '<button class="action-btn small" onclick="_copyAllFindings()">Copy all</button>';
1613
+ html += `<button class="action-btn small primary" onclick="_toggleScopePanel()">New analysis…</button>`;
1614
+ html += '</div>';
1615
+ html += renderRunsStrip(st);
1616
+ html += renderScopePanel();
1617
+ return html;
1618
+ }
1619
+
1620
+ function _setAnalysisRun(i) {
1621
+ resetAnalysisView(i); // clears the file filter, so the tree badges must be restored too
1622
+ syncTreeHealth();
1623
+ rerenderAnalysis();
1624
+ }
1625
+
1626
+ async function _deleteAnalysisRun(ev, ts, scope) {
1627
+ ev.stopPropagation();
1628
+ await fetch('/api/memory/analysis/delete-run', {
1629
+ method: 'POST',
1630
+ headers: { 'Content-Type': 'application/json' },
1631
+ body: JSON.stringify({ ts, scope }),
1632
+ });
1633
+ resetAnalysisView();
1634
+ analysisTs = null; // force refreshAnalysis to re-adopt the new latest run
1635
+ refreshAnalysis();
1636
+ }
1637
+
1638
+ // Past runs, newest first — project runs plus the shared user-scope runs.
1639
+ function renderRunsStrip(st) {
1640
+ const runs = allAnalysisRuns(st);
1641
+ if (!runs.length) return '';
1642
+ let html = '<div class="runs-strip"><span class="runs-label">runs</span>';
1643
+ if (runs.length > 1) {
1644
+ html += `<span class="run-chip${analysisRunIdx < 0 ? ' active' : ''}" onclick="_setAnalysisRun(-1)" title="Each file shows the newest run that audited it">current · ${runs.length} runs merged</span>`;
1645
+ }
1646
+ runs.forEach(({ run, scope }, i) => {
1647
+ const r = run.result || {};
1648
+ const bits = [analysisTimeAgo(run.ts)];
1649
+ if (scope === 'user') bits.push('user');
1650
+ if (r.model) bits.push(r.model);
1651
+ if (r.costUsd != null) bits.push(`$${r.costUsd.toFixed(2)}`);
1652
+ html += `<span class="run-chip${scope === 'user' ? ' run-chip-user' : ''}${i === analysisRunIdx ? ' active' : ''}" onclick="_setAnalysisRun(${i})" title="${esc(r.scopeDesc || '')}">${esc(bits.join(' · '))}<button class="run-chip-x" onclick="_deleteAnalysisRun(event, ${escAttrJs(run.ts)}, '${escAttrJs(scope)}')" title="Delete this run">×</button></span>`;
1653
+ });
1654
+ html += '</div>';
1655
+ return html;
1656
+ }
1657
+
1658
+ // The previous review stays on screen while runs are in flight — running state is a
1659
+ // banner above it, not a replacement view.
1660
+ function renderRunningBanner(st) {
1661
+ const pending = st.pending || [];
1662
+ if (!pending.length) return '';
1663
+ let html = '';
1664
+ for (const p of pending) {
1665
+ const bits = [];
1666
+ if (p.model) bits.push(p.model);
1667
+ if (p.scopeDesc) bits.push(p.scopeDesc);
1668
+ if (p.startedAt) bits.push(`started ${analysisTimeAgo(p.startedAt)}`);
1669
+ const meta = bits.length ? ` <span class="analysis-meta">${esc(bits.join(' · '))}</span>` : '';
1670
+ if (p.stalled) {
1671
+ html += `<div class="analysis-status analysis-stalled">Run appears stalled (server restarted mid-run?) — its result will surface if it ever completes.${meta}</div>`;
1672
+ } else {
1673
+ html += `<div class="analysis-status"><span class="analysis-spinner"></span>Analyzing memory with Claude Code — usually 30–90s…${meta}</div>`;
1674
+ }
1675
+ }
1676
+ return html;
1677
+ }
1678
+
1679
+ function renderAnalysis(st) {
1680
+ const shown = analysisRunIdx < 0 ? mergedRunView(st) : allAnalysisRuns(st)[analysisRunIdx]?.run;
1681
+ // The wrapper stays in the DOM even when empty so quiet polls can update just
1682
+ // the banner in place instead of rebuilding the whole review (see refreshAnalysis).
1683
+ let html = '<div class="analysis-box">';
1684
+ html += `<div id="runningBanner">${renderRunningBanner(st)}</div>`;
1685
+ html += renderAnalysisHead(st, shown?.result, shown?.ts);
1686
+ // A failed run no longer wipes history — show the error above the surviving review.
1687
+ if (st.error) {
1688
+ html += `<div class="analysis-status analysis-status-error">${esc(st.error)} <button class="action-btn small" onclick="_runAnalysis(true)">Retry</button></div>`;
1689
+ if (!shown) return `${html}</div>`;
1690
+ }
1691
+ if (!shown) {
1692
+ html += '<div class="analysis-empty">No analysis yet — pick a scope and run one.</div></div>';
1693
+ return html;
1694
+ }
1695
+ const r = shown.result;
1696
+ analysisFindings = r.findings || [];
1697
+ const byName = Object.fromEntries(stackData.map((s) => [s.name, s]));
1698
+ html += renderHealthDash(st, shown);
1699
+ if (r.summary) html += `<div class="analysis-summary">${esc(r.summary)}</div>`;
1700
+ html += renderMemoryMap(shown);
1701
+
1702
+ const live = analysisFindings.filter((f) => !isDismissed(f));
1703
+ const count = (sev) => live.filter((f) => f.severity === sev).length;
1704
+ if (analysisFindings.length) {
1705
+ html += '<div class="sev-strip">';
1706
+ for (const sev of ['all', 'high', 'med', 'low']) {
1707
+ const n = sev === 'all' ? live.length : count(sev);
1708
+ html += `<button class="sev-pill${analysisFilter === sev ? ' active' : ''}" data-sev="${esc(sev)}" onclick="_setAnalysisFilter('${escAttrJs(sev)}')">`;
1709
+ if (sev !== 'all') html += '<span class="dot"></span>';
1710
+ html += `${sev} ${n}</button>`;
1711
+ }
1712
+ if (analysisFileFilter) {
1713
+ html += `<button class="sev-pill active" onclick="_setFileFilter('${escAttrJs(analysisFileFilter.id)}','${escAttrJs(analysisFileFilter.name)}')" title="Clear the file filter">${esc(analysisFileFilter.name)} ×</button>`;
1714
+ }
1715
+ const nDismissed = dismissedCount();
1716
+ if (nDismissed) {
1717
+ html += `<span class="dismissed-note">${nDismissed} dismissed · <a onclick="_restoreDismissed()">restore</a></span>`;
1718
+ }
1719
+ if (fixSelected.size) {
1720
+ html += `<button class="card-btn fix-plan-btn" onclick="_copyFixPlan()" title="Copy the selected findings as one ordered fix prompt">${COPY_ICON}Copy fix plan (${fixSelected.size})</button>`;
1721
+ }
1722
+ html += '</div>';
1723
+ }
1724
+
1725
+ const visible = live.filter(
1726
+ (f) => (analysisFilter === 'all' || f.severity === analysisFilter) && findingMatchesFileFilter(f),
1727
+ );
1728
+ if (!analysisFindings.length) html += '<div class="analysis-empty">No issues found.</div>';
1729
+ else if (!visible.length) html += '<div class="analysis-empty">Nothing here — filtered out or dismissed.</div>';
1730
+ for (const f of visible) {
1731
+ const idx = analysisFindings.indexOf(f);
1732
+ html += `<div class="analysis-card ${esc(`sev-${f.severity}`)}">`;
1733
+ html += '<div class="analysis-card-head">';
1734
+ html += `<input type="checkbox" class="fix-check" ${fixSelected.has(findingKey(f)) ? 'checked ' : ''}onclick="_toggleFixSelect(${idx})" title="Queue for the fix plan">`;
1735
+ html += `<span class="kind-badge ${esc(`kind-${f.kind}`)}">${esc(f.kind)}</span>`;
1736
+ html += scopeBadge(f);
1737
+ html += `<span class="analysis-card-title">${esc(f.title)}</span>`;
1738
+ html += '<span class="card-actions">';
1739
+ html += `<button class="card-btn" onclick="_copyFindingPrompt(${idx})" title="Copy an agent-ready instruction for this finding">${COPY_ICON}Copy prompt</button>`;
1740
+ html += `<button class="card-btn" onclick="_dismissFinding(this, ${idx})" title="Drop this finding from the list">${DISMISS_ICON}Dismiss</button>`;
1741
+ html += '</span></div>';
1742
+ const files = f.files || [];
1743
+ html += '<div class="analysis-card-files">';
1744
+ if (!files.length) html += '<span class="no-files">no file — new memory suggested</span>';
1745
+ for (const fn of files) {
1746
+ // Same name-or-path resolution as the map and tree badges — a chip must
1747
+ // stay clickable when the agent reported a path instead of the name.
1748
+ const ids = findingFileIds(fn, f.scope);
1749
+ const child = stackData.find((s) => ids.includes(s.id)) || byName[fn];
1750
+ const scopeTag = fileChipScope(child);
1751
+ if (child) {
1752
+ html += `<a class="file-chip" href="#" onclick="selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${FILE_ICON}${esc(fn)}<span class="fscope fscope-${esc(scopeTag)}">${esc(scopeTag)}</span></a>`;
1753
+ } else {
1754
+ html += `<span class="file-chip">${FILE_ICON}${esc(fn)}</span>`;
1755
+ }
1756
+ }
1757
+ html += '</div>';
1758
+ html += `<div class="analysis-card-detail">${esc(f.detail)}</div>`;
1759
+ html += `<div class="analysis-card-suggestion">${esc(f.suggestion)}</div>`;
1760
+ html += '</div>';
1761
+ }
1762
+ html += '</div>';
1763
+ return html;
1764
+ }
1765
+
1766
+ // #endregion ANALYZER
1767
+
851
1768
  // #region RENDER_BUDGET
852
1769
 
1770
+ let footprintHighlight = false;
1771
+ let footprintScope = null; // null = whole footprint; a scope name = just that segment's files
1772
+ let footprintIds = new Set(); // source ids the server counted into the footprint
1773
+
1774
+ // Clicking the top bar highlights all footprint files; clicking a segment narrows
1775
+ // the highlight to that segment's scope (the skill segment = skill descriptions).
1776
+ function _toggleFootprint(scope = null, ev) {
1777
+ if (ev) ev.stopPropagation();
1778
+ if (footprintHighlight && footprintScope === scope) {
1779
+ footprintHighlight = false;
1780
+ footprintScope = null;
1781
+ } else {
1782
+ footprintHighlight = true;
1783
+ footprintScope = scope;
1784
+ }
1785
+ document.body.classList.toggle('fp-mode', footprintHighlight);
1786
+ applySegmentState();
1787
+ renderTree();
1788
+ }
1789
+
1790
+ function footprintHit(item) {
1791
+ if (footprintScope === 'skill') return item.scope === 'skill' && !item.parentId;
1792
+ if (footprintScope) return footprintIds.has(item.id) && item.scope === footprintScope;
1793
+ return footprintIds.has(item.id);
1794
+ }
1795
+
1796
+ function applySegmentState() {
1797
+ const scoped = footprintHighlight && footprintScope !== null;
1798
+ for (const el of document.querySelectorAll('.budget-segment')) {
1799
+ el.classList.toggle('seg-dim', scoped && el.dataset.scope !== footprintScope);
1800
+ }
1801
+ }
1802
+
853
1803
  function renderBudget() {
854
1804
  if (!summaryData) return;
1805
+ footprintIds = new Set(summaryData.ids || []);
855
1806
 
856
1807
  // Summary stat cards
857
1808
  document.getElementById('statFiles').textContent = summaryData.totalFiles;
858
- document.getElementById('statLines').textContent = summaryData.totalLines.toLocaleString();
1809
+ document.getElementById('statChars').textContent = summaryData.totalChars.toLocaleString();
859
1810
  document.getElementById('statBytes').textContent = formatBytes(summaryData.totalBytes);
860
1811
  document.getElementById('statAlways').textContent = summaryData.alwaysLoaded;
861
1812
 
862
1813
  // Budget text
863
1814
  document.getElementById('budgetText').textContent =
864
- `${summaryData.totalLines.toLocaleString()} lines / ${formatBytes(summaryData.totalBytes)}`;
1815
+ `${summaryData.totalChars.toLocaleString()} chars / ${formatBytes(summaryData.totalBytes)}`;
865
1816
 
866
- // Budget segments — proportional by lines per scope
1817
+ // Budget segments — proportional by chars per scope; the server's summary owns
1818
+ // the footprint filter, the client only renders its per-scope totals.
867
1819
  const segContainer = document.getElementById('budgetSegments');
868
- if (!stackData.length) {
869
- segContainer.innerHTML = '';
870
- return;
871
- }
872
-
873
- const scopeTotals = {};
874
- for (const s of stackData) {
875
- scopeTotals[s.scope] = (scopeTotals[s.scope] || 0) + (s.lines || 0);
876
- }
877
- const totalLines = summaryData.totalLines || 1;
1820
+ const scopeTotals = summaryData.scopeChars || {};
1821
+ const skillDescChars = summaryData.skillDesc?.chars || 0;
1822
+ const totalChars = summaryData.totalChars || 1;
878
1823
  let html = '';
879
1824
  for (const scope of SCOPE_ORDER) {
880
- const lines = scopeTotals[scope];
881
- if (!lines) continue;
882
- const pct = (lines / totalLines) * 100;
883
- html += `<div class="budget-segment" style="width:${pct}%;background:var(--scope-${scope})" title="${SCOPE_LABELS[scope] || scope}: ${lines} lines (${pct.toFixed(1)}%)"></div>`;
1825
+ const chars = scopeTotals[scope];
1826
+ if (!chars) continue;
1827
+ const pct = (chars / totalChars) * 100;
1828
+ html += `<div class="budget-segment" data-scope="${esc(scope)}" onclick="_toggleFootprint('${escAttrJs(scope)}', event)" style="width:${pct}%;background:var(--scope-${scope})" title="${SCOPE_LABELS[scope] || scope}: ${chars.toLocaleString()} chars (${pct.toFixed(1)}%) — click to highlight"></div>`;
1829
+ }
1830
+ if (skillDescChars) {
1831
+ const pct = (skillDescChars / totalChars) * 100;
1832
+ html += `<div class="budget-segment" data-scope="skill" onclick="_toggleFootprint('skill', event)" style="width:${pct}%;background:var(--scope-skill)" title="Skill descriptions (${esc(summaryData.skillDesc.count)} enabled): ${skillDescChars.toLocaleString()} chars (${pct.toFixed(1)}%) — click to highlight"></div>`;
884
1833
  }
885
1834
  segContainer.innerHTML = html;
1835
+ applySegmentState();
886
1836
  }
887
1837
 
888
1838
  // #endregion RENDER_BUDGET
@@ -893,18 +1843,17 @@ async function loadData() {
893
1843
  try {
894
1844
  [stackData, summaryData] = await Promise.all([fetchJSON('/api/stack'), fetchJSON('/api/summary')]);
895
1845
  invalidateTreeIndex();
1846
+ renderBudget(); // refreshes footprintIds, which renderTree's highlight reads
896
1847
  renderTree();
897
- renderBudget();
898
- if (!selectedFileId) {
1848
+ refreshAnalysis(); // health dots on the tree come from the latest run
1849
+ if (!selectedFileId && !healthViewOpen) {
899
1850
  const proj = stackData.find((s) => s.scope === 'project' && s.name === 'CLAUDE.md');
900
1851
  const user = stackData.find((s) => s.scope === 'user' && s.name === 'CLAUDE.md');
901
1852
  const auto = proj || user;
902
1853
  if (auto) selectedFileId = auto.id;
903
1854
  }
904
- if (selectedFileId) {
905
- renderTree();
906
- renderPreview();
907
- }
1855
+ if (selectedFileId) renderTree();
1856
+ renderPreview();
908
1857
  } catch (err) {
909
1858
  showToast(`Failed to load: ${err.message}`, 'error');
910
1859
  }
@@ -947,6 +1896,11 @@ document.addEventListener('keydown', (e) => {
947
1896
  }
948
1897
  return;
949
1898
  }
1899
+ if (e.key === 'Escape' && healthViewOpen) {
1900
+ e.preventDefault();
1901
+ openHealthView();
1902
+ return;
1903
+ }
950
1904
  if (e.key === 't') toggleTheme();
951
1905
  if (e.key === 'r') refreshData();
952
1906
  if (e.key === '?') {