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/README.md +9 -18
- package/package.json +1 -1
- package/public/app.js +986 -32
- package/public/index.html +11 -6
- package/public/style.css +1016 -36
- package/server.js +658 -26
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 =
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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.
|
|
1815
|
+
`${summaryData.totalChars.toLocaleString()} chars / ${formatBytes(summaryData.totalBytes)}`;
|
|
865
1816
|
|
|
866
|
-
// Budget segments — proportional by
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
|
881
|
-
if (!
|
|
882
|
-
const pct = (
|
|
883
|
-
html += `<div class="budget-segment" style="width:${pct}%;background:var(--scope-${scope})" title="${SCOPE_LABELS[scope] || scope}: ${
|
|
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
|
-
|
|
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
|
-
|
|
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 === '?') {
|