claude-code-memory-explorer 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-memory-explorer",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Memory explorer dashboard for Claude Code",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -401,6 +401,10 @@ function navigateGroup(direction) {
401
401
 
402
402
  // #region MEMORY_INDEX
403
403
 
404
+ function isMemoryIndex(source) {
405
+ return (source.scope === 'memory' || source.scope === 'user-memory') && source.name === 'MEMORY.md';
406
+ }
407
+
404
408
  function parseMemoryIndex(content) {
405
409
  const entries = [];
406
410
  for (const line of content.split('\n')) {
@@ -457,8 +461,16 @@ async function renderPreview() {
457
461
  html += '<div class="preview-title">';
458
462
  html += `<span class="scope-badge scope-${source.scope}">${esc(source.scope)}</span>`;
459
463
  html += `<span class="file-path">${esc(source.name)}</span>`;
464
+ html += '<div class="preview-actions">';
465
+ if (isMemoryIndex(source)) {
466
+ const broken = (source.unresolvedImports || []).length;
467
+ if (broken) {
468
+ 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>`;
469
+ }
470
+ }
460
471
  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>`;
461
- html += '</div>';
472
+ 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>`;
473
+ html += '</div></div>';
462
474
 
463
475
  // Badges row
464
476
  html += '<div class="preview-badges">';
@@ -472,10 +484,13 @@ async function renderPreview() {
472
484
  }
473
485
  html += '</div>';
474
486
 
475
- // Imports — only show children (files that have this source as parent)
487
+ const content = fileData.content || '';
488
+ const memoryEntries = isMemoryIndex(source) ? parseMemoryIndex(content) : [];
489
+ const showMemoryTable = memoryEntries.length > 0;
490
+
476
491
  const children = stackData.filter((s) => s.parentId === source.id);
477
492
  const unresolved = source.unresolvedImports || [];
478
- if (children.length || unresolved.length) {
493
+ if (!showMemoryTable && (children.length || unresolved.length)) {
479
494
  html += '<div class="preview-imports">';
480
495
  for (const child of children) {
481
496
  html += `<a class="import-link" href="#" onclick="selectFile('${escJs(child.id)}');return false" title="${esc(child.path)}">${esc(child.name)}</a>`;
@@ -491,19 +506,16 @@ async function renderPreview() {
491
506
  // File path
492
507
  html += `<div class="preview-filepath">${esc(source.path)}</div>`;
493
508
 
494
- // Content — with cutoff line for auto memory startup files
495
- const content = fileData.content || '';
496
509
  const hl = (text) => {
497
510
  const { text: processed, placeholders } = linkifyImports(text, source.id);
498
511
  return restorePlaceholders(highlightSource(processed, source.name), placeholders);
499
512
  };
500
513
 
501
- // Memory index view — render structured table for MEMORY.md index files
502
- if ((source.scope === 'memory' || source.scope === 'user-memory') && source.name === 'MEMORY.md') {
503
- const entries = parseMemoryIndex(content);
504
- if (entries.length) {
505
- html += renderMemoryIndexTable(entries);
506
- }
514
+ if (showMemoryTable) {
515
+ html += renderMemoryIndexTable(memoryEntries);
516
+ html += `<pre class="preview-code"><code>${hl(content)}</code></pre>`;
517
+ panel.innerHTML = html;
518
+ return;
507
519
  }
508
520
 
509
521
  if ((source.scope === 'memory' || source.scope === 'user-memory') && source.load === 'startup' && source.maxLines) {
@@ -530,6 +542,96 @@ function formatBytes(b) {
530
542
  return `${(b / 1024).toFixed(1)}KB`;
531
543
  }
532
544
 
545
+ function _confirmDeleteFile(filePath, fileName) {
546
+ const modal = document.getElementById('deleteConfirmModal');
547
+ modal.dataset.filePath = filePath;
548
+ document.getElementById('deleteFileName').textContent = fileName;
549
+ document.getElementById('deleteFilePath').textContent = filePath;
550
+ modal.classList.add('open');
551
+ }
552
+
553
+ async function _submitDeleteFile() {
554
+ const modal = document.getElementById('deleteConfirmModal');
555
+ const filePath = modal.dataset.filePath;
556
+ if (!filePath) return;
557
+ const btn = document.getElementById('deleteConfirmSubmit');
558
+ btn.disabled = true;
559
+ btn.textContent = 'Deleting...';
560
+ try {
561
+ const res = await fetch('/api/file', {
562
+ method: 'DELETE',
563
+ headers: { 'Content-Type': 'application/json' },
564
+ body: JSON.stringify({ path: filePath }),
565
+ });
566
+ if (!res.ok) {
567
+ const msg = await res
568
+ .json()
569
+ .then((e) => e.error)
570
+ .catch(() => `Delete failed (${res.status})`);
571
+ showToast(msg, 'error');
572
+ return;
573
+ }
574
+ closeModal('deleteConfirmModal');
575
+ delete modal.dataset.filePath;
576
+ selectedFileId = null;
577
+ await loadData();
578
+ showToast('File deleted', 'success');
579
+ } catch (err) {
580
+ showToast(err.message, 'error');
581
+ } finally {
582
+ btn.disabled = false;
583
+ btn.textContent = 'Delete';
584
+ }
585
+ }
586
+
587
+ function _cleanupOrphans(filePath) {
588
+ const source = stackData.find((s) => s.path === filePath);
589
+ const broken = source?.unresolvedImports || [];
590
+ if (!broken.length) {
591
+ showToast('No orphaned refs', 'info');
592
+ return;
593
+ }
594
+ const modal = document.getElementById('cleanupOrphansModal');
595
+ modal.dataset.filePath = filePath;
596
+ document.getElementById('cleanupOrphansCount').textContent = broken.length;
597
+ document.getElementById('cleanupOrphansList').innerHTML = broken.map((r) => `<li>${esc(r)}</li>`).join('');
598
+ modal.classList.add('open');
599
+ }
600
+
601
+ async function _submitCleanupOrphans() {
602
+ const modal = document.getElementById('cleanupOrphansModal');
603
+ const filePath = modal.dataset.filePath;
604
+ if (!filePath) return;
605
+ const btn = document.getElementById('cleanupOrphansSubmit');
606
+ btn.disabled = true;
607
+ btn.textContent = 'Cleaning...';
608
+ try {
609
+ const res = await fetch('/api/memory/cleanup-orphans', {
610
+ method: 'POST',
611
+ headers: { 'Content-Type': 'application/json' },
612
+ body: JSON.stringify({ path: filePath }),
613
+ });
614
+ if (!res.ok) {
615
+ const msg = await res
616
+ .json()
617
+ .then((e) => e.error)
618
+ .catch(() => `Cleanup failed (${res.status})`);
619
+ showToast(msg, 'error');
620
+ return;
621
+ }
622
+ const data = await res.json();
623
+ closeModal('cleanupOrphansModal');
624
+ delete modal.dataset.filePath;
625
+ await loadData();
626
+ showToast(`Removed ${data.removed.length} orphaned ref${data.removed.length === 1 ? '' : 's'}`, 'success');
627
+ } catch (err) {
628
+ showToast(err.message, 'error');
629
+ } finally {
630
+ btn.disabled = false;
631
+ btn.textContent = 'Remove';
632
+ }
633
+ }
634
+
533
635
  async function openInEditor(filePath) {
534
636
  showToast('Opening...', 'info');
535
637
  try {
@@ -617,7 +719,7 @@ async function refreshData() {
617
719
  try {
618
720
  await fetch('/api/refresh', { method: 'POST' });
619
721
  await loadData();
620
- showToast('Refreshed', 'success');
722
+ showToast('Data Refreshed', 'success');
621
723
  } catch (err) {
622
724
  showToast(err.message, 'error');
623
725
  }
@@ -765,18 +867,21 @@ document.addEventListener('DOMContentLoaded', async () => {
765
867
  loadTheme();
766
868
  initResize();
767
869
  bindModalKeys('projectPathInput', 'projectPickerModal', submitProjectPicker);
768
- // Handle ?project= query param
870
+ // Handle ?project= query param, else restore last-used project from localStorage
769
871
  const params = new URLSearchParams(location.search);
770
- if (params.has('project')) {
771
- const projectPath = params.get('project');
872
+ let desiredProject = params.get('project');
873
+ if (!desiredProject) desiredProject = getRecentProjects()[0] || null;
874
+ if (desiredProject) {
772
875
  try {
773
876
  const res = await fetch('/api/project', {
774
877
  method: 'PUT',
775
878
  headers: { 'Content-Type': 'application/json' },
776
- body: JSON.stringify({ path: projectPath }),
879
+ body: JSON.stringify({ path: desiredProject }),
777
880
  });
778
- if (!res.ok) showToast('Failed to switch project', 'error');
881
+ if (!res.ok && params.has('project')) showToast('Failed to switch project', 'error');
779
882
  } catch {}
883
+ }
884
+ if (params.has('project')) {
780
885
  params.delete('project');
781
886
  const qs = params.toString();
782
887
  history.replaceState(null, '', qs ? `?${qs}` : location.pathname + location.hash);
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>
@@ -104,6 +104,42 @@
104
104
  </div>
105
105
  </div>
106
106
 
107
+ <!-- Delete confirmation modal -->
108
+ <div class="modal-overlay" id="deleteConfirmModal" onclick="if(event.target===this)closeModal('deleteConfirmModal')">
109
+ <div class="modal">
110
+ <div class="modal-header">
111
+ <h3>Delete file?</h3>
112
+ <button class="modal-close" onclick="closeModal('deleteConfirmModal')">&#10005;</button>
113
+ </div>
114
+ <div class="modal-body">
115
+ <p class="modal-desc">This will permanently delete <strong id="deleteFileName"></strong> from disk.</p>
116
+ <div class="modal-filepath" id="deleteFilePath"></div>
117
+ <div class="modal-actions">
118
+ <button class="action-btn" onclick="closeModal('deleteConfirmModal')">Cancel</button>
119
+ <button class="action-btn danger" id="deleteConfirmSubmit" onclick="_submitDeleteFile()">Delete</button>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
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
+
107
143
  <!-- Help modal -->
108
144
  <div class="modal-overlay" id="helpModal" onclick="if(event.target===this)toggleHelpModal()">
109
145
  <div class="modal">
package/public/style.css CHANGED
@@ -411,20 +411,27 @@ body.light .tree-conditional .file-name {
411
411
  }
412
412
  .memory-index-row {
413
413
  display: grid;
414
- grid-template-columns: 72px 160px 1fr;
414
+ grid-template-columns: 72px max-content 1fr;
415
415
  align-items: baseline;
416
416
  gap: 8px;
417
417
  padding: 3px 0;
418
418
  }
419
419
  .memory-index-type {
420
- flex-shrink: 0;
420
+ text-align: center;
421
+ justify-self: center;
422
+ }
423
+ .modal-orphan-list {
424
+ margin: 0;
425
+ padding: 8px 12px 8px 28px;
426
+ list-style: disc;
427
+ font-family: var(--font-mono);
428
+ font-size: 11px;
429
+ color: var(--text-muted);
421
430
  }
422
431
  .memory-index-name {
423
432
  font-size: 11px;
424
433
  font-family: var(--font-mono);
425
434
  white-space: nowrap;
426
- overflow: hidden;
427
- text-overflow: ellipsis;
428
435
  }
429
436
  .memory-index-desc {
430
437
  font-size: 10px;
@@ -665,10 +672,33 @@ body.light .tree-conditional .file-name {
665
672
  .action-btn.primary:hover {
666
673
  filter: brightness(1.1);
667
674
  }
675
+ .action-btn.danger {
676
+ background: var(--error);
677
+ border-color: var(--error);
678
+ color: #fff;
679
+ }
680
+ .action-btn.danger:hover {
681
+ filter: brightness(1.1);
682
+ }
683
+ .modal-filepath {
684
+ font-family: 'IBM Plex Mono', monospace;
685
+ font-size: 11px;
686
+ color: var(--text-tertiary);
687
+ background: var(--bg-deep);
688
+ border: 1px solid var(--border);
689
+ border-radius: 4px;
690
+ padding: 6px 8px;
691
+ margin: 8px 0 12px;
692
+ word-break: break-all;
693
+ }
694
+ .preview-actions {
695
+ margin-left: auto;
696
+ display: inline-flex;
697
+ gap: 4px;
698
+ }
668
699
  .action-btn.small {
669
700
  padding: 2px 6px;
670
701
  font-size: 10px;
671
- margin-left: auto;
672
702
  display: inline-flex;
673
703
  align-items: center;
674
704
  justify-content: center;
@@ -787,8 +817,8 @@ kbd {
787
817
  color: var(--error);
788
818
  }
789
819
  .toast.success {
790
- border-color: var(--success);
791
- color: var(--success);
820
+ border-color: #2ea043;
821
+ color: #2ea043;
792
822
  }
793
823
  @keyframes slideIn {
794
824
  from {
package/server.js CHANGED
@@ -5,6 +5,7 @@ const express = require('express');
5
5
  const path = require('path');
6
6
  const fs = require('fs');
7
7
  const os = require('os');
8
+ const { execFileSync } = require('child_process');
8
9
 
9
10
  // #region CLI_ARGS
10
11
 
@@ -159,6 +160,11 @@ function spreadImports(filePath, content) {
159
160
  return { imports: resolved, softImports: resolvedSoft, unresolvedImports: unresolved };
160
161
  }
161
162
 
163
+ function hasPathsFilter(frontmatter) {
164
+ if (!frontmatter || !frontmatter.paths) return false;
165
+ return Array.isArray(frontmatter.paths) ? frontmatter.paths.length > 0 : true;
166
+ }
167
+
162
168
  function discoverMemorySources(projectPath) {
163
169
  const sources = [];
164
170
 
@@ -201,7 +207,7 @@ function discoverMemorySources(projectPath) {
201
207
  id: `user-rule-${path.basename(file, '.md')}`,
202
208
  name: path.basename(file),
203
209
  scope: 'rule',
204
- load: 'conditional',
210
+ load: hasPathsFilter(info.frontmatter) ? 'conditional' : 'always',
205
211
  ...info,
206
212
  ruleSource: 'user',
207
213
  ...spreadImports(info.path, info.content),
@@ -292,7 +298,7 @@ function discoverMemorySources(projectPath) {
292
298
  id: `project-rule-${path.basename(file, '.md')}`,
293
299
  name: path.basename(file),
294
300
  scope: 'rule',
295
- load: 'conditional',
301
+ load: hasPathsFilter(info.frontmatter) ? 'conditional' : 'always',
296
302
  ...info,
297
303
  ruleSource: 'project',
298
304
  ...spreadImports(info.path, info.content),
@@ -446,15 +452,38 @@ function findMemoryDir(projectPath) {
446
452
  const encoded = encodeProjectPath(projectPath);
447
453
  const memDir = path.join(projectsDir, encoded, 'memory');
448
454
  if (fs.existsSync(memDir)) return memDir;
455
+ const main = findMainWorktreePath(projectPath);
456
+ if (main) {
457
+ const mainMem = path.join(projectsDir, encodeProjectPath(main), 'memory');
458
+ if (fs.existsSync(mainMem)) return mainMem;
459
+ }
449
460
  return findMemoryDirBySubstring(projectPath);
450
461
  }
451
462
 
463
+ function findMainWorktreePath(projectPath) {
464
+ try {
465
+ const out = execFileSync('git', ['-C', projectPath, 'rev-parse', '--git-common-dir'], {
466
+ encoding: 'utf8',
467
+ stdio: ['ignore', 'pipe', 'ignore'],
468
+ timeout: 2000,
469
+ }).trim();
470
+ if (!out) return null;
471
+ const resolved = path.resolve(projectPath, out);
472
+ if (path.basename(resolved) !== '.git') return null;
473
+ const main = path.dirname(resolved);
474
+ if (main === path.resolve(projectPath)) return null;
475
+ return main;
476
+ } catch {
477
+ return null;
478
+ }
479
+ }
480
+
452
481
  function encodeProjectPath(projectPath) {
453
- // Claude Code uses double-dash encoding: C--Users-nikiforovall-dev-foo
482
+ // Claude Code encoding: `:` and `/` both become `-`, so `C:\Users\foo` → `C--Users-foo`
454
483
  return projectPath
455
484
  .replace(/\\/g, '/')
456
- .replace(/^([A-Za-z]):/, '$1') // strip colon but keep drive letter
457
- .replace(/\//g, '-'); // slashes become single dash; drive letter boundary becomes double dash naturally
485
+ .replace(/:/g, '-')
486
+ .replace(/\//g, '-');
458
487
  }
459
488
 
460
489
  function findMemoryDirBySubstring(projectPath) {
@@ -517,8 +546,10 @@ app.put('/api/project', (req, res) => {
517
546
  if (!dirPath) return res.status(400).json({ error: 'path required' });
518
547
  const resolved = path.resolve(dirPath.replace(/^~/, os.homedir()));
519
548
  if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'directory not found' });
520
- currentProjectPath = resolved;
521
- clearCache();
549
+ if (resolved !== path.resolve(currentProjectPath)) {
550
+ currentProjectPath = resolved;
551
+ clearCache();
552
+ }
522
553
  res.json({ path: currentProjectPath, name: path.basename(currentProjectPath) });
523
554
  });
524
555
 
@@ -527,6 +558,61 @@ app.post('/api/refresh', (_req, res) => {
527
558
  res.json({ ok: true });
528
559
  });
529
560
 
561
+ function resolveAllowedPath(filePath) {
562
+ if (!filePath) return { error: { status: 400, message: 'path required' } };
563
+ const resolved = path.resolve(filePath.replace(/^~/, os.homedir()));
564
+ const roots = [path.resolve(CLAUDE_DIR), path.resolve(currentProjectPath)];
565
+ const inRoot = roots.some((r) => resolved === r || resolved.startsWith(r + path.sep));
566
+ if (!inRoot) return { error: { status: 403, message: 'path outside allowed roots' } };
567
+ return { resolved };
568
+ }
569
+
570
+ app.delete('/api/file', (req, res) => {
571
+ const { resolved, error } = resolveAllowedPath(req.body?.path);
572
+ if (error) return res.status(error.status).json({ error: error.message });
573
+ try {
574
+ const stat = fs.statSync(resolved);
575
+ if (!stat.isFile()) return res.status(400).json({ error: 'not a file' });
576
+ fs.unlinkSync(resolved);
577
+ clearCache();
578
+ res.json({ ok: true });
579
+ } catch (err) {
580
+ if (err.code === 'ENOENT') return res.status(404).json({ error: 'file not found' });
581
+ res.status(500).json({ error: err.message });
582
+ }
583
+ });
584
+
585
+ app.post('/api/memory/cleanup-orphans', (req, res) => {
586
+ const { resolved, error } = resolveAllowedPath(req.body?.path);
587
+ if (error) return res.status(error.status).json({ error: error.message });
588
+ if (path.basename(resolved) !== 'MEMORY.md') return res.status(400).json({ error: 'not a MEMORY.md file' });
589
+ try {
590
+ const content = fs.readFileSync(resolved, 'utf-8');
591
+ const dir = path.dirname(resolved);
592
+ const lines = content.split('\n');
593
+ const removed = [];
594
+ const kept = [];
595
+ const linkRe = /^\s*-\s*\[([^\]]+)\]\(([^)]+\.md)\)/;
596
+ for (const line of lines) {
597
+ const m = line.match(linkRe);
598
+ if (m) {
599
+ const target = path.resolve(dir, m[2]);
600
+ if (!fs.existsSync(target)) {
601
+ removed.push({ name: m[1], file: m[2] });
602
+ continue;
603
+ }
604
+ }
605
+ kept.push(line);
606
+ }
607
+ if (removed.length === 0) return res.json({ ok: true, removed: [] });
608
+ fs.writeFileSync(resolved, kept.join('\n'), 'utf-8');
609
+ clearCache();
610
+ res.json({ ok: true, removed });
611
+ } catch (err) {
612
+ res.status(500).json({ error: err.message });
613
+ }
614
+ });
615
+
530
616
  app.post('/api/open-in-editor', (req, res) => {
531
617
  const { path: filePath } = req.body;
532
618
  if (!filePath) return res.status(400).json({ error: 'path required' });