claude-code-memory-explorer 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-memory-explorer",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Memory explorer dashboard for Claude Code",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -58,7 +58,7 @@ function highlightSource(text, fileName) {
58
58
  if (fm) {
59
59
  const fmHtml = hljs.highlight(fm[1], { language: 'yaml' }).value;
60
60
  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}`;
61
+ return `<span class="hl-fm-block"><span class="hl-frontmatter">---</span>\n${fmHtml}\n<span class="hl-frontmatter">---</span></span>\n${bodyHtml}`;
62
62
  }
63
63
  }
64
64
  return hljs.highlight(text, { language: lang }).value;
@@ -304,6 +304,12 @@ function toggleHelpModal() {
304
304
  document.getElementById('helpModal').classList.toggle('open');
305
305
  }
306
306
 
307
+ // biome-ignore lint/correctness/noUnusedVariables: called from topbar markup
308
+ function openAnalysisModal() {
309
+ document.getElementById('analysisModal').classList.add('open');
310
+ refreshAnalysis();
311
+ }
312
+
307
313
  function bindModalKeys(inputId, modalId, submitFn) {
308
314
  document.getElementById(inputId).addEventListener('keydown', (e) => {
309
315
  if (e.key === 'Enter') {
@@ -321,12 +327,13 @@ function bindModalKeys(inputId, modalId, submitFn) {
321
327
 
322
328
  // #region RENDER_TREE
323
329
 
324
- const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'agent-memory'];
330
+ const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'skill', 'agent-memory'];
325
331
  const SCOPE_LABELS = {
326
332
  policy: 'Managed Policy',
327
333
  user: 'User',
328
334
  project: 'Project',
329
335
  rule: 'Rules',
336
+ skill: 'Skills',
330
337
  memory: 'Auto Memory',
331
338
  'agent-memory': 'Agent Memory',
332
339
  };
@@ -667,14 +674,30 @@ async function renderPreview() {
667
674
  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
675
  html += '</div></div>';
669
676
 
677
+ // Description gets its own full-width line; the rest stay as key:value chips
678
+ const fmDesc =
679
+ fileData.frontmatter && typeof fileData.frontmatter.description === 'string'
680
+ ? fileData.frontmatter.description.replace(/\s+/g, ' ')
681
+ : '';
682
+ if (fmDesc)
683
+ html += `<div class="preview-desc" title="Click to expand" onclick="this.classList.toggle('expanded')">${esc(fmDesc)}</div>`;
684
+
670
685
  // Badges row
671
686
  html += '<div class="preview-badges">';
672
687
  html += `<span class="load-badge load-${source.load}">${esc(source.load)}</span>`;
673
688
  html += `<span class="tag-badge">${source.lines}L / ${formatBytes(source.bytes)}</span>`;
674
689
  if (fileData.frontmatter) {
675
690
  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>`;
691
+ if (k === 'description' && fmDesc) continue;
692
+ let val;
693
+ if (Array.isArray(v)) val = v.join(', ');
694
+ else if (v && typeof v === 'object')
695
+ val = Object.entries(v)
696
+ .map(([ck, cv]) => `${ck}=${cv}`)
697
+ .join(', ');
698
+ else val = String(v).replace(/\s+/g, ' ');
699
+ const shown = val.length > 72 ? `${val.slice(0, 69)}…` : val;
700
+ html += `<span class="tag-badge" title="${esc(`${k}: ${val}`)}"><span class="tag-k">${esc(k)}</span>${esc(shown)}</span>`;
678
701
  }
679
702
  }
680
703
  html += '</div>';
@@ -696,14 +719,15 @@ async function renderPreview() {
696
719
  html += '</div>';
697
720
  }
698
721
 
722
+ // File path lives inside the header as a muted line instead of its own band
723
+ html += `<div class="preview-filepath" title="${esc(source.path)}">${esc(source.path)}</div>`;
699
724
  html += '</div>';
700
725
 
701
- // File path
702
- html += `<div class="preview-filepath">${esc(source.path)}</div>`;
703
-
704
726
  const hl = (text) => {
705
727
  const { text: processed, placeholders } = linkifyImports(text, source.id);
706
- return restorePlaceholders(highlightSource(processed, source.name), placeholders);
728
+ // Skill names aren't filenames (e.g. "aspire") — derive the language from the real file
729
+ const fileName = source.path ? source.path.split(/[\\/]/).pop() : source.name;
730
+ return restorePlaceholders(highlightSource(processed, fileName), placeholders);
707
731
  };
708
732
 
709
733
  if (showMemoryTable) {
@@ -848,6 +872,433 @@ async function openInEditor(filePath) {
848
872
 
849
873
  // #endregion RENDER_PREVIEW
850
874
 
875
+ // #region ANALYZER
876
+
877
+ let analysisPollTimer = null;
878
+ let lastAnalysisSt = null;
879
+ let analysisFindings = []; // findings of the rendered result, addressed by index from handlers
880
+ let analysisFilter = 'all';
881
+ let analysisDismissed = new Set(); // session-only; keys are `${kind}|${title}`
882
+ let analysisTs = null; // reset dismissals + filter when a new result lands
883
+ let analysisRunIdx = 0; // which run from the per-project history is shown (0 = latest)
884
+
885
+ // Session-only identity of a finding, used by dismiss/copy/render alike
886
+ function findingKey(f) {
887
+ return `${f.kind}|${f.title}`;
888
+ }
889
+
890
+ function resetAnalysisView(runIdx = 0) {
891
+ analysisRunIdx = runIdx;
892
+ analysisFilter = 'all';
893
+ analysisDismissed = new Set();
894
+ }
895
+ let scopePanelOpen = false;
896
+ let scopeChecked = null; // Set of source ids; seeded with the default scope on first open
897
+ const scopeCollapsed = new Set();
898
+ const ANALYSIS_MODELS = ['sonnet', 'opus', 'fable'];
899
+ let analysisModel = ANALYSIS_MODELS.includes(localStorage.getItem('analysisModel'))
900
+ ? localStorage.getItem('analysisModel')
901
+ : 'sonnet';
902
+
903
+ function _setAnalysisModel(m) {
904
+ if (!ANALYSIS_MODELS.includes(m)) return;
905
+ analysisModel = m;
906
+ try {
907
+ localStorage.setItem('analysisModel', m);
908
+ } catch {}
909
+ rerenderAnalysis();
910
+ }
911
+
912
+ async function _runAnalysis(force, ids) {
913
+ try {
914
+ const body = { force: !!force, model: analysisModel };
915
+ if (Array.isArray(ids) && ids.length) body.ids = ids;
916
+ const res = await fetch('/api/memory/analyze', {
917
+ method: 'POST',
918
+ headers: { 'Content-Type': 'application/json' },
919
+ body: JSON.stringify(body),
920
+ });
921
+ if (!res.ok && res.status !== 409) {
922
+ const msg = await res
923
+ .json()
924
+ .then((e) => e.error)
925
+ .catch(() => `Analyze failed (${res.status})`);
926
+ showToast(msg, 'error');
927
+ return;
928
+ }
929
+ const data = await res.json().catch(() => ({}));
930
+ if (data.cached) showToast('Memory unchanged — showing cached analysis. Use Re-run to force.', 'info');
931
+ } catch (err) {
932
+ showToast(err.message, 'error');
933
+ return;
934
+ }
935
+ refreshAnalysis();
936
+ }
937
+
938
+ async function refreshAnalysis() {
939
+ const el = document.getElementById('analysisSection');
940
+ if (!el) return;
941
+ let st;
942
+ try {
943
+ st = await fetchJSON('/api/memory/analysis');
944
+ } catch {
945
+ return;
946
+ }
947
+ if (st.ts !== analysisTs) {
948
+ analysisTs = st.ts;
949
+ resetAnalysisView();
950
+ }
951
+ lastAnalysisSt = st;
952
+ el.innerHTML = renderAnalysis(st);
953
+ applyScopeIndeterminate(el);
954
+ clearTimeout(analysisPollTimer);
955
+ if (st.running) analysisPollTimer = setTimeout(refreshAnalysis, 2500);
956
+ }
957
+
958
+ // Re-render from the cached state — used by scope/filter/dismiss handlers so a
959
+ // checkbox click never triggers a server round-trip.
960
+ function rerenderAnalysis() {
961
+ const el = document.getElementById('analysisSection');
962
+ if (!el || !lastAnalysisSt) return;
963
+ el.innerHTML = renderAnalysis(lastAnalysisSt);
964
+ applyScopeIndeterminate(el);
965
+ }
966
+
967
+ // `indeterminate` is a DOM property, not an attribute — set it after every innerHTML render.
968
+ function applyScopeIndeterminate(el) {
969
+ for (const box of el.querySelectorAll('input[data-ind]')) box.indeterminate = true;
970
+ }
971
+
972
+ // #region ANALYZER_SCOPE
973
+
974
+ const CLAUDE_MD_SCOPES = new Set(['project', 'local']);
975
+
976
+ function analysisScopeGroups() {
977
+ return [
978
+ {
979
+ key: 'memory',
980
+ label: 'Auto memory',
981
+ cls: 't-memory',
982
+ tag: 'memory',
983
+ items: stackData.filter((s) => s.scope === 'memory'),
984
+ },
985
+ {
986
+ key: 'skill',
987
+ label: 'Skills',
988
+ cls: 't-skill',
989
+ tag: 'skill',
990
+ items: stackData.filter((s) => s.scope === 'skill' && !s.parentId),
991
+ },
992
+ {
993
+ key: 'claudemd',
994
+ label: 'CLAUDE.md',
995
+ cls: 't-claudemd',
996
+ tag: 'md',
997
+ items: stackData.filter((s) => CLAUDE_MD_SCOPES.has(s.scope) && !s.parentId),
998
+ },
999
+ {
1000
+ key: 'agent',
1001
+ label: 'Agent memory',
1002
+ cls: 't-agent',
1003
+ tag: 'agent',
1004
+ items: stackData.filter((s) => s.scope === 'agent-memory'),
1005
+ },
1006
+ ].filter((g) => g.items.length);
1007
+ }
1008
+
1009
+ function _toggleScopePanel() {
1010
+ scopePanelOpen = !scopePanelOpen;
1011
+ if (scopePanelOpen && !scopeChecked) {
1012
+ // default scope = auto memory only (skills, agent memory, CLAUDE.md are opt-in)
1013
+ scopeChecked = new Set(stackData.filter((s) => s.scope === 'memory').map((s) => s.id));
1014
+ }
1015
+ rerenderAnalysis();
1016
+ }
1017
+
1018
+ function _toggleScopeGroup(key) {
1019
+ const g = analysisScopeGroups().find((x) => x.key === key);
1020
+ if (!g) return;
1021
+ const allOn = g.items.every((s) => scopeChecked.has(s.id));
1022
+ for (const s of g.items) {
1023
+ if (allOn) scopeChecked.delete(s.id);
1024
+ else scopeChecked.add(s.id);
1025
+ }
1026
+ rerenderAnalysis();
1027
+ }
1028
+
1029
+ function _toggleScopeLeaf(id) {
1030
+ if (scopeChecked.has(id)) scopeChecked.delete(id);
1031
+ else scopeChecked.add(id);
1032
+ rerenderAnalysis();
1033
+ }
1034
+
1035
+ function _toggleScopeCollapse(key, e) {
1036
+ e.stopPropagation();
1037
+ if (scopeCollapsed.has(key)) scopeCollapsed.delete(key);
1038
+ else scopeCollapsed.add(key);
1039
+ rerenderAnalysis();
1040
+ }
1041
+
1042
+ function _runScopedAnalysis() {
1043
+ if (!scopeChecked?.size) return;
1044
+ scopePanelOpen = false;
1045
+ _runAnalysis(true, [...scopeChecked]);
1046
+ }
1047
+
1048
+ function renderScopePanel() {
1049
+ if (!scopePanelOpen || !scopeChecked) return '';
1050
+ let html = '<div class="scope-panel open">';
1051
+ html += '<div class="scope-panel-title">Select what to analyze</div>';
1052
+ html += '<div class="scope-tree">';
1053
+ for (const g of analysisScopeGroups()) {
1054
+ const on = g.items.filter((s) => scopeChecked.has(s.id)).length;
1055
+ const collapsed = scopeCollapsed.has(g.key);
1056
+ html += `<div class="scope-group${collapsed ? ' collapsed' : ''}">`;
1057
+ html += `<div class="scope-row" onclick="_toggleScopeGroup('${escAttrJs(g.key)}')">`;
1058
+ html += `<span class="scope-caret" onclick="_toggleScopeCollapse('${escAttrJs(g.key)}', event)">${collapsed ? '▸' : '▾'}</span>`;
1059
+ html += `<input type="checkbox" ${on === g.items.length ? 'checked' : ''} ${on > 0 && on < g.items.length ? 'data-ind="1"' : ''} onclick="event.preventDefault()">`;
1060
+ html += `<span class="scope-tag ${esc(g.cls)}">${g.tag}</span>`;
1061
+ html += `<span>${esc(g.label)}</span>`;
1062
+ html += `<span class="scope-meta">${on}/${g.items.length}</span>`;
1063
+ html += '</div>';
1064
+ html += '<div class="scope-children">';
1065
+ for (const s of g.items) {
1066
+ html += `<div class="scope-row" onclick="_toggleScopeLeaf('${escAttrJs(s.id)}')">`;
1067
+ html += `<input type="checkbox" ${scopeChecked.has(s.id) ? 'checked' : ''} onclick="event.preventDefault()">`;
1068
+ html += `<span>${esc(s.name)}</span>`;
1069
+ html += `<span class="scope-meta">${s.lines}L</span>`;
1070
+ html += '</div>';
1071
+ }
1072
+ html += '</div></div>';
1073
+ }
1074
+ html += '</div>';
1075
+ const n = scopeChecked.size;
1076
+ html += '<div class="scope-footer">';
1077
+ html += `<span class="scope-hint">${n} file${n === 1 ? '' : 's'} selected</span>`;
1078
+ html += '<div class="scope-model" title="Model for this analysis">';
1079
+ for (const m of ANALYSIS_MODELS)
1080
+ html += `<button class="scope-model-opt${m === analysisModel ? ' active' : ''}" onclick="_setAnalysisModel('${escAttrJs(m)}')">${m}</button>`;
1081
+ html += '</div>';
1082
+ html += '<button class="action-btn small" onclick="_toggleScopePanel()">Cancel</button>';
1083
+ html += `<button class="action-btn small primary" ${n ? '' : 'disabled'} onclick="_runScopedAnalysis()">Run analysis (${n})</button>`;
1084
+ html += '</div></div>';
1085
+ return html;
1086
+ }
1087
+
1088
+ // #endregion ANALYZER_SCOPE
1089
+
1090
+ // #region ANALYZER_ACTIONS
1091
+
1092
+ function memoryDirPath() {
1093
+ const idx = stackData.find((s) => s.scope === 'memory' && s.name === 'MEMORY.md');
1094
+ return idx ? idx.path.replace(/[\\/][^\\/]+$/, '') : null;
1095
+ }
1096
+
1097
+ function findingPrompt(f) {
1098
+ const dir = memoryDirPath();
1099
+ const lines = [
1100
+ dir
1101
+ ? `In the Claude Code auto-memory at ${dir}, apply this fix:`
1102
+ : "In this project's Claude Code auto-memory, apply this fix:",
1103
+ '',
1104
+ `Finding (${f.kind}, ${f.severity}): ${f.title}`,
1105
+ f.detail,
1106
+ '',
1107
+ `Action: ${f.suggestion}`,
1108
+ ];
1109
+ if ((f.files || []).length) lines.push(`Files: ${f.files.join(', ')}`);
1110
+ lines.push('', 'Update MEMORY.md index lines if files are added, renamed, or removed.');
1111
+ return lines.join('\n');
1112
+ }
1113
+
1114
+ function copyText(text, okMsg) {
1115
+ navigator.clipboard
1116
+ .writeText(text)
1117
+ .then(() => showToast(okMsg, 'info'))
1118
+ .catch(() => showToast('Clipboard unavailable', 'error'));
1119
+ }
1120
+
1121
+ function _copyFindingPrompt(idx) {
1122
+ const f = analysisFindings[idx];
1123
+ if (f) copyText(findingPrompt(f), 'Prompt copied — paste into any Claude Code session');
1124
+ }
1125
+
1126
+ function _copyAllFindings() {
1127
+ const live = analysisFindings.filter((f) => !analysisDismissed.has(findingKey(f)));
1128
+ if (!live.length) return;
1129
+ copyText(live.map(findingPrompt).join('\n\n---\n\n'), `${live.length} prompts copied`);
1130
+ }
1131
+
1132
+ function _dismissFinding(btn, idx) {
1133
+ const f = analysisFindings[idx];
1134
+ if (!f) return;
1135
+ const card = btn.closest('.analysis-card');
1136
+ card.style.maxHeight = `${card.offsetHeight}px`;
1137
+ requestAnimationFrame(() => card.classList.add('leaving'));
1138
+ setTimeout(() => {
1139
+ analysisDismissed.add(findingKey(f));
1140
+ rerenderAnalysis();
1141
+ }, 280);
1142
+ }
1143
+
1144
+ function _restoreDismissed() {
1145
+ analysisDismissed = new Set();
1146
+ rerenderAnalysis();
1147
+ }
1148
+
1149
+ function _setAnalysisFilter(sev) {
1150
+ analysisFilter = sev;
1151
+ rerenderAnalysis();
1152
+ }
1153
+
1154
+ // #endregion ANALYZER_ACTIONS
1155
+
1156
+ function analysisTimeAgo(ts) {
1157
+ const min = Math.round((Date.now() - ts) / 60000);
1158
+ if (min < 1) return 'just now';
1159
+ if (min < 60) return `${min}m ago`;
1160
+ const h = Math.round(min / 60);
1161
+ return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
1162
+ }
1163
+
1164
+ const COPY_ICON =
1165
+ '<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>';
1166
+ const DISMISS_ICON =
1167
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>';
1168
+ const FILE_ICON =
1169
+ '<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>';
1170
+
1171
+ function fileChipScope(source) {
1172
+ if (!source) return '';
1173
+ return source.scope === 'agent-memory' ? 'agent' : source.scope;
1174
+ }
1175
+
1176
+ function renderAnalysisHead(st, r, ts) {
1177
+ const meta = [];
1178
+ if (ts) meta.push(analysisTimeAgo(ts));
1179
+ if (r?.costUsd != null) meta.push(`$${r.costUsd.toFixed(2)}`);
1180
+ if (r?.durationMs != null) meta.push(`${Math.round(r.durationMs / 1000)}s`);
1181
+ if (r?.model) meta.push(r.model);
1182
+ if (r?.scopeDesc) meta.push(`scope: ${r.scopeDesc}`);
1183
+ if (st.stale && analysisRunIdx === 0) meta.push('memory changed since');
1184
+ let html = '<div class="analysis-head">';
1185
+ html += '<span class="analysis-title">Claude analysis</span>';
1186
+ html += `<span class="analysis-meta">${esc(meta.join(' · '))}</span>`;
1187
+ if (r && (r.findings || []).length)
1188
+ html += '<button class="action-btn small" onclick="_copyAllFindings()">Copy all</button>';
1189
+ html += `<button class="action-btn small primary" onclick="_toggleScopePanel()">New analysis…</button>`;
1190
+ html += '</div>';
1191
+ html += renderRunsStrip(st);
1192
+ html += renderScopePanel();
1193
+ return html;
1194
+ }
1195
+
1196
+ function _setAnalysisRun(i) {
1197
+ resetAnalysisView(i);
1198
+ rerenderAnalysis();
1199
+ }
1200
+
1201
+ async function _deleteAnalysisRun(ev, ts) {
1202
+ ev.stopPropagation();
1203
+ await fetch('/api/memory/analysis/delete-run', {
1204
+ method: 'POST',
1205
+ headers: { 'Content-Type': 'application/json' },
1206
+ body: JSON.stringify({ ts }),
1207
+ });
1208
+ resetAnalysisView();
1209
+ analysisTs = null; // force refreshAnalysis to re-adopt the new latest run
1210
+ refreshAnalysis();
1211
+ }
1212
+
1213
+ // Past runs for this project (newest first).
1214
+ function renderRunsStrip(st) {
1215
+ const runs = st.runs || [];
1216
+ if (!runs.length) return '';
1217
+ let html = '<div class="runs-strip"><span class="runs-label">runs</span>';
1218
+ runs.forEach((run, i) => {
1219
+ const r = run.result || {};
1220
+ const bits = [analysisTimeAgo(run.ts)];
1221
+ if (r.model) bits.push(r.model);
1222
+ if (r.costUsd != null) bits.push(`$${r.costUsd.toFixed(2)}`);
1223
+ html += `<span class="run-chip${i === analysisRunIdx ? ' active' : ''}" onclick="_setAnalysisRun(${i})" title="${esc(r.scopeDesc || '')}">${esc(bits.join(' · '))}<button class="run-chip-x" onclick="_deleteAnalysisRun(event, ${escAttrJs(run.ts)})" title="Delete this run">×</button></span>`;
1224
+ });
1225
+ html += '</div>';
1226
+ return html;
1227
+ }
1228
+
1229
+ function renderAnalysis(st) {
1230
+ if (st.running) {
1231
+ return '<div class="analysis-box"><div class="analysis-status"><span class="analysis-spinner"></span>Analyzing memory with Claude Code — usually 30–90s…</div></div>';
1232
+ }
1233
+ const runs = st.runs || [];
1234
+ const shown = runs[analysisRunIdx];
1235
+ let html = '<div class="analysis-box">';
1236
+ html += renderAnalysisHead(st, shown?.result, shown?.ts);
1237
+ if (st.error) {
1238
+ html += `<div class="analysis-status analysis-status-error">${esc(st.error)} <button class="action-btn small" onclick="_runAnalysis(true)">Retry</button></div></div>`;
1239
+ return html;
1240
+ }
1241
+ if (!shown) {
1242
+ html += '<div class="analysis-empty">No analysis yet — pick a scope and run one.</div></div>';
1243
+ return html;
1244
+ }
1245
+ const r = shown.result;
1246
+ analysisFindings = r.findings || [];
1247
+ const byName = Object.fromEntries(stackData.map((s) => [s.name, s]));
1248
+ if (r.summary) html += `<div class="analysis-summary">${esc(r.summary)}</div>`;
1249
+
1250
+ const live = analysisFindings.filter((f) => !analysisDismissed.has(findingKey(f)));
1251
+ const count = (sev) => live.filter((f) => f.severity === sev).length;
1252
+ if (analysisFindings.length) {
1253
+ html += '<div class="sev-strip">';
1254
+ for (const sev of ['all', 'high', 'med', 'low']) {
1255
+ const n = sev === 'all' ? live.length : count(sev);
1256
+ html += `<button class="sev-pill${analysisFilter === sev ? ' active' : ''}" data-sev="${esc(sev)}" onclick="_setAnalysisFilter('${escAttrJs(sev)}')">`;
1257
+ if (sev !== 'all') html += '<span class="dot"></span>';
1258
+ html += `${sev} ${n}</button>`;
1259
+ }
1260
+ if (analysisDismissed.size) {
1261
+ html += `<span class="dismissed-note">${analysisDismissed.size} dismissed · <a onclick="_restoreDismissed()">restore</a></span>`;
1262
+ }
1263
+ html += '</div>';
1264
+ }
1265
+
1266
+ const visible = live.filter((f) => analysisFilter === 'all' || f.severity === analysisFilter);
1267
+ if (!analysisFindings.length) html += '<div class="analysis-empty">No issues found.</div>';
1268
+ else if (!visible.length) html += '<div class="analysis-empty">Nothing here — filtered out or dismissed.</div>';
1269
+ for (const f of visible) {
1270
+ const idx = analysisFindings.indexOf(f);
1271
+ html += `<div class="analysis-card ${esc(`sev-${f.severity}`)}">`;
1272
+ html += '<div class="analysis-card-head">';
1273
+ html += `<span class="kind-badge ${esc(`kind-${f.kind}`)}">${esc(f.kind)}</span>`;
1274
+ html += `<span class="analysis-card-title">${esc(f.title)}</span>`;
1275
+ html += '<span class="card-actions">';
1276
+ html += `<button class="card-btn" onclick="_copyFindingPrompt(${idx})" title="Copy an agent-ready instruction for this finding">${COPY_ICON}Copy prompt</button>`;
1277
+ html += `<button class="card-btn" onclick="_dismissFinding(this, ${idx})" title="Drop this finding from the list">${DISMISS_ICON}Dismiss</button>`;
1278
+ html += '</span></div>';
1279
+ const files = f.files || [];
1280
+ html += '<div class="analysis-card-files">';
1281
+ if (!files.length) html += '<span class="no-files">no file — new memory suggested</span>';
1282
+ for (const fn of files) {
1283
+ const child = byName[fn];
1284
+ const scopeTag = fileChipScope(child);
1285
+ if (child) {
1286
+ html += `<a class="file-chip" href="#" onclick="closeModal('analysisModal');selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${FILE_ICON}${esc(fn)}<span class="fscope fscope-${esc(scopeTag)}">${esc(scopeTag)}</span></a>`;
1287
+ } else {
1288
+ html += `<span class="file-chip">${FILE_ICON}${esc(fn)}</span>`;
1289
+ }
1290
+ }
1291
+ html += '</div>';
1292
+ html += `<div class="analysis-card-detail">${esc(f.detail)}</div>`;
1293
+ html += `<div class="analysis-card-suggestion">${esc(f.suggestion)}</div>`;
1294
+ html += '</div>';
1295
+ }
1296
+ html += '</div>';
1297
+ return html;
1298
+ }
1299
+
1300
+ // #endregion ANALYZER
1301
+
851
1302
  // #region RENDER_BUDGET
852
1303
 
853
1304
  function renderBudget() {
@@ -855,32 +1306,30 @@ function renderBudget() {
855
1306
 
856
1307
  // Summary stat cards
857
1308
  document.getElementById('statFiles').textContent = summaryData.totalFiles;
858
- document.getElementById('statLines').textContent = summaryData.totalLines.toLocaleString();
1309
+ document.getElementById('statChars').textContent = summaryData.totalChars.toLocaleString();
859
1310
  document.getElementById('statBytes').textContent = formatBytes(summaryData.totalBytes);
860
1311
  document.getElementById('statAlways').textContent = summaryData.alwaysLoaded;
861
1312
 
862
1313
  // Budget text
863
1314
  document.getElementById('budgetText').textContent =
864
- `${summaryData.totalLines.toLocaleString()} lines / ${formatBytes(summaryData.totalBytes)}`;
1315
+ `${summaryData.totalChars.toLocaleString()} chars / ${formatBytes(summaryData.totalBytes)}`;
865
1316
 
866
- // Budget segments — proportional by lines per scope
1317
+ // Budget segments — proportional by chars per scope; the server's summary owns
1318
+ // the footprint filter, the client only renders its per-scope totals.
867
1319
  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;
1320
+ const scopeTotals = summaryData.scopeChars || {};
1321
+ const skillDescChars = summaryData.skillDesc?.chars || 0;
1322
+ const totalChars = summaryData.totalChars || 1;
878
1323
  let html = '';
879
1324
  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>`;
1325
+ const chars = scopeTotals[scope];
1326
+ if (!chars) continue;
1327
+ const pct = (chars / totalChars) * 100;
1328
+ html += `<div class="budget-segment" style="width:${pct}%;background:var(--scope-${scope})" title="${SCOPE_LABELS[scope] || scope}: ${chars.toLocaleString()} chars (${pct.toFixed(1)}%)"></div>`;
1329
+ }
1330
+ if (skillDescChars) {
1331
+ const pct = (skillDescChars / totalChars) * 100;
1332
+ html += `<div class="budget-segment" style="width:${pct}%;background:var(--scope-skill)" title="Skill descriptions (${esc(summaryData.skillDesc.count)} enabled): ${skillDescChars.toLocaleString()} chars (${pct.toFixed(1)}%)"></div>`;
884
1333
  }
885
1334
  segContainer.innerHTML = html;
886
1335
  }
package/public/index.html CHANGED
@@ -32,6 +32,10 @@
32
32
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
33
33
  <span id="projectName">Loading...</span>
34
34
  </div>
35
+ <button class="topbar-btn" id="analyzeBtn" title="Analyze memory with Claude" onclick="openAnalysisModal()">
36
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.7L19.6 10l-5.7 1.9L12 17.6l-1.9-5.7L4.4 10l5.7-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/></svg>
37
+ <span>Analyze</span>
38
+ </button>
35
39
  <button class="topbar-btn" id="refreshBtn" title="Refresh (r)" onclick="refreshData()">
36
40
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
37
41
  </button>
@@ -44,6 +48,7 @@
44
48
  </button>
45
49
  <button class="topbar-btn" id="helpBtn" title="Keyboard shortcuts (?)" onclick="toggleHelpModal()">
46
50
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="17" r="0.5" fill="currentColor"/></svg>
51
+ <kbd>?</kbd>
47
52
  </button>
48
53
  <a class="topbar-btn" href="https://github.com/NikiforovAll/claude-code-memory" target="_blank" rel="noopener" title="GitHub">
49
54
  <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
@@ -54,7 +59,7 @@
54
59
  <div class="budget-bar" id="budgetBar">
55
60
  <div class="budget-label">
56
61
  <span>Memory sources</span>
57
- <span class="mono" id="budgetText">0 lines</span>
62
+ <span class="mono" id="budgetText">0 chars</span>
58
63
  </div>
59
64
  <div class="budget-track">
60
65
  <div class="budget-segments" id="budgetSegments"></div>
@@ -82,7 +87,7 @@
82
87
  <!-- Summary stats -->
83
88
  <div class="summary-bar" id="summaryBar">
84
89
  <div class="summary-card"><span class="sc-value" id="statFiles">-</span><span class="sc-label">Files</span></div>
85
- <div class="summary-card"><span class="sc-value" id="statLines">-</span><span class="sc-label">Lines</span></div>
90
+ <div class="summary-card"><span class="sc-value" id="statChars">-</span><span class="sc-label">Chars</span></div>
86
91
  <div class="summary-card"><span class="sc-value" id="statBytes">-</span><span class="sc-label">Bytes</span></div>
87
92
  <div class="summary-card"><span class="sc-value" id="statAlways">-</span><span class="sc-label">Always loaded</span></div>
88
93
  </div>
@@ -145,6 +150,19 @@
145
150
  </div>
146
151
  </div>
147
152
 
153
+ <!-- Analysis modal -->
154
+ <div class="modal-overlay" id="analysisModal" onclick="if(event.target===this)closeModal('analysisModal')">
155
+ <div class="modal modal-wide">
156
+ <div class="modal-header">
157
+ <h3>Claude analysis</h3>
158
+ <button class="modal-close" onclick="closeModal('analysisModal')">&#10005;</button>
159
+ </div>
160
+ <div class="modal-body">
161
+ <div id="analysisSection"></div>
162
+ </div>
163
+ </div>
164
+ </div>
165
+
148
166
  <!-- Help modal -->
149
167
  <div class="modal-overlay" id="helpModal" onclick="if(event.target===this)toggleHelpModal()">
150
168
  <div class="modal">