claude-code-memory-explorer 0.2.5 → 0.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/README.md CHANGED
@@ -62,6 +62,7 @@ Claude Code stores memory across multiple locations:
62
62
  | CLAUDE.local.md | `./CLAUDE.local.md` | Always |
63
63
  | Rules | `.claude/rules/*.md` | Conditional (path-scoped) |
64
64
  | Auto memory | `~/.claude/projects/<encoded>/memory/` | Startup (MEMORY.md) or on-demand |
65
+ | Agent memory | `~/.claude/agent-memory/<agent>/`, `./.claude/agent-memory/<agent>/`, `./.claude/agent-memory-local/<agent>/` | Startup (MEMORY.md) or on-demand |
65
66
 
66
67
  The dashboard:
67
68
  1. **Scans** all memory locations — `~/.claude/`, ancestor directories, project rules, auto memory
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-memory-explorer",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Memory explorer dashboard for Claude Code",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -243,14 +243,14 @@ function bindModalKeys(inputId, modalId, submitFn) {
243
243
 
244
244
  // #region RENDER_TREE
245
245
 
246
- const SCOPE_ORDER = ['policy', 'user', 'user-memory', 'project', 'rule', 'memory'];
246
+ const SCOPE_ORDER = ['policy', 'user', 'project', 'rule', 'memory', 'agent-memory'];
247
247
  const SCOPE_LABELS = {
248
248
  policy: 'Managed Policy',
249
- 'user-memory': 'User Memory',
250
249
  user: 'User',
251
250
  project: 'Project',
252
251
  rule: 'Rules',
253
252
  memory: 'Auto Memory',
253
+ 'agent-memory': 'Agent Memory',
254
254
  };
255
255
  const LOAD_ICONS = {
256
256
  always: '\u25CF',
@@ -317,7 +317,8 @@ function renderTree() {
317
317
  const meta = `${item.lines}L`;
318
318
  const isConditional = item.load === 'conditional' || item.load === 'ondemand';
319
319
  const pad = indent ? ` style="padding-left:${12 + indent * 16}px"` : '';
320
- let h = `<div class="tree-item${sel}${indent ? ' tree-child' : ''}${isConditional ? ' tree-conditional' : ''}" data-id="${esc(item.id)}" title="${esc(item.path)}" onclick="selectFile('${escJs(item.id)}')"${pad}>`;
320
+ const muted = item.scope === 'agent-memory' ? ' tree-agent-item' : '';
321
+ let h = `<div class="tree-item${sel}${indent ? ' tree-child' : ''}${isConditional ? ' tree-conditional' : ''}${muted}" data-id="${esc(item.id)}" title="${esc(item.path)}" onclick="selectFile('${escJs(item.id)}')"${pad}>`;
321
322
  h += `<span class="load-icon" title="${loadTitle}" style="color:var(--scope-${item.scope})">${loadIcon}</span>`;
322
323
  h += `<span class="file-name">${esc(item.name)}</span>`;
323
324
  h += `<span class="file-meta">${meta}</span>`;
@@ -334,12 +335,67 @@ function renderTree() {
334
335
  const items = groups[scope];
335
336
  if (!items) continue;
336
337
  const label = SCOPE_LABELS[scope] || scope;
337
- html += `<div class="tree-group-header"><span class="scope-dot" style="color:var(--scope-${scope})">\u25CF</span> ${esc(label)} <span style="opacity:0.5">${items.length}</span></div>`;
338
- for (const item of items) html += renderItem(item, 0);
338
+ const collapsible = scope === 'agent-memory';
339
+ const collapsed = collapsible && isGroupCollapsed(scope);
340
+ const chevron = collapsible ? `<span class="group-chevron">${collapsed ? '\u25B8' : '\u25BE'}</span>` : '';
341
+ const headerClass = collapsible ? 'tree-group-header tree-group-clickable' : 'tree-group-header';
342
+ const onclick = collapsible ? ` onclick="_toggleGroup('${escJs(scope)}')"` : '';
343
+ html += `<div class="${headerClass}" data-scope="${esc(scope)}"${onclick}>${chevron}<span class="scope-dot" style="color:var(--scope-${scope})">\u25CF</span> ${esc(label)} <span style="opacity:0.5">${items.length}</span></div>`;
344
+ if (collapsed) continue;
345
+ if (scope === 'agent-memory') {
346
+ const sorted = [...items].sort((a, b) => {
347
+ const so = ['user', 'project', 'local'];
348
+ return (
349
+ so.indexOf(a.agentScope) - so.indexOf(b.agentScope) ||
350
+ a.agentName.localeCompare(b.agentName) ||
351
+ (a.name === 'MEMORY.md' ? -1 : 1)
352
+ );
353
+ });
354
+ let lastKey = null;
355
+ for (const item of sorted) {
356
+ const key = `${item.agentScope}/${item.agentName}`;
357
+ if (key !== lastKey) {
358
+ html += `<div class="tree-subgroup-header" title="${esc(item.agentScope)} scope"><span class="agent-name">${esc(item.agentName)}</span><span class="agent-scope-tag">${esc(item.agentScope)}</span></div>`;
359
+ lastKey = key;
360
+ }
361
+ html += renderItem(item, 1);
362
+ }
363
+ } else {
364
+ for (const item of items) html += renderItem(item, 0);
365
+ }
339
366
  }
340
367
  container.innerHTML = html;
341
368
  }
342
369
 
370
+ const COLLAPSED_DEFAULTS = ['agent-memory'];
371
+ function getCollapsedGroups() {
372
+ try {
373
+ const stored = localStorage.getItem('collapsedGroups');
374
+ if (stored !== null) return new Set(JSON.parse(stored));
375
+ } catch {}
376
+ return new Set(COLLAPSED_DEFAULTS);
377
+ }
378
+ function isGroupCollapsed(scope) {
379
+ return getCollapsedGroups().has(scope);
380
+ }
381
+ function setGroupCollapsed(scope, collapsed) {
382
+ const set = getCollapsedGroups();
383
+ if (collapsed) set.add(scope);
384
+ else set.delete(scope);
385
+ localStorage.setItem('collapsedGroups', JSON.stringify([...set]));
386
+ }
387
+ function _toggleGroup(scope) {
388
+ setGroupCollapsed(scope, !isGroupCollapsed(scope));
389
+ renderTree();
390
+ }
391
+ function getItemScope(item) {
392
+ return item?.parentId ? stackData.find((s) => s.id === item.parentId)?.scope : item?.scope;
393
+ }
394
+ function expandGroupForItem(item) {
395
+ const scope = getItemScope(item);
396
+ if (scope && isGroupCollapsed(scope)) setGroupCollapsed(scope, false);
397
+ }
398
+
343
399
  function pushFileState(id) {
344
400
  const url = id ? `#${encodeURIComponent(id)}` : location.pathname;
345
401
  history.pushState({ fileId: id }, '', url);
@@ -368,7 +424,9 @@ function navigateTree(direction) {
368
424
  if (idx < 0) idx = navOrder.length - 1;
369
425
  if (idx >= navOrder.length) idx = 0;
370
426
  }
371
- selectedFileId = navOrder[idx].id;
427
+ const target = navOrder[idx];
428
+ selectedFileId = target.id;
429
+ expandGroupForItem(target);
372
430
  pushFileState(selectedFileId);
373
431
  renderTree();
374
432
  renderPreview();
@@ -381,8 +439,7 @@ function navigateGroup(direction) {
381
439
  if (!activeScopes.length) return;
382
440
 
383
441
  const current = stackData.find((s) => s.id === selectedFileId);
384
- const currentScope = current?.parentId ? stackData.find((s) => s.id === current.parentId)?.scope : current?.scope;
385
- let scopeIdx = activeScopes.indexOf(currentScope);
442
+ let scopeIdx = activeScopes.indexOf(getItemScope(current));
386
443
  if (scopeIdx === -1) {
387
444
  scopeIdx = direction > 0 ? 0 : activeScopes.length - 1;
388
445
  } else {
@@ -390,7 +447,9 @@ function navigateGroup(direction) {
390
447
  if (scopeIdx < 0) scopeIdx = activeScopes.length - 1;
391
448
  if (scopeIdx >= activeScopes.length) scopeIdx = 0;
392
449
  }
393
- selectedFileId = groups[activeScopes[scopeIdx]][0].id;
450
+ const target = groups[activeScopes[scopeIdx]][0];
451
+ selectedFileId = target.id;
452
+ expandGroupForItem(target);
394
453
  pushFileState(selectedFileId);
395
454
  renderTree();
396
455
  renderPreview();
@@ -401,6 +460,10 @@ function navigateGroup(direction) {
401
460
 
402
461
  // #region MEMORY_INDEX
403
462
 
463
+ function isMemoryIndex(source) {
464
+ return (source.scope === 'memory' || source.scope === 'agent-memory') && source.name === 'MEMORY.md';
465
+ }
466
+
404
467
  function parseMemoryIndex(content) {
405
468
  const entries = [];
406
469
  for (const line of content.split('\n')) {
@@ -458,6 +521,12 @@ async function renderPreview() {
458
521
  html += `<span class="scope-badge scope-${source.scope}">${esc(source.scope)}</span>`;
459
522
  html += `<span class="file-path">${esc(source.name)}</span>`;
460
523
  html += '<div class="preview-actions">';
524
+ if (isMemoryIndex(source)) {
525
+ const broken = (source.unresolvedImports || []).length;
526
+ if (broken) {
527
+ html += `<button class="action-btn small" onclick="_cleanupOrphans('${escJs(source.path)}')" title="Cleanup ${broken} orphaned refs"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M12 11v6"/></svg><span style="margin-left:3px">${broken}</span></button>`;
528
+ }
529
+ }
461
530
  html += `<button class="action-btn small" onclick="openInEditor('${escJs(source.path)}')" title="Open in VS Code"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M17.583 2.207a1.1 1.1 0 0 1 1.541.033l2.636 2.636a1.1 1.1 0 0 1 .033 1.541L10.68 17.53a1.1 1.1 0 0 1-.345.247l-4.56 1.903a.55.55 0 0 1-.725-.725l1.903-4.56a1.1 1.1 0 0 1 .247-.345zm.902 1.87-8.794 8.793-.946 2.268 2.268-.946 8.794-8.793z"/></svg></button>`;
462
531
  html += `<button class="action-btn small" onclick="_confirmDeleteFile('${escJs(source.path)}','${escJs(source.name)}')" title="Delete file"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>`;
463
532
  html += '</div></div>';
@@ -474,10 +543,13 @@ async function renderPreview() {
474
543
  }
475
544
  html += '</div>';
476
545
 
477
- // Imports — only show children (files that have this source as parent)
546
+ const content = fileData.content || '';
547
+ const memoryEntries = isMemoryIndex(source) ? parseMemoryIndex(content) : [];
548
+ const showMemoryTable = memoryEntries.length > 0;
549
+
478
550
  const children = stackData.filter((s) => s.parentId === source.id);
479
551
  const unresolved = source.unresolvedImports || [];
480
- if (children.length || unresolved.length) {
552
+ if (!showMemoryTable && (children.length || unresolved.length)) {
481
553
  html += '<div class="preview-imports">';
482
554
  for (const child of children) {
483
555
  html += `<a class="import-link" href="#" onclick="selectFile('${escJs(child.id)}');return false" title="${esc(child.path)}">${esc(child.name)}</a>`;
@@ -493,22 +565,19 @@ async function renderPreview() {
493
565
  // File path
494
566
  html += `<div class="preview-filepath">${esc(source.path)}</div>`;
495
567
 
496
- // Content — with cutoff line for auto memory startup files
497
- const content = fileData.content || '';
498
568
  const hl = (text) => {
499
569
  const { text: processed, placeholders } = linkifyImports(text, source.id);
500
570
  return restorePlaceholders(highlightSource(processed, source.name), placeholders);
501
571
  };
502
572
 
503
- // Memory index view — render structured table for MEMORY.md index files
504
- if ((source.scope === 'memory' || source.scope === 'user-memory') && source.name === 'MEMORY.md') {
505
- const entries = parseMemoryIndex(content);
506
- if (entries.length) {
507
- html += renderMemoryIndexTable(entries);
508
- }
573
+ if (showMemoryTable) {
574
+ html += renderMemoryIndexTable(memoryEntries);
575
+ html += `<pre class="preview-code"><code>${hl(content)}</code></pre>`;
576
+ panel.innerHTML = html;
577
+ return;
509
578
  }
510
579
 
511
- if ((source.scope === 'memory' || source.scope === 'user-memory') && source.load === 'startup' && source.maxLines) {
580
+ if ((source.scope === 'memory' || source.scope === 'agent-memory') && source.load === 'startup' && source.maxLines) {
512
581
  const lines = content.split('\n');
513
582
  const cutoff = source.maxLines;
514
583
  if (lines.length > cutoff) {
@@ -574,6 +643,54 @@ async function _submitDeleteFile() {
574
643
  }
575
644
  }
576
645
 
646
+ function _cleanupOrphans(filePath) {
647
+ const source = stackData.find((s) => s.path === filePath);
648
+ const broken = source?.unresolvedImports || [];
649
+ if (!broken.length) {
650
+ showToast('No orphaned refs', 'info');
651
+ return;
652
+ }
653
+ const modal = document.getElementById('cleanupOrphansModal');
654
+ modal.dataset.filePath = filePath;
655
+ document.getElementById('cleanupOrphansCount').textContent = broken.length;
656
+ document.getElementById('cleanupOrphansList').innerHTML = broken.map((r) => `<li>${esc(r)}</li>`).join('');
657
+ modal.classList.add('open');
658
+ }
659
+
660
+ async function _submitCleanupOrphans() {
661
+ const modal = document.getElementById('cleanupOrphansModal');
662
+ const filePath = modal.dataset.filePath;
663
+ if (!filePath) return;
664
+ const btn = document.getElementById('cleanupOrphansSubmit');
665
+ btn.disabled = true;
666
+ btn.textContent = 'Cleaning...';
667
+ try {
668
+ const res = await fetch('/api/memory/cleanup-orphans', {
669
+ method: 'POST',
670
+ headers: { 'Content-Type': 'application/json' },
671
+ body: JSON.stringify({ path: filePath }),
672
+ });
673
+ if (!res.ok) {
674
+ const msg = await res
675
+ .json()
676
+ .then((e) => e.error)
677
+ .catch(() => `Cleanup failed (${res.status})`);
678
+ showToast(msg, 'error');
679
+ return;
680
+ }
681
+ const data = await res.json();
682
+ closeModal('cleanupOrphansModal');
683
+ delete modal.dataset.filePath;
684
+ await loadData();
685
+ showToast(`Removed ${data.removed.length} orphaned ref${data.removed.length === 1 ? '' : 's'}`, 'success');
686
+ } catch (err) {
687
+ showToast(err.message, 'error');
688
+ } finally {
689
+ btn.disabled = false;
690
+ btn.textContent = 'Remove';
691
+ }
692
+ }
693
+
577
694
  async function openInEditor(filePath) {
578
695
  showToast('Opening...', 'info');
579
696
  try {
package/public/index.html CHANGED
@@ -34,12 +34,12 @@
34
34
  <button class="topbar-btn" id="refreshBtn" title="Refresh (r)" onclick="refreshData()">
35
35
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
36
36
  </button>
37
- <button class="topbar-btn" id="helpBtn" title="Keyboard shortcuts (?)" onclick="toggleHelpModal()">
38
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="17" r="0.5" fill="currentColor"/></svg>
39
- </button>
40
37
  <button class="topbar-btn" id="themeBtn" title="Toggle theme (t)" onclick="toggleTheme()">
41
38
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
42
39
  </button>
40
+ <button class="topbar-btn" id="helpBtn" title="Keyboard shortcuts (?)" onclick="toggleHelpModal()">
41
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="17" r="0.5" fill="currentColor"/></svg>
42
+ </button>
43
43
  <a class="topbar-btn" href="https://github.com/NikiforovAll/claude-code-memory" target="_blank" rel="noopener" title="GitHub">
44
44
  <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
45
45
  </a>
@@ -122,6 +122,24 @@
122
122
  </div>
123
123
  </div>
124
124
 
125
+ <!-- Cleanup orphans modal -->
126
+ <div class="modal-overlay" id="cleanupOrphansModal" onclick="if(event.target===this)closeModal('cleanupOrphansModal')">
127
+ <div class="modal">
128
+ <div class="modal-header">
129
+ <h3>Cleanup orphaned refs?</h3>
130
+ <button class="modal-close" onclick="closeModal('cleanupOrphansModal')">&#10005;</button>
131
+ </div>
132
+ <div class="modal-body">
133
+ <p class="modal-desc">Remove <strong id="cleanupOrphansCount"></strong> bullet line(s) from MEMORY.md that point to files missing on disk.</p>
134
+ <ul id="cleanupOrphansList" class="modal-orphan-list"></ul>
135
+ <div class="modal-actions">
136
+ <button class="action-btn" onclick="closeModal('cleanupOrphansModal')">Cancel</button>
137
+ <button class="action-btn danger" id="cleanupOrphansSubmit" onclick="_submitCleanupOrphans()">Remove</button>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ </div>
142
+
125
143
  <!-- Help modal -->
126
144
  <div class="modal-overlay" id="helpModal" onclick="if(event.target===this)toggleHelpModal()">
127
145
  <div class="modal">
package/public/style.css CHANGED
@@ -24,7 +24,7 @@
24
24
  --scope-local: #8a9bb5;
25
25
  --scope-rule: #a89bc4;
26
26
  --scope-memory: #c4a06a;
27
- --scope-user-memory: #9ab87a;
27
+ --scope-agent-memory: #8aa8a3;
28
28
  --scope-policy: #b58a8a;
29
29
  --font-mono: 'IBM Plex Mono', monospace;
30
30
  --font-serif: 'Playfair Display', serif;
@@ -261,6 +261,49 @@ body {
261
261
  align-items: center;
262
262
  gap: 6px;
263
263
  }
264
+ .tree-subgroup-header {
265
+ padding: 6px 12px 2px 20px;
266
+ font-size: 11px;
267
+ font-family: var(--font-mono);
268
+ color: var(--text-muted);
269
+ display: flex;
270
+ align-items: center;
271
+ gap: 8px;
272
+ opacity: 0.85;
273
+ }
274
+ .tree-subgroup-header .agent-name {
275
+ color: var(--text-tertiary);
276
+ }
277
+ .tree-subgroup-header .agent-scope-tag {
278
+ font-size: 9px;
279
+ font-weight: 600;
280
+ text-transform: uppercase;
281
+ letter-spacing: 0.4px;
282
+ padding: 1px 5px;
283
+ border-radius: 3px;
284
+ background: rgba(138, 168, 163, 0.12);
285
+ color: var(--scope-agent-memory);
286
+ opacity: 0.85;
287
+ }
288
+ .tree-group-clickable {
289
+ cursor: pointer;
290
+ user-select: none;
291
+ }
292
+ .tree-group-clickable:hover {
293
+ color: var(--text-secondary);
294
+ }
295
+ .group-chevron {
296
+ display: inline-block;
297
+ width: 10px;
298
+ font-size: 9px;
299
+ color: var(--text-muted);
300
+ }
301
+ .tree-agent-item .file-name {
302
+ opacity: 0.7;
303
+ }
304
+ body.light .tree-agent-item .file-name {
305
+ opacity: 0.8;
306
+ }
264
307
  .tree-item {
265
308
  padding: 5px 12px 5px 24px;
266
309
  cursor: pointer;
@@ -411,20 +454,27 @@ body.light .tree-conditional .file-name {
411
454
  }
412
455
  .memory-index-row {
413
456
  display: grid;
414
- grid-template-columns: 72px 160px 1fr;
457
+ grid-template-columns: 72px max-content 1fr;
415
458
  align-items: baseline;
416
459
  gap: 8px;
417
460
  padding: 3px 0;
418
461
  }
419
462
  .memory-index-type {
420
- flex-shrink: 0;
463
+ text-align: center;
464
+ justify-self: center;
465
+ }
466
+ .modal-orphan-list {
467
+ margin: 0;
468
+ padding: 8px 12px 8px 28px;
469
+ list-style: disc;
470
+ font-family: var(--font-mono);
471
+ font-size: 11px;
472
+ color: var(--text-muted);
421
473
  }
422
474
  .memory-index-name {
423
475
  font-size: 11px;
424
476
  font-family: var(--font-mono);
425
477
  white-space: nowrap;
426
- overflow: hidden;
427
- text-overflow: ellipsis;
428
478
  }
429
479
  .memory-index-desc {
430
480
  font-size: 10px;
@@ -484,9 +534,9 @@ body.light .tree-conditional .file-name {
484
534
  background: rgba(196, 149, 106, 0.18);
485
535
  color: var(--scope-memory);
486
536
  }
487
- .scope-user-memory {
488
- background: rgba(154, 184, 122, 0.18);
489
- color: var(--scope-user-memory);
537
+ .scope-agent-memory {
538
+ background: rgba(138, 168, 163, 0.18);
539
+ color: var(--scope-agent-memory);
490
540
  }
491
541
  .scope-policy {
492
542
  background: rgba(181, 138, 138, 0.18);
package/server.js CHANGED
@@ -52,6 +52,10 @@ function clearCache() {
52
52
 
53
53
  // #region FILESYSTEM_SCANNING
54
54
 
55
+ const CLAUDE_MD_VARIANTS = ['CLAUDE.md', '.claude/CLAUDE.md', 'CLAUDE.local.md', '.claude/CLAUDE.local.md'];
56
+
57
+ const slug = s => s.replace(/[^a-zA-Z0-9]/g, '-');
58
+
55
59
  const MANAGED_POLICY_PATHS = process.platform === 'win32'
56
60
  ? [path.join(process.env.ProgramFiles || 'C:\\Program Files', 'ClaudeCode', 'CLAUDE.md')]
57
61
  : process.platform === 'darwin'
@@ -160,6 +164,11 @@ function spreadImports(filePath, content) {
160
164
  return { imports: resolved, softImports: resolvedSoft, unresolvedImports: unresolved };
161
165
  }
162
166
 
167
+ function hasPathsFilter(frontmatter) {
168
+ if (!frontmatter || !frontmatter.paths) return false;
169
+ return Array.isArray(frontmatter.paths) ? frontmatter.paths.length > 0 : true;
170
+ }
171
+
163
172
  function discoverMemorySources(projectPath) {
164
173
  const sources = [];
165
174
 
@@ -202,7 +211,7 @@ function discoverMemorySources(projectPath) {
202
211
  id: `user-rule-${path.basename(file, '.md')}`,
203
212
  name: path.basename(file),
204
213
  scope: 'rule',
205
- load: 'conditional',
214
+ load: hasPathsFilter(info.frontmatter) ? 'conditional' : 'always',
206
215
  ...info,
207
216
  ruleSource: 'user',
208
217
  ...spreadImports(info.path, info.content),
@@ -214,16 +223,16 @@ function discoverMemorySources(projectPath) {
214
223
  const ancestors = getAncestorDirs(projectPath);
215
224
  const seenPaths = new Set(sources.map(s => s.path));
216
225
  for (const dir of ancestors) {
217
- for (const name of ['CLAUDE.md', '.claude/CLAUDE.md']) {
218
- const filePath = path.join(dir, name);
226
+ const isProjectRoot = path.resolve(dir) === path.resolve(projectPath);
227
+ for (const rel of CLAUDE_MD_VARIANTS) {
228
+ const filePath = path.join(dir, rel);
219
229
  if (seenPaths.has(filePath)) continue;
220
230
  const info = fileInfo(filePath);
221
231
  if (!info) continue;
222
232
  seenPaths.add(filePath);
223
- const isProjectRoot = path.resolve(dir) === path.resolve(projectPath);
224
233
  sources.push({
225
- id: `project-claude-md-${dir.replace(/[^a-zA-Z0-9]/g, '-')}`,
226
- name: name.includes('/') ? '.claude/CLAUDE.md' : 'CLAUDE.md',
234
+ id: `project-claude-md-${slug(rel)}-${slug(dir)}`,
235
+ name: rel,
227
236
  scope: 'project',
228
237
  load: 'always',
229
238
  ...info,
@@ -231,23 +240,6 @@ function discoverMemorySources(projectPath) {
231
240
  isProjectRoot,
232
241
  ...spreadImports(info.path, info.content),
233
242
  });
234
- break;
235
- }
236
-
237
- const localPath = path.join(dir, 'CLAUDE.local.md');
238
- if (seenPaths.has(localPath)) continue;
239
- const localInfo = fileInfo(localPath);
240
- if (localInfo) {
241
- seenPaths.add(localPath);
242
- sources.push({
243
- id: `local-claude-md-${dir.replace(/[^a-zA-Z0-9]/g, '-')}`,
244
- name: 'CLAUDE.local.md',
245
- scope: 'project',
246
- load: 'always',
247
- ...localInfo,
248
- dir,
249
- ...spreadImports(localInfo.path, localInfo.content),
250
- });
251
243
  }
252
244
  }
253
245
 
@@ -260,7 +252,7 @@ function discoverMemorySources(projectPath) {
260
252
  for (const entry of entries) {
261
253
  if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
262
254
  const subdir = path.join(dir, entry.name);
263
- for (const name of ['CLAUDE.md', '.claude/CLAUDE.md']) {
255
+ for (const name of CLAUDE_MD_VARIANTS) {
264
256
  const filePath = path.join(subdir, name);
265
257
  if (seenPaths.has(filePath)) continue;
266
258
  const info = fileInfo(filePath);
@@ -268,8 +260,8 @@ function discoverMemorySources(projectPath) {
268
260
  seenPaths.add(filePath);
269
261
  const rel = path.relative(projectPath, subdir).replace(/\\/g, '/');
270
262
  sources.push({
271
- id: `project-claude-md-${subdir.replace(/[^a-zA-Z0-9]/g, '-')}`,
272
- name: `${rel}/${name.includes('/') ? '.claude/CLAUDE.md' : 'CLAUDE.md'}`,
263
+ id: `project-claude-md-${slug(name)}-${slug(subdir)}`,
264
+ name: `${rel}/${name}`,
273
265
  scope: 'project',
274
266
  load: 'tree',
275
267
  ...info,
@@ -293,7 +285,7 @@ function discoverMemorySources(projectPath) {
293
285
  id: `project-rule-${path.basename(file, '.md')}`,
294
286
  name: path.basename(file),
295
287
  scope: 'rule',
296
- load: 'conditional',
288
+ load: hasPathsFilter(info.frontmatter) ? 'conditional' : 'always',
297
289
  ...info,
298
290
  ruleSource: 'project',
299
291
  ...spreadImports(info.path, info.content),
@@ -301,67 +293,61 @@ function discoverMemorySources(projectPath) {
301
293
  }
302
294
  }
303
295
 
304
- // 6. Auto memory
305
- const memoryDir = findMemoryDir(projectPath);
306
- if (memoryDir && fs.existsSync(memoryDir)) {
307
- const memoryMd = path.join(memoryDir, 'MEMORY.md');
296
+ function pushMemoryDir(dir, scope, idPrefix, extraFields = {}) {
297
+ const memoryMd = path.join(dir, 'MEMORY.md');
308
298
  const memInfo = fileInfo(memoryMd);
309
299
  if (memInfo) {
310
300
  sources.push({
311
- id: 'memory-index',
301
+ id: `${idPrefix}-index`,
312
302
  name: 'MEMORY.md',
313
- scope: 'memory',
303
+ scope,
314
304
  load: 'startup',
305
+ ...extraFields,
315
306
  ...memInfo,
316
307
  maxLines: 200,
317
308
  maxBytes: 25 * 1024,
318
309
  ...spreadImports(memInfo.path, memInfo.content),
319
310
  });
320
311
  }
321
- for (const file of findMdFiles(memoryDir)) {
312
+ for (const file of findMdFiles(dir)) {
322
313
  if (path.basename(file) === 'MEMORY.md') continue;
323
314
  const info = fileInfo(file);
324
315
  if (!info) continue;
325
316
  sources.push({
326
- id: `memory-${path.basename(file, '.md')}`,
317
+ id: `${idPrefix}-${path.basename(file, '.md')}`,
327
318
  name: path.basename(file),
328
- scope: 'memory',
319
+ scope,
329
320
  load: 'ondemand',
321
+ ...extraFields,
330
322
  ...info,
331
323
  ...spreadImports(info.path, info.content),
332
324
  });
333
325
  }
334
326
  }
335
327
 
336
- // 7. User-level memory (~/.claude/memory/)
337
- const userMemoryDir = path.join(os.homedir(), '.claude', 'memory');
338
- if (fs.existsSync(userMemoryDir)) {
339
- const userMemoryMd = path.join(userMemoryDir, 'MEMORY.md');
340
- const userMemInfo = fileInfo(userMemoryMd);
341
- if (userMemInfo) {
342
- sources.push({
343
- id: 'user-memory-index',
344
- name: 'MEMORY.md',
345
- scope: 'user-memory',
346
- load: 'startup',
347
- ...userMemInfo,
348
- maxLines: 200,
349
- maxBytes: 25 * 1024,
350
- ...spreadImports(userMemInfo.path, userMemInfo.content),
351
- });
352
- }
353
- for (const file of findMdFiles(userMemoryDir)) {
354
- if (path.basename(file) === 'MEMORY.md') continue;
355
- const info = fileInfo(file);
356
- if (!info) continue;
357
- sources.push({
358
- id: `user-memory-${path.basename(file, '.md')}`,
359
- name: path.basename(file),
360
- scope: 'user-memory',
361
- load: 'ondemand',
362
- ...info,
363
- ...spreadImports(info.path, info.content),
364
- });
328
+ // 6. Auto memory
329
+ const memoryDir = findMemoryDir(projectPath);
330
+ if (memoryDir && fs.existsSync(memoryDir)) {
331
+ pushMemoryDir(memoryDir, 'memory', 'memory');
332
+ }
333
+
334
+ // 7. Subagent persistent memory (https://code.claude.com/docs/en/sub-agents#enable-persistent-memory)
335
+ // user: ~/.claude/agent-memory/<agent>/
336
+ // project: <project>/.claude/agent-memory/<agent>/
337
+ // local: <project>/.claude/agent-memory-local/<agent>/
338
+ const agentMemoryRoots = [
339
+ { root: path.join(os.homedir(), '.claude', 'agent-memory'), agentScope: 'user' },
340
+ { root: path.join(projectPath, '.claude', 'agent-memory'), agentScope: 'project' },
341
+ { root: path.join(projectPath, '.claude', 'agent-memory-local'), agentScope: 'local' },
342
+ ];
343
+ for (const { root, agentScope } of agentMemoryRoots) {
344
+ let agentDirs;
345
+ try { agentDirs = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; }
346
+ for (const entry of agentDirs) {
347
+ if (!entry.isDirectory()) continue;
348
+ const agentName = entry.name;
349
+ const idPrefix = `agent-memory-${agentScope}-${agentName.replace(/[^a-zA-Z0-9]/g, '-')}`;
350
+ pushMemoryDir(path.join(root, agentName), 'agent-memory', idPrefix, { agentScope, agentName });
365
351
  }
366
352
  }
367
353
 
@@ -379,7 +365,7 @@ function discoverMemorySources(projectPath) {
379
365
  for (const { imp, parent, hard } of batch) {
380
366
  if (seen.has(imp)) {
381
367
  const existing = sourceByPath[imp];
382
- if (existing && !existing.parentId && existing.scope === 'memory') {
368
+ if (existing && !existing.parentId && (existing.scope === 'memory' || existing.scope === 'agent-memory')) {
383
369
  existing.parentId = parent.id;
384
370
  if (hard) existing.load = 'import';
385
371
  }
@@ -553,13 +539,18 @@ app.post('/api/refresh', (_req, res) => {
553
539
  res.json({ ok: true });
554
540
  });
555
541
 
556
- app.delete('/api/file', (req, res) => {
557
- const { path: filePath } = req.body || {};
558
- if (!filePath) return res.status(400).json({ error: 'path required' });
542
+ function resolveAllowedPath(filePath) {
543
+ if (!filePath) return { error: { status: 400, message: 'path required' } };
559
544
  const resolved = path.resolve(filePath.replace(/^~/, os.homedir()));
560
545
  const roots = [path.resolve(CLAUDE_DIR), path.resolve(currentProjectPath)];
561
546
  const inRoot = roots.some((r) => resolved === r || resolved.startsWith(r + path.sep));
562
- if (!inRoot) return res.status(403).json({ error: 'path outside allowed roots' });
547
+ if (!inRoot) return { error: { status: 403, message: 'path outside allowed roots' } };
548
+ return { resolved };
549
+ }
550
+
551
+ app.delete('/api/file', (req, res) => {
552
+ const { resolved, error } = resolveAllowedPath(req.body?.path);
553
+ if (error) return res.status(error.status).json({ error: error.message });
563
554
  try {
564
555
  const stat = fs.statSync(resolved);
565
556
  if (!stat.isFile()) return res.status(400).json({ error: 'not a file' });
@@ -572,6 +563,37 @@ app.delete('/api/file', (req, res) => {
572
563
  }
573
564
  });
574
565
 
566
+ app.post('/api/memory/cleanup-orphans', (req, res) => {
567
+ const { resolved, error } = resolveAllowedPath(req.body?.path);
568
+ if (error) return res.status(error.status).json({ error: error.message });
569
+ if (path.basename(resolved) !== 'MEMORY.md') return res.status(400).json({ error: 'not a MEMORY.md file' });
570
+ try {
571
+ const content = fs.readFileSync(resolved, 'utf-8');
572
+ const dir = path.dirname(resolved);
573
+ const lines = content.split('\n');
574
+ const removed = [];
575
+ const kept = [];
576
+ const linkRe = /^\s*-\s*\[([^\]]+)\]\(([^)]+\.md)\)/;
577
+ for (const line of lines) {
578
+ const m = line.match(linkRe);
579
+ if (m) {
580
+ const target = path.resolve(dir, m[2]);
581
+ if (!fs.existsSync(target)) {
582
+ removed.push({ name: m[1], file: m[2] });
583
+ continue;
584
+ }
585
+ }
586
+ kept.push(line);
587
+ }
588
+ if (removed.length === 0) return res.json({ ok: true, removed: [] });
589
+ fs.writeFileSync(resolved, kept.join('\n'), 'utf-8');
590
+ clearCache();
591
+ res.json({ ok: true, removed });
592
+ } catch (err) {
593
+ res.status(500).json({ error: err.message });
594
+ }
595
+ });
596
+
575
597
  app.post('/api/open-in-editor', (req, res) => {
576
598
  const { path: filePath } = req.body;
577
599
  if (!filePath) return res.status(400).json({ error: 'path required' });