claude-code-memory-explorer 1.1.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +639 -48
- package/public/index.html +3 -1
- package/public/style.css +466 -0
- package/public/vendor/claude-hub-sdk.js +40 -6
- package/server.js +201 -55
package/public/app.js
CHANGED
|
@@ -5,6 +5,10 @@ let stackData = [];
|
|
|
5
5
|
let summaryData = null;
|
|
6
6
|
let selectedFileId = null;
|
|
7
7
|
let healthViewOpen = false; // opt-in view; the Analyze button toggles it, selecting a file exits it
|
|
8
|
+
let homeData = null;
|
|
9
|
+
let homeOpen = false; // the user scope opens on the home cards; a card or the health view drills into the tree
|
|
10
|
+
let homeFocus = null; // scopes a home card drilled into; the tree shows only these until cleared
|
|
11
|
+
let previewPath = null; // a file outside the stack (settings, output style) shown read-only in the preview
|
|
8
12
|
|
|
9
13
|
// #endregion STATE
|
|
10
14
|
|
|
@@ -202,10 +206,23 @@ async function fetchJSON(url) {
|
|
|
202
206
|
|
|
203
207
|
// #region PROJECT
|
|
204
208
|
|
|
205
|
-
|
|
206
|
-
|
|
209
|
+
// The user scope rides in the recents list as this entry, so the boot restore brings it back.
|
|
210
|
+
const USER_ROW = '::user::';
|
|
211
|
+
|
|
212
|
+
function projectKey(info) {
|
|
213
|
+
return info?.user ? USER_ROW : info?.path;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function loadProject(info) {
|
|
217
|
+
const wasUser = !!projectData?.user;
|
|
218
|
+
projectData = info || (await fetchJSON('/api/project'));
|
|
207
219
|
document.getElementById('projectName').textContent = projectData.name;
|
|
208
|
-
document.getElementById('projectBtn').title = projectData.
|
|
220
|
+
document.getElementById('projectBtn').title = projectData.user
|
|
221
|
+
? `User scope · ${projectData.configDir}`
|
|
222
|
+
: projectData.path;
|
|
223
|
+
document.body.classList.toggle('user-scope', !!projectData.user);
|
|
224
|
+
if (projectData.user !== wasUser) homeOpen = !!projectData.user;
|
|
225
|
+
homeFocus = null;
|
|
209
226
|
}
|
|
210
227
|
|
|
211
228
|
// Shared by the project picker, the boot restore, and the hub project shim. Throws with the
|
|
@@ -216,12 +233,13 @@ async function putProject(dirPath) {
|
|
|
216
233
|
const res = await fetch('/api/project', {
|
|
217
234
|
method: 'PUT',
|
|
218
235
|
headers: { 'Content-Type': 'application/json' },
|
|
219
|
-
body: JSON.stringify({ path: dirPath }),
|
|
236
|
+
body: JSON.stringify(dirPath === USER_ROW ? { user: true } : { path: dirPath }),
|
|
220
237
|
});
|
|
221
238
|
if (!res.ok) {
|
|
222
239
|
const err = await res.json().catch(() => ({}));
|
|
223
240
|
throw new Error(err.error || `${res.status}`);
|
|
224
241
|
}
|
|
242
|
+
return res.json();
|
|
225
243
|
}
|
|
226
244
|
|
|
227
245
|
function changeProject() {
|
|
@@ -238,11 +256,15 @@ async function submitProjectPicker() {
|
|
|
238
256
|
btn.disabled = true;
|
|
239
257
|
btn.textContent = 'Switching...';
|
|
240
258
|
try {
|
|
241
|
-
await putProject(dirPath);
|
|
259
|
+
const info = await putProject(dirPath);
|
|
260
|
+
// A later hub push of the project this app left must still apply, not be deduped.
|
|
261
|
+
lastAppliedProject = dirPath;
|
|
242
262
|
closeModal('projectPickerModal');
|
|
243
263
|
addRecentProject(dirPath);
|
|
244
|
-
|
|
245
|
-
|
|
264
|
+
// loadData reads projectData to decide whether the home cards need /api/home.
|
|
265
|
+
await loadProject(info);
|
|
266
|
+
await loadData();
|
|
267
|
+
showToast(dirPath === USER_ROW ? 'Switched to user scope' : 'Project switched', 'success');
|
|
246
268
|
} catch (err) {
|
|
247
269
|
showToast(err.message, 'error');
|
|
248
270
|
} finally {
|
|
@@ -291,6 +313,17 @@ function getRecentProjects() {
|
|
|
291
313
|
}
|
|
292
314
|
}
|
|
293
315
|
|
|
316
|
+
// A fresh app (no recents) starts on the user home, so the hub's replayed project does not apply either.
|
|
317
|
+
function userScopeIsHead() {
|
|
318
|
+
const head = getRecentProjects()[0];
|
|
319
|
+
return !head || head === USER_ROW;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function isUserScopeRemembered() {
|
|
323
|
+
await projectInfoPromise;
|
|
324
|
+
return userScopeIsHead();
|
|
325
|
+
}
|
|
326
|
+
|
|
294
327
|
function addRecentProject(p) {
|
|
295
328
|
const recent = getRecentProjects().filter((r) => r !== p);
|
|
296
329
|
recent.unshift(p);
|
|
@@ -305,7 +338,7 @@ function setPickerMode(add) {
|
|
|
305
338
|
document.getElementById('projectPickerFilter').hidden = add;
|
|
306
339
|
document.getElementById('projectPickerAddForm').hidden = !add;
|
|
307
340
|
const input = document.getElementById(add ? 'projectPathInput' : 'projectPickerInput');
|
|
308
|
-
if (add) input.value =
|
|
341
|
+
if (add) input.value = projectData?.path || '';
|
|
309
342
|
// The overlay fades in, so a focus() on a field that is still hidden is dropped.
|
|
310
343
|
setTimeout(() => input.focus(), 100);
|
|
311
344
|
}
|
|
@@ -319,19 +352,29 @@ function splitProjectPath(p) {
|
|
|
319
352
|
function renderProjectPicker() {
|
|
320
353
|
const list = document.getElementById('projectPickerList');
|
|
321
354
|
const q = document.getElementById('projectPickerInput').value.trim().toLowerCase();
|
|
322
|
-
const current =
|
|
323
|
-
const recent = getRecentProjects();
|
|
324
|
-
|
|
355
|
+
const current = projectKey(projectData);
|
|
356
|
+
const recent = getRecentProjects().filter((p) => p !== USER_ROW);
|
|
357
|
+
const userMatches = !q || 'user (no project)'.includes(q);
|
|
358
|
+
pickerRows = [
|
|
359
|
+
...(userMatches ? [USER_ROW] : []),
|
|
360
|
+
...(q ? recent.filter((p) => p.toLowerCase().includes(q)) : recent),
|
|
361
|
+
];
|
|
325
362
|
if (!pickerRows.length) {
|
|
326
363
|
pickerIdx = -1;
|
|
327
|
-
|
|
328
|
-
list.innerHTML = `<div class="picker-empty">${msg}</div>`;
|
|
364
|
+
list.innerHTML = '<div class="picker-empty">No projects match</div>';
|
|
329
365
|
return;
|
|
330
366
|
}
|
|
331
367
|
list.innerHTML = pickerRows
|
|
332
368
|
.map((p, i) => {
|
|
333
|
-
const { name, parent } = splitProjectPath(p);
|
|
334
369
|
const cur = p === current;
|
|
370
|
+
if (p === USER_ROW) {
|
|
371
|
+
return `<div class="picker-row picker-user${cur ? ' current' : ''}" onclick="_pickProject(${i})" title="Sources every session loads, whatever the project">
|
|
372
|
+
<span class="picker-name">User (no project)</span>
|
|
373
|
+
<span class="picker-parent">${esc(claudeConfigDir)}</span>
|
|
374
|
+
${cur ? '<span class="picker-tag">current</span>' : ''}
|
|
375
|
+
</div>`;
|
|
376
|
+
}
|
|
377
|
+
const { name, parent } = splitProjectPath(p);
|
|
335
378
|
return `<div class="picker-row${cur ? ' current' : ''}" onclick="_pickProject(${i})" title="${esc(p)}">
|
|
336
379
|
<span class="picker-name">${esc(name)}</span>
|
|
337
380
|
<span class="picker-parent">${esc(parent)}</span>
|
|
@@ -363,7 +406,7 @@ function _pickProject(idx) {
|
|
|
363
406
|
function _removeRecentProject(idx, e) {
|
|
364
407
|
e.stopPropagation();
|
|
365
408
|
const target = pickerRows[idx];
|
|
366
|
-
if (!target) return;
|
|
409
|
+
if (!target || target === USER_ROW) return;
|
|
367
410
|
localStorage.setItem(recentsKey(), JSON.stringify(getRecentProjects().filter((p) => p !== target)));
|
|
368
411
|
renderProjectPicker();
|
|
369
412
|
}
|
|
@@ -452,11 +495,29 @@ const SHORTCUT_PAIRS = [
|
|
|
452
495
|
title: 'Hub',
|
|
453
496
|
hub: true,
|
|
454
497
|
rows: [
|
|
455
|
-
{
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
498
|
+
{
|
|
499
|
+
keys: ['Ctrl', 'Alt', '←/→'],
|
|
500
|
+
action: ['hub.prevApp', 'hub.nextApp'],
|
|
501
|
+
combo: true,
|
|
502
|
+
hubMod: true,
|
|
503
|
+
label: 'Previous / next hub app',
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
keys: ['Alt', '1…9'],
|
|
507
|
+
action: 'hub.appByNumber',
|
|
508
|
+
combo: true,
|
|
509
|
+
hubMod: true,
|
|
510
|
+
label: 'Jump to hub app by number',
|
|
511
|
+
},
|
|
512
|
+
{ keys: ['Ctrl', 'Alt', 'P'], action: 'hub.projectPicker', combo: true, hubMod: true, label: 'Project picker' },
|
|
513
|
+
{
|
|
514
|
+
keys: ['Ctrl', 'Alt', 'W'],
|
|
515
|
+
action: 'hub.configDirPicker',
|
|
516
|
+
combo: true,
|
|
517
|
+
hubMod: true,
|
|
518
|
+
label: 'Config dir picker',
|
|
519
|
+
},
|
|
520
|
+
{ keys: ['Ctrl', 'Alt', 'A'], action: 'hub.appLauncher', combo: true, hubMod: true, label: 'App launcher' },
|
|
460
521
|
],
|
|
461
522
|
},
|
|
462
523
|
],
|
|
@@ -464,6 +525,21 @@ const SHORTCUT_PAIRS = [
|
|
|
464
525
|
|
|
465
526
|
const EMPTY_GROUP = { title: '', rows: [] };
|
|
466
527
|
|
|
528
|
+
// macOS names Control, Option and Shift by their symbols.
|
|
529
|
+
const IS_MAC = /^Mac/i.test(navigator.userAgentData?.platform || navigator.platform || '');
|
|
530
|
+
const MAC_KEYS = { Ctrl: '⌃', Alt: '⌥', Shift: '⇧' };
|
|
531
|
+
|
|
532
|
+
// The keys of a help row as they read on this system. The hub's modifier is Ctrl+Alt, and on
|
|
533
|
+
// macOS Control+Option for the tab numbers too (Windows and Linux use bare Alt for those). A hub
|
|
534
|
+
// row shows the keys the hub reports, which the user can change; row.keys until it answers.
|
|
535
|
+
function helpKeys(row, mac = IS_MAC, keyLabel = hub.keyLabel) {
|
|
536
|
+
const live = row.action && keyLabel(row.action);
|
|
537
|
+
if (live) return live;
|
|
538
|
+
if (!mac) return row.keys;
|
|
539
|
+
if (row.hubMod) return ['⌃', '⌥', ...row.keys.filter((k) => k !== 'Ctrl' && k !== 'Alt')];
|
|
540
|
+
return row.keys.map((k) => MAC_KEYS[k] || k);
|
|
541
|
+
}
|
|
542
|
+
|
|
467
543
|
// Interleaves each pair's rows left-then-right so CSS grid auto-placement lands
|
|
468
544
|
// them on shared row tracks (see .shortcuts in style.css).
|
|
469
545
|
function buildHelpShortcuts() {
|
|
@@ -480,9 +556,10 @@ function buildHelpShortcuts() {
|
|
|
480
556
|
for (let i = 0; i < Math.max(left.rows.length, right.rows.length); i++) {
|
|
481
557
|
for (const [group, side] of sides) {
|
|
482
558
|
const row = group.rows[i];
|
|
483
|
-
|
|
559
|
+
const parts = row && helpKeys(row);
|
|
560
|
+
if (!parts?.length) continue;
|
|
484
561
|
const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
|
|
485
|
-
const keys =
|
|
562
|
+
const keys = parts.map((k) => `<kbd>${esc(k)}</kbd>`).join(sep);
|
|
486
563
|
const cls = side + (group.hub ? ' sc-hub' : '');
|
|
487
564
|
cells.push(`<dt class="${esc(cls)}">${keys}</dt><dd class="${esc(cls)}">${esc(row.label)}</dd>`);
|
|
488
565
|
}
|
|
@@ -499,7 +576,8 @@ function toggleHelpModal() {
|
|
|
499
576
|
}
|
|
500
577
|
|
|
501
578
|
function openHealthView() {
|
|
502
|
-
healthViewOpen = !healthViewOpen;
|
|
579
|
+
healthViewOpen = homeOpen || !healthViewOpen;
|
|
580
|
+
leaveHome();
|
|
503
581
|
document.getElementById('analyzeBtn')?.classList.toggle('active', healthViewOpen);
|
|
504
582
|
renderPreview();
|
|
505
583
|
if (healthViewOpen) refreshAnalysis();
|
|
@@ -509,13 +587,14 @@ function openHealthView() {
|
|
|
509
587
|
|
|
510
588
|
// #region RENDER_TREE
|
|
511
589
|
|
|
512
|
-
const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'skill', 'agent-memory'];
|
|
590
|
+
const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'skill', 'agent', 'agent-memory'];
|
|
513
591
|
const SCOPE_LABELS = {
|
|
514
592
|
policy: 'Managed Policy',
|
|
515
593
|
user: 'User',
|
|
516
594
|
project: 'Project',
|
|
517
595
|
rule: 'Rules',
|
|
518
596
|
skill: 'Skills',
|
|
597
|
+
agent: 'Agents',
|
|
519
598
|
memory: 'Auto Memory',
|
|
520
599
|
'agent-memory': 'Agent Memory',
|
|
521
600
|
};
|
|
@@ -612,9 +691,10 @@ function renderTree() {
|
|
|
612
691
|
}
|
|
613
692
|
|
|
614
693
|
let html = '';
|
|
694
|
+
if (projectData?.user) html += treeHomeLink();
|
|
615
695
|
for (const scope of SCOPE_ORDER) {
|
|
616
696
|
const items = groups[scope];
|
|
617
|
-
if (!items) continue;
|
|
697
|
+
if (!items || !inFocus(scope)) continue;
|
|
618
698
|
const label = SCOPE_LABELS[scope] || scope;
|
|
619
699
|
const collapsible = scope === 'agent-memory';
|
|
620
700
|
const collapsed = collapsible && isGroupCollapsed(scope);
|
|
@@ -703,13 +783,16 @@ function pushFileState(id) {
|
|
|
703
783
|
}
|
|
704
784
|
|
|
705
785
|
function selectFile(id, pushState = true) {
|
|
786
|
+
leaveHome();
|
|
706
787
|
if (healthViewOpen) {
|
|
707
788
|
healthViewOpen = false;
|
|
708
789
|
document.getElementById('analyzeBtn')?.classList.remove('active');
|
|
709
790
|
}
|
|
710
791
|
selectedFileId = selectedFileId === id ? null : id;
|
|
792
|
+
previewPath = null;
|
|
711
793
|
if (selectedFileId) {
|
|
712
794
|
const item = stackData.find((s) => s.id === selectedFileId);
|
|
795
|
+
if (item && !inFocus(getItemScope(item))) homeFocus = null;
|
|
713
796
|
if (item) {
|
|
714
797
|
expandGroupForItem(item);
|
|
715
798
|
expandItemAncestors(item);
|
|
@@ -749,7 +832,7 @@ function getVisibleOrder() {
|
|
|
749
832
|
}
|
|
750
833
|
};
|
|
751
834
|
for (const scope of SCOPE_ORDER) {
|
|
752
|
-
if (isGroupCollapsed(scope)) continue;
|
|
835
|
+
if (isGroupCollapsed(scope) || !inFocus(scope)) continue;
|
|
753
836
|
const items = groups[scope];
|
|
754
837
|
if (items) for (const item of items) walk(item);
|
|
755
838
|
}
|
|
@@ -848,7 +931,7 @@ async function renderPreview() {
|
|
|
848
931
|
if (lastAnalysisSt) rerenderAnalysis();
|
|
849
932
|
return;
|
|
850
933
|
}
|
|
851
|
-
const source = stackData.find((s) => s.id === selectedFileId);
|
|
934
|
+
const source = stackData.find((s) => s.id === selectedFileId) || previewPath;
|
|
852
935
|
if (!source) {
|
|
853
936
|
panel.innerHTML =
|
|
854
937
|
'<div class="preview-empty"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v4c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 9v4c0 1.66 4.03 3 9 3s9-1.34 9-3V9"/><path d="M3 13v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4"/></svg><span>Select a file to preview</span></div>';
|
|
@@ -859,9 +942,11 @@ async function renderPreview() {
|
|
|
859
942
|
try {
|
|
860
943
|
fileData = await fetchJSON(`/api/file?path=${encodeURIComponent(source.path)}`);
|
|
861
944
|
} catch {
|
|
862
|
-
|
|
945
|
+
const msg = source.outside ? `${source.path} does not exist` : 'Failed to load file';
|
|
946
|
+
panel.innerHTML = `<div class="preview-empty"><span>${esc(msg)}</span></div>`;
|
|
863
947
|
return;
|
|
864
948
|
}
|
|
949
|
+
if (source.outside) Object.assign(source, { lines: fileData.lines, bytes: fileData.bytes });
|
|
865
950
|
|
|
866
951
|
let html = '<div class="preview-header">';
|
|
867
952
|
html += '<div class="preview-title">';
|
|
@@ -875,7 +960,8 @@ async function renderPreview() {
|
|
|
875
960
|
}
|
|
876
961
|
}
|
|
877
962
|
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>`;
|
|
878
|
-
|
|
963
|
+
if (!source.outside)
|
|
964
|
+
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>`;
|
|
879
965
|
html += '</div></div>';
|
|
880
966
|
|
|
881
967
|
// Description gets its own full-width line; the rest stay as key:value chips
|
|
@@ -1080,6 +1166,7 @@ async function openInEditor(filePath) {
|
|
|
1080
1166
|
|
|
1081
1167
|
// #region ANALYZER
|
|
1082
1168
|
|
|
1169
|
+
const SEV_ORDER = { high: 0, med: 1, low: 2, clean: 3 };
|
|
1083
1170
|
let analysisPollTimer = null;
|
|
1084
1171
|
let lastAnalysisSt = null;
|
|
1085
1172
|
let analysisFindings = []; // findings of the rendered result, addressed by index from handlers
|
|
@@ -1236,6 +1323,7 @@ async function refreshAnalysis() {
|
|
|
1236
1323
|
renderTree();
|
|
1237
1324
|
if (isNew && !healthViewOpen && selectedFileId) renderPreview();
|
|
1238
1325
|
rerenderAnalysis(st);
|
|
1326
|
+
if (homeOpen) renderHome();
|
|
1239
1327
|
}
|
|
1240
1328
|
lastPollKey = pollKey;
|
|
1241
1329
|
clearTimeout(analysisPollTimer);
|
|
@@ -1312,8 +1400,11 @@ function analysisScopeGroups() {
|
|
|
1312
1400
|
function _toggleScopePanel() {
|
|
1313
1401
|
scopePanelOpen = !scopePanelOpen;
|
|
1314
1402
|
if (scopePanelOpen && !scopeChecked) {
|
|
1315
|
-
// default scope = auto memory only (skills, agent memory, CLAUDE.md are opt-in)
|
|
1316
|
-
|
|
1403
|
+
// default scope = auto memory only (skills, agent memory, CLAUDE.md are opt-in);
|
|
1404
|
+
// the user scope has no auto memory, so it starts on the user CLAUDE.md
|
|
1405
|
+
const memory = stackData.filter((s) => s.scope === 'memory');
|
|
1406
|
+
const fallback = stackData.filter((s) => s.scope === 'user' && !s.parentId);
|
|
1407
|
+
scopeChecked = new Set((memory.length ? memory : fallback).map((s) => s.id));
|
|
1317
1408
|
}
|
|
1318
1409
|
rerenderAnalysis();
|
|
1319
1410
|
}
|
|
@@ -1709,14 +1800,13 @@ function renderMemoryMap(shown) {
|
|
|
1709
1800
|
}
|
|
1710
1801
|
}
|
|
1711
1802
|
}
|
|
1712
|
-
const sevRank = { high: 0, med: 1, low: 2, clean: 3 };
|
|
1713
1803
|
const cellSev = (s) => {
|
|
1714
1804
|
const c = perFile.get(s.id);
|
|
1715
1805
|
return c ? (c.high ? 'high' : c.med ? 'med' : 'low') : 'clean';
|
|
1716
1806
|
};
|
|
1717
1807
|
let html = '<div class="hv-map-label">Memory map — colored by worst finding</div><div class="hv-map">';
|
|
1718
1808
|
for (const s of [...audited].sort(
|
|
1719
|
-
(a, b) =>
|
|
1809
|
+
(a, b) => SEV_ORDER[cellSev(a)] - SEV_ORDER[cellSev(b)] || a.name.localeCompare(b.name),
|
|
1720
1810
|
)) {
|
|
1721
1811
|
const c = perFile.get(s.id);
|
|
1722
1812
|
const sev = cellSev(s);
|
|
@@ -1781,10 +1871,9 @@ function _toggleFixSelect(idx) {
|
|
|
1781
1871
|
}
|
|
1782
1872
|
|
|
1783
1873
|
function _copyFixPlan() {
|
|
1784
|
-
const order = { high: 0, med: 1, low: 2 };
|
|
1785
1874
|
const picked = analysisFindings
|
|
1786
1875
|
.filter((f) => fixSelected.has(findingKey(f)))
|
|
1787
|
-
.sort((a, b) =>
|
|
1876
|
+
.sort((a, b) => SEV_ORDER[a.severity] - SEV_ORDER[b.severity]);
|
|
1788
1877
|
if (!picked.length) return;
|
|
1789
1878
|
const plan = [
|
|
1790
1879
|
`Fix plan — ${picked.length} finding${picked.length > 1 ? 's' : ''} from a Claude Code memory audit, ordered by severity. Apply each in order.`,
|
|
@@ -1968,6 +2057,11 @@ function renderAnalysis(st) {
|
|
|
1968
2057
|
|
|
1969
2058
|
// #region RENDER_BUDGET
|
|
1970
2059
|
|
|
2060
|
+
// Listed descriptions: [scope, summary key, label]. Each item's cost is its description, not its file.
|
|
2061
|
+
const DESC_SEGMENTS = [
|
|
2062
|
+
['skill', 'skillDesc', 'Skill descriptions'],
|
|
2063
|
+
['agent', 'agentDesc', 'Agent descriptions'],
|
|
2064
|
+
];
|
|
1971
2065
|
let footprintHighlight = false;
|
|
1972
2066
|
let footprintScope = null; // null = whole footprint; a scope name = just that segment's files
|
|
1973
2067
|
let footprintIds = new Set(); // source ids the server counted into the footprint
|
|
@@ -1989,7 +2083,7 @@ function _toggleFootprint(scope = null, ev) {
|
|
|
1989
2083
|
}
|
|
1990
2084
|
|
|
1991
2085
|
function footprintHit(item) {
|
|
1992
|
-
if (
|
|
2086
|
+
if (DESC_SEGMENTS.some(([scope]) => scope === footprintScope)) return item.scope === footprintScope && !item.parentId;
|
|
1993
2087
|
if (footprintScope) return footprintIds.has(item.id) && item.scope === footprintScope;
|
|
1994
2088
|
return footprintIds.has(item.id);
|
|
1995
2089
|
}
|
|
@@ -2019,7 +2113,6 @@ function renderBudget() {
|
|
|
2019
2113
|
// the footprint filter, the client only renders its per-scope totals.
|
|
2020
2114
|
const segContainer = document.getElementById('budgetSegments');
|
|
2021
2115
|
const scopeTotals = summaryData.scopeChars || {};
|
|
2022
|
-
const skillDescChars = summaryData.skillDesc?.chars || 0;
|
|
2023
2116
|
const totalChars = summaryData.totalChars || 1;
|
|
2024
2117
|
let html = '';
|
|
2025
2118
|
for (const scope of SCOPE_ORDER) {
|
|
@@ -2028,9 +2121,11 @@ function renderBudget() {
|
|
|
2028
2121
|
const pct = (chars / totalChars) * 100;
|
|
2029
2122
|
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>`;
|
|
2030
2123
|
}
|
|
2031
|
-
|
|
2032
|
-
const
|
|
2033
|
-
|
|
2124
|
+
for (const [scope, desc, label] of DESC_SEGMENTS) {
|
|
2125
|
+
const chars = summaryData[desc]?.chars || 0;
|
|
2126
|
+
if (!chars) continue;
|
|
2127
|
+
const pct = (chars / totalChars) * 100;
|
|
2128
|
+
html += `<div class="budget-segment" data-scope="${esc(scope)}" onclick="_toggleFootprint('${escAttrJs(scope)}', event)" style="width:${pct}%;background:var(--scope-${scope})" title="${esc(label)} (${esc(summaryData[desc].count)}): ${chars.toLocaleString()} chars (${pct.toFixed(1)}%) — click to highlight"></div>`;
|
|
2034
2129
|
}
|
|
2035
2130
|
segContainer.innerHTML = html;
|
|
2036
2131
|
applySegmentState();
|
|
@@ -2038,11 +2133,470 @@ function renderBudget() {
|
|
|
2038
2133
|
|
|
2039
2134
|
// #endregion RENDER_BUDGET
|
|
2040
2135
|
|
|
2136
|
+
// #region HOME
|
|
2137
|
+
|
|
2138
|
+
const svgIcon = (body) => `<svg class="home-ic" viewBox="0 0 24 24">${body}</svg>`;
|
|
2139
|
+
const HOME_ICON = svgIcon('<path d="M3 10l9-7 9 7v10a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><path d="M9 22V12h6v10"/>');
|
|
2140
|
+
const LOCK_ICON = svgIcon('<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/>');
|
|
2141
|
+
const SPARKLE_ICON = svgIcon(
|
|
2142
|
+
'<path d="M12 3l1.9 5.7L19.6 10l-5.7 1.9L12 17.6l-1.9-5.7L4.4 10l5.7-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
|
|
2143
|
+
);
|
|
2144
|
+
|
|
2145
|
+
function applyLayout() {
|
|
2146
|
+
const on = homeOpen && !!projectData?.user;
|
|
2147
|
+
document.body.classList.toggle('home-mode', on);
|
|
2148
|
+
if (on) renderHome();
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function leaveHome() {
|
|
2152
|
+
if (!homeOpen) return;
|
|
2153
|
+
homeOpen = false;
|
|
2154
|
+
applyLayout();
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function showHome() {
|
|
2158
|
+
homeOpen = true;
|
|
2159
|
+
homeFocus = null;
|
|
2160
|
+
previewPath = null;
|
|
2161
|
+
healthViewOpen = false;
|
|
2162
|
+
applyLayout();
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
function inFocus(scope) {
|
|
2166
|
+
return !homeFocus || homeFocus.includes(scope);
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function treeHomeLink() {
|
|
2170
|
+
const home = `<span class="tree-home-crumb" onclick="showHome()">${HOME_ICON}Home</span>`;
|
|
2171
|
+
if (!homeFocus) return `<div class="tree-home-link">${home}</div>`;
|
|
2172
|
+
const { groups } = getTreeIndex();
|
|
2173
|
+
const label = homeFocus
|
|
2174
|
+
.filter((s) => groups[s])
|
|
2175
|
+
.map((s) => SCOPE_LABELS[s] || s)
|
|
2176
|
+
.join(' + ');
|
|
2177
|
+
return `<div class="tree-home-link">${home}<span class="tree-home-sep">›</span><span class="tree-home-focus">${esc(label)}</span><span class="tree-home-all" onclick="_clearHomeFocus()" title="Show every group">show all</span></div>`;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function _clearHomeFocus() {
|
|
2181
|
+
homeFocus = null;
|
|
2182
|
+
renderTree();
|
|
2183
|
+
scrollToSelected();
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// scopes is a comma list; without it the clicked file's own group is the focus.
|
|
2187
|
+
function _openFromHome(id, scopes) {
|
|
2188
|
+
const item = stackData.find((s) => s.id === id);
|
|
2189
|
+
homeFocus = scopes ? scopes.split(',') : item ? [getItemScope(item)] : null;
|
|
2190
|
+
for (const s of homeFocus || []) setGroupCollapsed(s, false);
|
|
2191
|
+
selectedFileId = null;
|
|
2192
|
+
selectFile(id);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
function _openPath(path, scope) {
|
|
2196
|
+
leaveHome();
|
|
2197
|
+
healthViewOpen = false;
|
|
2198
|
+
selectedFileId = null;
|
|
2199
|
+
previewPath = { outside: true, id: null, scope, load: 'read-only', path, name: splitProjectPath(path).name };
|
|
2200
|
+
renderTree();
|
|
2201
|
+
renderPreview();
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
function fmtChars(n) {
|
|
2205
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function rootOf(item, byId) {
|
|
2209
|
+
let cur = item;
|
|
2210
|
+
while (cur?.parentId && byId.has(cur.parentId)) cur = byId.get(cur.parentId);
|
|
2211
|
+
return cur;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
function hardImportsOf(rootId) {
|
|
2215
|
+
const kids = (getTreeIndex().childrenOf[rootId] || []).filter((s) => s.load === 'import');
|
|
2216
|
+
return kids.flatMap((k) => [k, ...hardImportsOf(k.id)]);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function withImportsChars(item) {
|
|
2220
|
+
return [item, ...hardImportsOf(item.id)].reduce((n, s) => n + (s.chars || 0), 0);
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
const HOME_NONE = '<li><span class="home-empty">None</span></li>';
|
|
2224
|
+
|
|
2225
|
+
function moreRow(n) {
|
|
2226
|
+
return `<li class="home-more"><span class="home-nm">+ ${n} more</span></li>`;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function homeBudget() {
|
|
2230
|
+
const parts = { policy: 0, user: 0, rule: 0 };
|
|
2231
|
+
const byId = new Map(stackData.map((s) => [s.id, s]));
|
|
2232
|
+
for (const s of stackData) {
|
|
2233
|
+
if (!footprintIds.has(s.id)) continue;
|
|
2234
|
+
const root = rootOf(s, byId);
|
|
2235
|
+
if (root.scope === 'policy') parts.policy += s.chars || 0;
|
|
2236
|
+
else if (root.id === 'user-claude-md') parts.user += s.chars || 0;
|
|
2237
|
+
else if (root.scope === 'rule' && root.load === 'always') parts.rule += s.chars || 0;
|
|
2238
|
+
}
|
|
2239
|
+
parts.policy += homeData?.managed?.claudeMdChars || 0;
|
|
2240
|
+
const style = homeData?.outputStyle?.chars ?? null;
|
|
2241
|
+
return [
|
|
2242
|
+
{ key: 'policy', label: 'Policy', chars: parts.policy },
|
|
2243
|
+
{ key: 'user', label: 'CLAUDE.md + imports', chars: parts.user },
|
|
2244
|
+
{ key: 'rule', label: 'Always-on rules', chars: parts.rule },
|
|
2245
|
+
{ key: 'style', label: 'Output style', chars: style || 0, unknown: style == null },
|
|
2246
|
+
{ key: 'skill', label: 'Skill descriptions', chars: summaryData?.skillDesc?.chars || 0 },
|
|
2247
|
+
{ key: 'agent', label: 'Agent descriptions', chars: summaryData?.agentDesc?.chars || 0 },
|
|
2248
|
+
];
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
function homeCardClick({ open, focus, file, fileScope, health }) {
|
|
2252
|
+
if (open) return ` onclick="_openFromHome('${escAttrJs(open)}', '${escAttrJs(focus || '')}')"`;
|
|
2253
|
+
if (file) return ` onclick="_openPath('${escAttrJs(file)}', '${escAttrJs(fileScope)}')"`;
|
|
2254
|
+
if (health) return ' onclick="openHealthView()"';
|
|
2255
|
+
return '';
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
function homeCard({ color, title, when, always, wide, full, body, ...click }) {
|
|
2259
|
+
const onclick = homeCardClick(click);
|
|
2260
|
+
return `<div class="home-card${full ? ' full' : wide ? ' wide' : ''}" style="--c:${color}"${onclick}${onclick ? ' tabindex="0" role="button"' : ''}>
|
|
2261
|
+
<div class="home-ch"><span class="home-title">${title}</span>${when ? `<span class="home-when${always ? ' always' : ''}">${esc(when)}</span>` : ''}</div>
|
|
2262
|
+
${body}
|
|
2263
|
+
</div>`;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
function homeRow(s, right, extra = '', label = s.name) {
|
|
2267
|
+
return `<li onclick="event.stopPropagation();_openFromHome('${escAttrJs(s.id)}')"><span class="home-nm">${esc(label)}</span>${extra}<span class="home-r">${right}</span></li>`;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function homeMiniRow(s, chars, max, color) {
|
|
2271
|
+
const pct = max ? Math.max(4, Math.round((chars / max) * 100)) : 0;
|
|
2272
|
+
return homeRow(
|
|
2273
|
+
s,
|
|
2274
|
+
esc(chars.toLocaleString()),
|
|
2275
|
+
`<span class="home-mini" style="--c:${color}"><span style="width:${pct}%"></span></span>`,
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
function homeTopRows(items, color, limit = 3) {
|
|
2280
|
+
if (!items.length) return HOME_NONE;
|
|
2281
|
+
const sorted = [...items].sort((a, b) => b.descChars - a.descChars);
|
|
2282
|
+
const max = sorted[0].descChars;
|
|
2283
|
+
let html = sorted
|
|
2284
|
+
.slice(0, limit)
|
|
2285
|
+
.map((s) => homeMiniRow(s, s.descChars, max, color))
|
|
2286
|
+
.join('');
|
|
2287
|
+
if (sorted.length > limit) html += moreRow(sorted.length - limit);
|
|
2288
|
+
return html;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
function homeStats(pairs) {
|
|
2292
|
+
return `<div class="home-stats">${pairs.map(([v, l]) => `<div><b>${esc(v)}</b><span>${esc(l)}</span></div>`).join('')}</div>`;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
function renderHomePolicy() {
|
|
2296
|
+
const policy = stackData.find((s) => s.scope === 'policy' && !s.parentId);
|
|
2297
|
+
const managed = homeData?.managed || {};
|
|
2298
|
+
const managedPath = homeData?.settingsPaths?.managed || '';
|
|
2299
|
+
const none = '<span class="home-pill mut">none</span>';
|
|
2300
|
+
const mdRow = policy
|
|
2301
|
+
? homeRow(policy, esc(`${fmtChars(withImportsChars(policy))} chars`), '', 'CLAUDE.md')
|
|
2302
|
+
: `<li><span class="home-nm">CLAUDE.md</span><span class="home-r">${none}</span></li>`;
|
|
2303
|
+
const settingsName = splitProjectPath(managedPath).name || 'managed-settings.json';
|
|
2304
|
+
const settingsRow = managed.keys
|
|
2305
|
+
? `<li onclick="event.stopPropagation();_openPath('${escAttrJs(managedPath)}', 'policy')" title="${esc(managedPath)}"><span class="home-nm">${esc(settingsName)}${managed.source === 'server' ? ' <span class="home-d">· from claude.ai</span>' : ''}</span><span class="home-r">${esc(managed.keys.join(', ') || 'empty')}</span></li>`
|
|
2306
|
+
: `<li title="${esc(managedPath)}"><span class="home-nm">managed settings</span><span class="home-r">${none}</span></li>`;
|
|
2307
|
+
const claudeMdRow =
|
|
2308
|
+
managed.claudeMdChars == null
|
|
2309
|
+
? ''
|
|
2310
|
+
: `<li><span class="home-nm">claudeMd in settings</span><span class="home-r">${esc(`${fmtChars(managed.claudeMdChars)} chars`)}</span></li>`;
|
|
2311
|
+
return homeCard({
|
|
2312
|
+
color: 'var(--scope-policy)',
|
|
2313
|
+
title: `${LOCK_ICON}Managed policy`,
|
|
2314
|
+
when: 'always',
|
|
2315
|
+
always: true,
|
|
2316
|
+
open: policy?.id,
|
|
2317
|
+
file: managed.keys ? managedPath : null,
|
|
2318
|
+
fileScope: 'policy',
|
|
2319
|
+
body: `<ul class="home-rows">${mdRow}${settingsRow}${claudeMdRow}</ul>`,
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
function renderHomeUserMd() {
|
|
2324
|
+
const md = stackData.find((s) => s.id === 'user-claude-md');
|
|
2325
|
+
const card = { color: 'var(--scope-user)', title: 'User CLAUDE.md', when: 'always', always: true };
|
|
2326
|
+
if (!md) {
|
|
2327
|
+
return homeCard({
|
|
2328
|
+
...card,
|
|
2329
|
+
body: `<div class="home-path">${esc(summaryData?.userClaudeMd || '')}</div><div class="home-empty">File does not exist</div>`,
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
const imports = hardImportsOf(md.id);
|
|
2333
|
+
const tree = imports
|
|
2334
|
+
.map((s, i) => {
|
|
2335
|
+
const branch = i === imports.length - 1 ? '└─' : '├─';
|
|
2336
|
+
return `<div onclick="event.stopPropagation();_openFromHome('${escAttrJs(s.id)}')"><span class="home-d">${branch}</span> @${esc(s.name)} <span class="home-d">${esc(fmtChars(s.chars || 0))}</span></div>`;
|
|
2337
|
+
})
|
|
2338
|
+
.join('');
|
|
2339
|
+
return homeCard({
|
|
2340
|
+
...card,
|
|
2341
|
+
open: md.id,
|
|
2342
|
+
body: `<div class="home-path" title="${esc(md.path)}">${esc(md.path)}</div>${homeStats([
|
|
2343
|
+
[md.lines, 'lines'],
|
|
2344
|
+
[fmtChars(withImportsChars(md)), 'chars'],
|
|
2345
|
+
[imports.length, 'imports'],
|
|
2346
|
+
])}${imports.length ? `<div class="home-tree">${tree}</div>` : ''}`,
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
function renderHomeStyle() {
|
|
2351
|
+
const st = homeData?.outputStyle;
|
|
2352
|
+
if (!st) return '';
|
|
2353
|
+
const isDefault = st.from === 'default';
|
|
2354
|
+
const isManaged = st.from === 'managed';
|
|
2355
|
+
const fromLabel = isDefault ? 'not set · default' : `set in ${isManaged ? 'managed' : 'user'} settings`;
|
|
2356
|
+
const keep = st.keepCodingInstructions == null ? 'n/a' : String(st.keepCodingInstructions);
|
|
2357
|
+
const setIn = isManaged ? homeData.settingsPaths.managed : homeData.settingsPaths.user;
|
|
2358
|
+
const setInScope = isManaged ? 'policy' : 'user';
|
|
2359
|
+
const fromRow = isDefault
|
|
2360
|
+
? `<li><span class="home-nm">${esc(fromLabel)}</span></li>`
|
|
2361
|
+
: `<li onclick="event.stopPropagation();_openPath('${escAttrJs(setIn)}', '${escAttrJs(setInScope)}')"><span class="home-nm" title="${esc(setIn)}">${esc(fromLabel)}</span></li>`;
|
|
2362
|
+
return homeCard({
|
|
2363
|
+
color: 'var(--scope-style)',
|
|
2364
|
+
title: 'Output style',
|
|
2365
|
+
when: 'always',
|
|
2366
|
+
always: true,
|
|
2367
|
+
file: st.path || (isDefault ? null : setIn),
|
|
2368
|
+
fileScope: st.path ? 'user' : setInScope,
|
|
2369
|
+
body: `${homeStats([[st.name, !st.path ? 'built-in · text not on disk' : `${fmtChars(st.chars || 0)} chars`]])}
|
|
2370
|
+
<ul class="home-rows">
|
|
2371
|
+
${fromRow}
|
|
2372
|
+
<li><span class="home-nm">keep-coding-instructions</span><span class="home-r">${esc(keep)}</span></li>
|
|
2373
|
+
</ul>`,
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function renderHomeRules() {
|
|
2378
|
+
const rules = stackData.filter((s) => s.scope === 'rule' && !s.parentId);
|
|
2379
|
+
const always = rules.filter((s) => s.load === 'always');
|
|
2380
|
+
const cond = rules.filter((s) => s.load !== 'always');
|
|
2381
|
+
const alwaysChars = always.reduce((n, s) => n + withImportsChars(s), 0);
|
|
2382
|
+
const globs = (s) => {
|
|
2383
|
+
const p = s.frontmatter?.paths;
|
|
2384
|
+
return Array.isArray(p) ? p.join(', ') : String(p || '');
|
|
2385
|
+
};
|
|
2386
|
+
const list = (items, right) => (items.length ? items.map((s) => homeRow(s, right(s))).join('') : HOME_NONE);
|
|
2387
|
+
return homeCard({
|
|
2388
|
+
color: 'var(--scope-rule)',
|
|
2389
|
+
title: 'User rules',
|
|
2390
|
+
when: `${claudeConfigDir}/rules`,
|
|
2391
|
+
wide: true,
|
|
2392
|
+
open: rules[0]?.id,
|
|
2393
|
+
body: `<div class="home-cols">
|
|
2394
|
+
<div><div class="home-colh">Always loaded · ${always.length}${always.length ? ` · ${esc(fmtChars(alwaysChars))}` : ''}</div>
|
|
2395
|
+
<ul class="home-rows">${list(always, (s) => esc(fmtChars(withImportsChars(s))))}</ul></div>
|
|
2396
|
+
<div><div class="home-colh">When a file matches · ${cond.length}</div>
|
|
2397
|
+
<ul class="home-rows">${list(cond, (s) => esc(globs(s)))}</ul></div>
|
|
2398
|
+
</div>`,
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
function renderHomeAgentMemory() {
|
|
2403
|
+
const idx = stackData.filter((s) => s.scope === 'agent-memory' && s.name === 'MEMORY.md' && !s.parentId);
|
|
2404
|
+
const rows = idx
|
|
2405
|
+
.map((s) => {
|
|
2406
|
+
const max = s.maxLines || 200;
|
|
2407
|
+
const pct = Math.min(100, Math.round((s.lines / max) * 100));
|
|
2408
|
+
const warn = pct >= 90 || s.bytes > (s.maxBytes || 25 * 1024);
|
|
2409
|
+
const bar = `<span class="home-mini" style="--c:var(--scope-agent-memory)"><span class="${warn ? 'warn' : ''}" style="width:${pct}%"></span></span>`;
|
|
2410
|
+
return homeRow(s, esc(`${s.lines}/${max}`), bar, s.agentName);
|
|
2411
|
+
})
|
|
2412
|
+
.join('');
|
|
2413
|
+
return homeCard({
|
|
2414
|
+
color: 'var(--scope-agent-memory)',
|
|
2415
|
+
title: 'Subagent memory',
|
|
2416
|
+
when: 'on agent start',
|
|
2417
|
+
open: idx[0]?.id,
|
|
2418
|
+
body: `<div class="home-path">${esc(claudeConfigDir)}/agent-memory/</div>${
|
|
2419
|
+
rows ? `<ul class="home-rows">${rows}</ul>` : '<div class="home-empty">No subagent keeps user memory</div>'
|
|
2420
|
+
}`,
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
function renderHomeListed() {
|
|
2425
|
+
const skills = stackData.filter((s) => s.scope === 'skill' && s.descChars != null);
|
|
2426
|
+
const agents = stackData.filter((s) => s.scope === 'agent' && s.descChars != null);
|
|
2427
|
+
const first = skills[0] || agents[0];
|
|
2428
|
+
return homeCard({
|
|
2429
|
+
color: 'var(--scope-skill)',
|
|
2430
|
+
title: 'User skills and agents',
|
|
2431
|
+
when: 'descriptions only',
|
|
2432
|
+
always: true,
|
|
2433
|
+
wide: true,
|
|
2434
|
+
open: first?.id,
|
|
2435
|
+
focus: 'skill,agent',
|
|
2436
|
+
body: `<div class="home-cols">
|
|
2437
|
+
<div><div class="home-colh">Skills · ${skills.length}${skills.length ? ` · ${esc(fmtChars(summaryData?.skillDesc?.chars || 0))}` : ''}</div>
|
|
2438
|
+
<ul class="home-rows">${homeTopRows(skills, 'var(--scope-skill)')}</ul></div>
|
|
2439
|
+
<div><div class="home-colh">Agents · ${agents.length}${agents.length ? ` · ${esc(fmtChars(summaryData?.agentDesc?.chars || 0))}` : ''}</div>
|
|
2440
|
+
<ul class="home-rows">${homeTopRows(agents, 'var(--scope-agent)')}</ul></div>
|
|
2441
|
+
</div>${
|
|
2442
|
+
hub.can('project.plugins')
|
|
2443
|
+
? '<ul class="home-rows home-plugins"><li onclick="event.stopPropagation();_openPlugins()"><span class="home-nm">Plugins list their skills and agents too. Open Marketplace</span></li></ul>'
|
|
2444
|
+
: ''
|
|
2445
|
+
}`,
|
|
2446
|
+
});
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
function _openPlugins() {
|
|
2450
|
+
hub.invoke('project.plugins');
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
function renderHomeSettings() {
|
|
2454
|
+
const set = homeData?.settings;
|
|
2455
|
+
if (!set) return '';
|
|
2456
|
+
const show = (v) => {
|
|
2457
|
+
if (v == null) return '—';
|
|
2458
|
+
if (Array.isArray(v)) return v.length ? v.join(', ') : '[]';
|
|
2459
|
+
return String(v);
|
|
2460
|
+
};
|
|
2461
|
+
const rows = [
|
|
2462
|
+
['autoMemoryEnabled', set.autoMemoryEnabled],
|
|
2463
|
+
['autoMemoryDirectory', set.autoMemoryDirectory],
|
|
2464
|
+
['claudeMdExcludes', set.claudeMdExcludes],
|
|
2465
|
+
['cleanupPeriodDays', set.cleanupPeriodDays],
|
|
2466
|
+
['DISABLE_AUTO_MEMORY', set.CLAUDE_CODE_DISABLE_AUTO_MEMORY],
|
|
2467
|
+
]
|
|
2468
|
+
.map(
|
|
2469
|
+
([k, s]) =>
|
|
2470
|
+
`<span class="home-k">${esc(k)}</span><span>${esc(show(s?.value))}<span class="home-from">${esc(s?.from || '')}</span></span>`,
|
|
2471
|
+
)
|
|
2472
|
+
.join('');
|
|
2473
|
+
return homeCard({
|
|
2474
|
+
color: 'var(--text-muted)',
|
|
2475
|
+
title: 'Memory settings',
|
|
2476
|
+
when: 'effective',
|
|
2477
|
+
file: homeData.settingsPaths.user,
|
|
2478
|
+
fileScope: 'user',
|
|
2479
|
+
body: `<div class="home-kv">${rows}</div>`,
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
const SEVERITY_PILL = { high: 'err', med: 'warn', low: 'mut' };
|
|
2484
|
+
|
|
2485
|
+
function renderHomeHealth() {
|
|
2486
|
+
const st = lastAnalysisSt;
|
|
2487
|
+
const run = st ? mergedRunView(st) : null;
|
|
2488
|
+
const latest = allAnalysisRuns(st)[0]?.run;
|
|
2489
|
+
const pending = (st?.pending || []).length;
|
|
2490
|
+
let rows;
|
|
2491
|
+
if (!run) {
|
|
2492
|
+
rows =
|
|
2493
|
+
'<li><span class="home-empty">No run yet. Analyze checks the user scope for conflicts and stale facts.</span></li>';
|
|
2494
|
+
} else {
|
|
2495
|
+
const findings = (run.result?.findings || [])
|
|
2496
|
+
.filter((f) => !isDismissed(f))
|
|
2497
|
+
.sort((a, b) => (SEV_ORDER[a.severity] ?? 3) - (SEV_ORDER[b.severity] ?? 3));
|
|
2498
|
+
rows = findings.length
|
|
2499
|
+
? findings
|
|
2500
|
+
.slice(0, 4)
|
|
2501
|
+
.map(
|
|
2502
|
+
(f) =>
|
|
2503
|
+
`<li><span class="home-pill ${SEVERITY_PILL[f.severity] || 'mut'}">${esc(f.kind || f.severity)}</span><span class="home-nm">${esc(f.title)}</span></li>`,
|
|
2504
|
+
)
|
|
2505
|
+
.join('')
|
|
2506
|
+
: '<li><span class="home-pill ok">ok</span><span class="home-nm">No open findings</span></li>';
|
|
2507
|
+
if (findings.length > 4) rows += moreRow(findings.length - 4);
|
|
2508
|
+
}
|
|
2509
|
+
const when = pending ? 'running…' : latest ? `last run ${analysisTimeAgo(latest.ts)}` : 'never run';
|
|
2510
|
+
return homeCard({
|
|
2511
|
+
color: 'var(--accent)',
|
|
2512
|
+
title: `${SPARKLE_ICON}Health · user scope`,
|
|
2513
|
+
when,
|
|
2514
|
+
full: true,
|
|
2515
|
+
health: true,
|
|
2516
|
+
body: `<ul class="home-rows">${rows}</ul>`,
|
|
2517
|
+
});
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function renderHomeRecents() {
|
|
2521
|
+
const recent = getRecentProjects()
|
|
2522
|
+
.filter((p) => p !== USER_ROW)
|
|
2523
|
+
.slice(0, 6);
|
|
2524
|
+
const chips = recent
|
|
2525
|
+
.map((p) => {
|
|
2526
|
+
const { name } = splitProjectPath(p);
|
|
2527
|
+
return `<span class="home-chip" title="${esc(p)}" onclick="_homeSwitch('${escAttrJs(p)}')"><span class="home-dot"></span>${esc(name)}</span>`;
|
|
2528
|
+
})
|
|
2529
|
+
.join('');
|
|
2530
|
+
return `<div class="home-recents"><h2>Open a project</h2><div class="home-chips">${chips}<span class="home-chip home-chip-add" onclick="_homeAddPath()">+ Add path</span></div></div>`;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
function _homeSwitch(p) {
|
|
2534
|
+
document.getElementById('projectPathInput').value = p;
|
|
2535
|
+
submitProjectPicker();
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
function _goUserHome() {
|
|
2539
|
+
if (projectData?.user) showHome();
|
|
2540
|
+
else _homeSwitch(USER_ROW);
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
function _homeAddPath() {
|
|
2544
|
+
changeProject();
|
|
2545
|
+
setPickerMode(true);
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
function renderHome() {
|
|
2549
|
+
const el = document.getElementById('homeView');
|
|
2550
|
+
if (!el || !summaryData) return;
|
|
2551
|
+
const parts = homeBudget();
|
|
2552
|
+
const known = parts.reduce((n, p) => n + p.chars, 0);
|
|
2553
|
+
const total = known || 1;
|
|
2554
|
+
const bar = parts
|
|
2555
|
+
.filter((p) => p.chars)
|
|
2556
|
+
.map(
|
|
2557
|
+
(p) =>
|
|
2558
|
+
`<span style="width:${(p.chars / total) * 100}%;background:var(--scope-${p.key})" title="${esc(p.label)}: ${p.chars.toLocaleString()} chars"></span>`,
|
|
2559
|
+
)
|
|
2560
|
+
.join('');
|
|
2561
|
+
const legend = parts
|
|
2562
|
+
.map((p) =>
|
|
2563
|
+
p.unknown
|
|
2564
|
+
? `<span class="home-unk"><i style="background:var(--scope-${p.key})"></i>${esc(p.label)}: built-in, not counted</span>`
|
|
2565
|
+
: `<span${p.chars ? '' : ' class="home-zero"'}><i style="background:var(--scope-${p.key})"></i>${esc(p.label)} <b>${esc(fmtChars(p.chars))}</b></span>`,
|
|
2566
|
+
)
|
|
2567
|
+
.join('');
|
|
2568
|
+
el.innerHTML = `<div class="home">
|
|
2569
|
+
<h1>Your memory</h1>
|
|
2570
|
+
<div class="home-budget">
|
|
2571
|
+
<div class="home-budget-head"><span class="t">Loaded into every session</span><span class="n">${esc(known.toLocaleString())} <small>chars · from files on disk</small></span></div>
|
|
2572
|
+
<div class="home-bar">${bar}</div>
|
|
2573
|
+
<div class="home-legend">${legend}</div>
|
|
2574
|
+
</div>
|
|
2575
|
+
<div class="home-grid">
|
|
2576
|
+
${renderHomePolicy()}
|
|
2577
|
+
${renderHomeUserMd()}
|
|
2578
|
+
${renderHomeStyle()}
|
|
2579
|
+
${renderHomeListed()}
|
|
2580
|
+
${renderHomeSettings()}
|
|
2581
|
+
${renderHomeRules()}
|
|
2582
|
+
${renderHomeAgentMemory()}
|
|
2583
|
+
${renderHomeHealth()}
|
|
2584
|
+
</div>
|
|
2585
|
+
${renderHomeRecents()}
|
|
2586
|
+
</div>`;
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
// #endregion HOME
|
|
2590
|
+
|
|
2041
2591
|
// #region DATA
|
|
2042
2592
|
|
|
2043
2593
|
async function loadData() {
|
|
2044
2594
|
try {
|
|
2045
|
-
[stackData, summaryData] = await Promise.all([
|
|
2595
|
+
[stackData, summaryData, homeData] = await Promise.all([
|
|
2596
|
+
fetchJSON('/api/stack'),
|
|
2597
|
+
fetchJSON('/api/summary'),
|
|
2598
|
+
projectData?.user ? fetchJSON('/api/home') : null,
|
|
2599
|
+
]);
|
|
2046
2600
|
invalidateTreeIndex();
|
|
2047
2601
|
renderBudget(); // refreshes footprintIds, which renderTree's highlight reads
|
|
2048
2602
|
renderTree();
|
|
@@ -2055,6 +2609,7 @@ async function loadData() {
|
|
|
2055
2609
|
}
|
|
2056
2610
|
if (selectedFileId) renderTree();
|
|
2057
2611
|
renderPreview();
|
|
2612
|
+
applyLayout();
|
|
2058
2613
|
} catch (err) {
|
|
2059
2614
|
showToast(`Failed to load: ${err.message}`, 'error');
|
|
2060
2615
|
}
|
|
@@ -2102,6 +2657,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
2102
2657
|
openHealthView();
|
|
2103
2658
|
return;
|
|
2104
2659
|
}
|
|
2660
|
+
if (e.key === 'Escape' && projectData?.user && !homeOpen) {
|
|
2661
|
+
e.preventDefault();
|
|
2662
|
+
showHome();
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2105
2665
|
if (e.key === 't') toggleTheme();
|
|
2106
2666
|
if (e.key === 'r') refreshData();
|
|
2107
2667
|
if (e.key === '?') {
|
|
@@ -2112,6 +2672,14 @@ document.addEventListener('keydown', (e) => {
|
|
|
2112
2672
|
e.preventDefault();
|
|
2113
2673
|
changeProject();
|
|
2114
2674
|
}
|
|
2675
|
+
if (homeOpen) {
|
|
2676
|
+
const card = e.target.closest?.('.home-card[onclick]');
|
|
2677
|
+
if (card && (e.key === 'Enter' || e.key === ' ')) {
|
|
2678
|
+
e.preventDefault();
|
|
2679
|
+
card.click();
|
|
2680
|
+
}
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2115
2683
|
// Skip when modifiers are held (e.g. hub's Ctrl+Alt+Arrow app switching).
|
|
2116
2684
|
const plain = !e.ctrlKey && !e.altKey && !e.metaKey;
|
|
2117
2685
|
if (plain && (e.key === 'j' || e.key === 'ArrowDown')) {
|
|
@@ -2131,8 +2699,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
2131
2699
|
expandOrDescend();
|
|
2132
2700
|
}
|
|
2133
2701
|
if (e.key === 'Enter' && selectedFileId) renderPreview();
|
|
2134
|
-
if (e.key === 'e' && selectedFileId) {
|
|
2135
|
-
const s = stackData.find((x) => x.id === selectedFileId);
|
|
2702
|
+
if (e.key === 'e' && (selectedFileId || previewPath)) {
|
|
2703
|
+
const s = stackData.find((x) => x.id === selectedFileId) || previewPath;
|
|
2136
2704
|
if (s) openInEditor(s.path);
|
|
2137
2705
|
}
|
|
2138
2706
|
});
|
|
@@ -2173,6 +2741,7 @@ hub.onThemes((themes) => {
|
|
|
2173
2741
|
// Set synchronously when the hub pushes a project, so the DOMContentLoaded restore below can't
|
|
2174
2742
|
// let a stale localStorage recent win the race against the hub's choice.
|
|
2175
2743
|
let hubProjectPath = null;
|
|
2744
|
+
let hubProjectReplayed = false;
|
|
2176
2745
|
// Shared with the DOMContentLoaded restore so a boot restore and a hub push never apply the same project twice.
|
|
2177
2746
|
let lastAppliedProject = null;
|
|
2178
2747
|
|
|
@@ -2183,22 +2752,35 @@ let lastAppliedProject = null;
|
|
|
2183
2752
|
if (lastAppliedProject === dirPath) return;
|
|
2184
2753
|
lastAppliedProject = dirPath;
|
|
2185
2754
|
try {
|
|
2186
|
-
await putProject(dirPath);
|
|
2755
|
+
const info = await putProject(dirPath);
|
|
2187
2756
|
// The hub does not persist its own scope, so a hub-pushed project must become the recent
|
|
2188
2757
|
// head here: after a hard refresh the boot path PUTs getRecentProjects()[0], which would
|
|
2189
2758
|
// otherwise clobber the server's hub-scoped project with the previous one.
|
|
2190
2759
|
addRecentProject(dirPath);
|
|
2191
|
-
await
|
|
2760
|
+
await loadProject(info);
|
|
2761
|
+
await loadData();
|
|
2192
2762
|
} catch (err) {
|
|
2193
2763
|
lastAppliedProject = null;
|
|
2194
2764
|
throw err;
|
|
2195
2765
|
}
|
|
2196
2766
|
}
|
|
2197
2767
|
|
|
2198
|
-
hub
|
|
2768
|
+
// The hub replays project.changed after its welcome and before the first hub:active; a live
|
|
2769
|
+
// change comes after it. Only a live change takes the app off a remembered user scope.
|
|
2770
|
+
let hubActiveSeen = false;
|
|
2771
|
+
hub.onActive(() => {
|
|
2772
|
+
hubActiveSeen = true;
|
|
2773
|
+
});
|
|
2774
|
+
|
|
2775
|
+
hub.subscribe('project.changed', async (p) => {
|
|
2199
2776
|
const dirPath = typeof p?.project === 'string' && p.project ? p.project : null;
|
|
2200
2777
|
if (!dirPath) return;
|
|
2201
2778
|
hubProjectPath = dirPath;
|
|
2779
|
+
hubProjectReplayed = !hubActiveSeen;
|
|
2780
|
+
if (hubProjectReplayed && (await isUserScopeRemembered())) {
|
|
2781
|
+
if (hubProjectPath === dirPath) hubProjectPath = null;
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2202
2784
|
applyProject(dirPath).catch((err) => console.warn('project.changed failed:', err.message));
|
|
2203
2785
|
});
|
|
2204
2786
|
|
|
@@ -2211,6 +2793,11 @@ let lastAppliedProject = null;
|
|
|
2211
2793
|
|
|
2212
2794
|
// #endregion HUB_INTEGRATION
|
|
2213
2795
|
|
|
2796
|
+
// The home's Marketplace link depends on hub.can, which only knows the hub's actions after welcome.
|
|
2797
|
+
hub.onStatus(() => {
|
|
2798
|
+
if (homeOpen) renderHome();
|
|
2799
|
+
});
|
|
2800
|
+
|
|
2214
2801
|
// #region RESIZE
|
|
2215
2802
|
|
|
2216
2803
|
function initResize() {
|
|
@@ -2270,7 +2857,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
|
2270
2857
|
// A hub-pushed project outranks the localStorage recent: ClaudeHub.connect() is fire-and-forget at script
|
|
2271
2858
|
// eval, so project.changed can land before or during this block.
|
|
2272
2859
|
await projectInfoPromise;
|
|
2273
|
-
if (!desiredProject)
|
|
2860
|
+
if (!desiredProject) {
|
|
2861
|
+
const hubPick = hubProjectReplayed && userScopeIsHead() ? null : hubProjectPath;
|
|
2862
|
+
desiredProject = hubPick || getRecentProjects()[0] || USER_ROW;
|
|
2863
|
+
}
|
|
2274
2864
|
if (desiredProject) {
|
|
2275
2865
|
// Set before the PUT: the hub's push can land while it is in flight.
|
|
2276
2866
|
lastAppliedProject = desiredProject;
|
|
@@ -2301,12 +2891,13 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
|
2301
2891
|
else showToast('Failed to connect to server', 'error');
|
|
2302
2892
|
}
|
|
2303
2893
|
}
|
|
2304
|
-
if (projectData) addRecentProject(projectData
|
|
2894
|
+
if (projectData) addRecentProject(projectKey(projectData));
|
|
2305
2895
|
await loadData();
|
|
2306
2896
|
// Restore file selection from hash
|
|
2307
2897
|
const hash = decodeURIComponent(location.hash.slice(1));
|
|
2308
2898
|
const hashItem = hash && stackData.find((s) => s.id === hash);
|
|
2309
2899
|
if (hashItem) {
|
|
2900
|
+
leaveHome();
|
|
2310
2901
|
selectedFileId = hash;
|
|
2311
2902
|
expandGroupForItem(hashItem);
|
|
2312
2903
|
expandItemAncestors(hashItem);
|