claude-code-memory-explorer 0.9.1 → 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/public/app.js CHANGED
@@ -9,12 +9,23 @@ let selectedFileId = null;
9
9
 
10
10
  // #region UTILS
11
11
 
12
+ const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '`': '&#96;' };
13
+
12
14
  function esc(s) {
13
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
15
+ if (s == null) return '';
16
+ return String(s).replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c]);
14
17
  }
15
18
 
16
- function escJs(s) {
17
- return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
19
+ // For a value landing inside a quoted JS string inside an HTML attribute —
20
+ // onclick="fn('${escAttrJs(x)}')". The browser HTML-decodes the attribute before
21
+ // the JS parser sees it, so the JS escape must happen first and be escaped in turn.
22
+ function escAttrJs(value) {
23
+ const js = String(value == null ? '' : value)
24
+ .replace(/\\/g, '\\\\')
25
+ .replace(/'/g, "\\'")
26
+ .replace(/\r/g, '\\r')
27
+ .replace(/\n/g, '\\n');
28
+ return esc(js);
18
29
  }
19
30
 
20
31
  // #endregion UTILS
@@ -47,7 +58,7 @@ function highlightSource(text, fileName) {
47
58
  if (fm) {
48
59
  const fmHtml = hljs.highlight(fm[1], { language: 'yaml' }).value;
49
60
  const bodyHtml = hljs.highlight(fm[2], { language: 'markdown' }).value;
50
- 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}`;
51
62
  }
52
63
  }
53
64
  return hljs.highlight(text, { language: lang }).value;
@@ -64,7 +75,7 @@ function linkifyImports(text, _sourceId) {
64
75
  const ph = (child, display) => {
65
76
  const token = `\x00LINK${placeholders.length}\x00`;
66
77
  placeholders.push(
67
- `<a class="inline-import" href="#" onclick="selectFile('${escJs(child.id)}');return false" title="${esc(child.path)}">${esc(display)}</a>`,
78
+ `<a class="inline-import" href="#" onclick="selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${esc(display)}</a>`,
68
79
  );
69
80
  return token;
70
81
  };
@@ -273,9 +284,9 @@ function renderRecentProjects() {
273
284
  recent
274
285
  .map(
275
286
  (p) =>
276
- `<div class="recent-project-item" onclick="_selectRecentProject('${escJs(p)}')">` +
287
+ `<div class="recent-project-item" onclick="_selectRecentProject('${escAttrJs(p)}')">` +
277
288
  `<span>${esc(p)}</span>` +
278
- `<button class="recent-project-remove" onclick="_removeRecentProject('${escJs(p)}', event)" title="Remove">&#10005;</button>` +
289
+ `<button class="recent-project-remove" onclick="_removeRecentProject('${escAttrJs(p)}', event)" title="Remove">&#10005;</button>` +
279
290
  `</div>`,
280
291
  )
281
292
  .join('');
@@ -293,6 +304,12 @@ function toggleHelpModal() {
293
304
  document.getElementById('helpModal').classList.toggle('open');
294
305
  }
295
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
+
296
313
  function bindModalKeys(inputId, modalId, submitFn) {
297
314
  document.getElementById(inputId).addEventListener('keydown', (e) => {
298
315
  if (e.key === 'Enter') {
@@ -310,12 +327,13 @@ function bindModalKeys(inputId, modalId, submitFn) {
310
327
 
311
328
  // #region RENDER_TREE
312
329
 
313
- const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'agent-memory'];
330
+ const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'skill', 'agent-memory'];
314
331
  const SCOPE_LABELS = {
315
332
  policy: 'Managed Policy',
316
333
  user: 'User',
317
334
  project: 'Project',
318
335
  rule: 'Rules',
336
+ skill: 'Skills',
319
337
  memory: 'Auto Memory',
320
338
  'agent-memory': 'Agent Memory',
321
339
  };
@@ -385,9 +403,9 @@ function renderTree() {
385
403
  const children = childrenOf[item.id];
386
404
  const expanded = children && isItemExpanded(item.id);
387
405
  const chevron = children
388
- ? `<span class="tree-chevron" onclick="_toggleItem(event,'${escJs(item.id)}')" title="${children.length} referenced">${expanded ? '▾' : '▸'}</span>`
406
+ ? `<span class="tree-chevron" onclick="_toggleItem(event,'${escAttrJs(item.id)}')" title="${children.length} referenced">${expanded ? '▾' : '▸'}</span>`
389
407
  : '<span class="tree-chevron-spacer"></span>';
390
- 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('${escJs(item.id)}')"${pad}>`;
408
+ 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}>`;
391
409
  h += chevron;
392
410
  h += `<span class="load-icon" title="${loadTitle}" style="color:var(--scope-${item.scope})">${loadIcon}</span>`;
393
411
  h += `<span class="file-name">${esc(item.name)}</span>`;
@@ -408,7 +426,7 @@ function renderTree() {
408
426
  const collapsed = collapsible && isGroupCollapsed(scope);
409
427
  const chevron = collapsible ? `<span class="group-chevron">${collapsed ? '\u25B8' : '\u25BE'}</span>` : '';
410
428
  const headerClass = collapsible ? 'tree-group-header tree-group-clickable' : 'tree-group-header';
411
- const onclick = collapsible ? ` onclick="_toggleGroup('${escJs(scope)}')"` : '';
429
+ const onclick = collapsible ? ` onclick="_toggleGroup('${escAttrJs(scope)}')"` : '';
412
430
  html += `<div class="${headerClass}" data-scope="${esc(scope)}"${onclick}>${chevron}<span class="scope-dot" style="color:var(--scope-${scope})">\u25CF</span> ${esc(label)} <span style="opacity:0.5">${items.length}</span></div>`;
413
431
  if (collapsed) continue;
414
432
  if (scope === 'agent-memory') {
@@ -505,7 +523,7 @@ function selectFile(id, pushState = true) {
505
523
  }
506
524
 
507
525
  function scrollToSelected() {
508
- const el = document.querySelector(`.tree-item[data-id="${selectedFileId}"]`);
526
+ const el = document.querySelector(`.tree-item[data-id="${CSS.escape(selectedFileId ?? '')}"]`);
509
527
  if (el) el.scrollIntoView({ block: 'nearest' });
510
528
  }
511
529
 
@@ -608,7 +626,7 @@ function renderMemoryIndexTable(entries) {
608
626
  const type = typeMatch ? typeMatch[1] : 'memory';
609
627
  const scopeColor = TYPE_COLORS[type] || 'memory';
610
628
  const nameHtml = child
611
- ? `<a class="import-link" href="#" onclick="selectFile('${escJs(child.id)}');return false" title="${esc(child.path)}">${esc(entry.name)}</a>`
629
+ ? `<a class="import-link" href="#" onclick="selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${esc(entry.name)}</a>`
612
630
  : `<span class="import-link unresolved" title="Not found: ${esc(entry.file)}">⚠ ${esc(entry.name)}</span>`;
613
631
  html += `<div class="memory-index-row">`;
614
632
  html += `<span class="scope-badge scope-${scopeColor} memory-index-type">${esc(type)}</span>`;
@@ -649,21 +667,37 @@ async function renderPreview() {
649
667
  if (isMemoryIndex(source)) {
650
668
  const broken = (source.unresolvedImports || []).length;
651
669
  if (broken) {
652
- html += `<button class="action-btn small" onclick="_cleanupOrphans('${escJs(source.path)}')" title="Cleanup ${broken} orphaned refs"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M12 11v6"/></svg><span style="margin-left:3px">${broken}</span></button>`;
670
+ html += `<button class="action-btn small" onclick="_cleanupOrphans('${escAttrJs(source.path)}')" title="Cleanup ${broken} orphaned refs"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M12 11v6"/></svg><span style="margin-left:3px">${broken}</span></button>`;
653
671
  }
654
672
  }
655
- html += `<button class="action-btn small" onclick="openInEditor('${escJs(source.path)}')" title="Open in VS Code"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M17.583 2.207a1.1 1.1 0 0 1 1.541.033l2.636 2.636a1.1 1.1 0 0 1 .033 1.541L10.68 17.53a1.1 1.1 0 0 1-.345.247l-4.56 1.903a.55.55 0 0 1-.725-.725l1.903-4.56a1.1 1.1 0 0 1 .247-.345zm.902 1.87-8.794 8.793-.946 2.268 2.268-.946 8.794-8.793z"/></svg></button>`;
656
- html += `<button class="action-btn small" onclick="_confirmDeleteFile('${escJs(source.path)}','${escJs(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>`;
673
+ html += `<button class="action-btn small" onclick="openInEditor('${escAttrJs(source.path)}')" title="Open in VS Code"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M17.583 2.207a1.1 1.1 0 0 1 1.541.033l2.636 2.636a1.1 1.1 0 0 1 .033 1.541L10.68 17.53a1.1 1.1 0 0 1-.345.247l-4.56 1.903a.55.55 0 0 1-.725-.725l1.903-4.56a1.1 1.1 0 0 1 .247-.345zm.902 1.87-8.794 8.793-.946 2.268 2.268-.946 8.794-8.793z"/></svg></button>`;
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>`;
657
675
  html += '</div></div>';
658
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
+
659
685
  // Badges row
660
686
  html += '<div class="preview-badges">';
661
687
  html += `<span class="load-badge load-${source.load}">${esc(source.load)}</span>`;
662
688
  html += `<span class="tag-badge">${source.lines}L / ${formatBytes(source.bytes)}</span>`;
663
689
  if (fileData.frontmatter) {
664
690
  for (const [k, v] of Object.entries(fileData.frontmatter)) {
665
- const val = Array.isArray(v) ? v.join(', ') : v;
666
- 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>`;
667
701
  }
668
702
  }
669
703
  html += '</div>';
@@ -677,7 +711,7 @@ async function renderPreview() {
677
711
  if (!showMemoryTable && (children.length || unresolved.length)) {
678
712
  html += '<div class="preview-imports">';
679
713
  for (const child of children) {
680
- html += `<a class="import-link" href="#" onclick="selectFile('${escJs(child.id)}');return false" title="${esc(child.path)}">${esc(child.name)}</a>`;
714
+ html += `<a class="import-link" href="#" onclick="selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${esc(child.name)}</a>`;
681
715
  }
682
716
  for (const u of unresolved) {
683
717
  html += `<span class="import-link unresolved" title="Not found: ${esc(u)}">⚠ ${esc(u)}</span>`;
@@ -685,14 +719,15 @@ async function renderPreview() {
685
719
  html += '</div>';
686
720
  }
687
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>`;
688
724
  html += '</div>';
689
725
 
690
- // File path
691
- html += `<div class="preview-filepath">${esc(source.path)}</div>`;
692
-
693
726
  const hl = (text) => {
694
727
  const { text: processed, placeholders } = linkifyImports(text, source.id);
695
- 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);
696
731
  };
697
732
 
698
733
  if (showMemoryTable) {
@@ -837,6 +872,433 @@ async function openInEditor(filePath) {
837
872
 
838
873
  // #endregion RENDER_PREVIEW
839
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
+
840
1302
  // #region RENDER_BUDGET
841
1303
 
842
1304
  function renderBudget() {
@@ -844,32 +1306,30 @@ function renderBudget() {
844
1306
 
845
1307
  // Summary stat cards
846
1308
  document.getElementById('statFiles').textContent = summaryData.totalFiles;
847
- document.getElementById('statLines').textContent = summaryData.totalLines.toLocaleString();
1309
+ document.getElementById('statChars').textContent = summaryData.totalChars.toLocaleString();
848
1310
  document.getElementById('statBytes').textContent = formatBytes(summaryData.totalBytes);
849
1311
  document.getElementById('statAlways').textContent = summaryData.alwaysLoaded;
850
1312
 
851
1313
  // Budget text
852
1314
  document.getElementById('budgetText').textContent =
853
- `${summaryData.totalLines.toLocaleString()} lines / ${formatBytes(summaryData.totalBytes)}`;
1315
+ `${summaryData.totalChars.toLocaleString()} chars / ${formatBytes(summaryData.totalBytes)}`;
854
1316
 
855
- // 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.
856
1319
  const segContainer = document.getElementById('budgetSegments');
857
- if (!stackData.length) {
858
- segContainer.innerHTML = '';
859
- return;
860
- }
861
-
862
- const scopeTotals = {};
863
- for (const s of stackData) {
864
- scopeTotals[s.scope] = (scopeTotals[s.scope] || 0) + (s.lines || 0);
865
- }
866
- const totalLines = summaryData.totalLines || 1;
1320
+ const scopeTotals = summaryData.scopeChars || {};
1321
+ const skillDescChars = summaryData.skillDesc?.chars || 0;
1322
+ const totalChars = summaryData.totalChars || 1;
867
1323
  let html = '';
868
1324
  for (const scope of SCOPE_ORDER) {
869
- const lines = scopeTotals[scope];
870
- if (!lines) continue;
871
- const pct = (lines / totalLines) * 100;
872
- 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>`;
873
1333
  }
874
1334
  segContainer.innerHTML = html;
875
1335
  }
@@ -981,11 +1441,7 @@ document.addEventListener('keydown', (e) => {
981
1441
  .catch(() => ({}));
982
1442
  if (!cfg.enabled) return;
983
1443
  window.__HUB__ = cfg;
984
- const fwd = (e) =>
985
- window.parent?.postMessage(
986
- { type: 'hub:keydown', key: e.key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey },
987
- '*',
988
- );
1444
+ const fwd = (e) => hubPost({ type: 'hub:keydown', key: e.key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
989
1445
  document.addEventListener('keydown', (e) => {
990
1446
  if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
991
1447
  e.preventDefault();
@@ -1005,12 +1461,19 @@ document.addEventListener('keydown', (e) => {
1005
1461
 
1006
1462
  function _hubNavigate(app, url) {
1007
1463
  if (!window.__HUB__?.enabled) return;
1008
- window.parent?.postMessage({ type: 'hub:navigate', app, url }, '*');
1464
+ hubPost({ type: 'hub:navigate', app, url });
1009
1465
  }
1010
1466
 
1011
1467
  // Hoisted out of initHubTheme so initHubProject can share it.
1012
1468
  const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
1013
1469
 
1470
+ // Every send is addressed to the hub explicitly. With targetOrigin '*' any page that
1471
+ // framed this app also received the forwarded keystrokes and navigation intents.
1472
+ function hubPost(message) {
1473
+ const origin = hubOrigin();
1474
+ if (origin) window.parent?.postMessage(message, origin);
1475
+ }
1476
+
1014
1477
  (function initHubTheme() {
1015
1478
  const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
1016
1479
  const getColorTheme = () => document.body.dataset.colorTheme || 'ember';
@@ -1036,8 +1499,7 @@ const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origi
1036
1499
  if (t === lastTheme && ct === lastColorTheme) return;
1037
1500
  lastTheme = t;
1038
1501
  lastColorTheme = ct;
1039
- const origin = hubOrigin();
1040
- if (origin) window.parent.postMessage({ type: 'hub:theme', theme: t, colorTheme: ct }, origin);
1502
+ hubPost({ type: 'hub:theme', theme: t, colorTheme: ct });
1041
1503
  }).observe(document.body, {
1042
1504
  attributes: true,
1043
1505
  attributeFilter: ['class', 'data-color-theme'],