claude-code-memory-explorer 0.4.0 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-memory-explorer",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Memory explorer dashboard for Claude Code",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -258,6 +258,7 @@ const LOAD_ICONS = {
258
258
  conditional: '\u25CB',
259
259
  ondemand: '\u25CC',
260
260
  import: '@',
261
+ link: '\u2197',
261
262
  };
262
263
  const LOAD_TITLES = {
263
264
  always: 'Always loaded',
@@ -265,6 +266,7 @@ const LOAD_TITLES = {
265
266
  conditional: 'Conditional (path-scoped)',
266
267
  ondemand: 'On-demand',
267
268
  import: 'Imported by parent file',
269
+ link: 'Referenced via markdown link (not auto-loaded)',
268
270
  };
269
271
 
270
272
  let treeIndex = null;
@@ -282,17 +284,12 @@ function getTreeIndex() {
282
284
  groups[s.scope].push(s);
283
285
  }
284
286
  }
285
- const navOrder = [];
286
- function collect(item) {
287
- navOrder.push(item);
288
- const children = childrenOf[item.id];
289
- if (children) for (const c of children) collect(c);
290
- }
291
- for (const scope of SCOPE_ORDER) {
292
- const items = groups[scope];
293
- if (items) for (const item of items) collect(item);
287
+ // Default-expand parents whose children are real discovered files (e.g. auto-memory
288
+ // dirs indexed by MEMORY.md). Pure @import/link references (importedBy set) stay collapsed.
289
+ for (const parentId in childrenOf) {
290
+ if (childrenOf[parentId].some((c) => !c.importedBy)) expandedItems.add(parentId);
294
291
  }
295
- treeIndex = { groups, childrenOf, navOrder };
292
+ treeIndex = { groups, childrenOf };
296
293
  return treeIndex;
297
294
  }
298
295
 
@@ -315,16 +312,21 @@ function renderTree() {
315
312
  const loadIcon = LOAD_ICONS[item.load] || '';
316
313
  const loadTitle = LOAD_TITLES[item.load] || item.load;
317
314
  const meta = `${item.lines}L`;
318
- const isConditional = item.load === 'conditional' || item.load === 'ondemand';
315
+ const isConditional = item.load === 'conditional' || item.load === 'ondemand' || item.load === 'link';
319
316
  const pad = indent ? ` style="padding-left:${12 + indent * 16}px"` : '';
320
317
  const muted = item.scope === 'agent-memory' ? ' tree-agent-item' : '';
318
+ const children = childrenOf[item.id];
319
+ const expanded = children && isItemExpanded(item.id);
320
+ const chevron = children
321
+ ? `<span class="tree-chevron" onclick="_toggleItem(event,'${escJs(item.id)}')" title="${children.length} referenced">${expanded ? '▾' : '▸'}</span>`
322
+ : '<span class="tree-chevron-spacer"></span>';
321
323
  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}>`;
324
+ h += chevron;
322
325
  h += `<span class="load-icon" title="${loadTitle}" style="color:var(--scope-${item.scope})">${loadIcon}</span>`;
323
326
  h += `<span class="file-name">${esc(item.name)}</span>`;
324
327
  h += `<span class="file-meta">${meta}</span>`;
325
328
  h += '</div>';
326
- const children = childrenOf[item.id];
327
- if (children) {
329
+ if (expanded) {
328
330
  for (const child of children) h += renderItem(child, (indent || 0) + 1);
329
331
  }
330
332
  return h;
@@ -396,6 +398,26 @@ function expandGroupForItem(item) {
396
398
  if (scope && isGroupCollapsed(scope)) setGroupCollapsed(scope, false);
397
399
  }
398
400
 
401
+ // Referenced/child files are collapsed by default; expand on click. Only top-level
402
+ // "root" files (those not pulled in via an import/link) are shown until expanded.
403
+ const expandedItems = new Set();
404
+ function isItemExpanded(id) {
405
+ return expandedItems.has(id);
406
+ }
407
+ function _toggleItem(event, id) {
408
+ event.stopPropagation();
409
+ if (expandedItems.has(id)) expandedItems.delete(id);
410
+ else expandedItems.add(id);
411
+ renderTree();
412
+ }
413
+ function expandItemAncestors(item) {
414
+ let cur = item;
415
+ while (cur?.parentId) {
416
+ expandedItems.add(cur.parentId);
417
+ cur = stackData.find((s) => s.id === cur.parentId);
418
+ }
419
+ }
420
+
399
421
  function pushFileState(id) {
400
422
  const url = id ? `#${encodeURIComponent(id)}` : location.pathname;
401
423
  history.pushState({ fileId: id }, '', url);
@@ -403,6 +425,13 @@ function pushFileState(id) {
403
425
 
404
426
  function selectFile(id, pushState = true) {
405
427
  selectedFileId = selectedFileId === id ? null : id;
428
+ if (selectedFileId) {
429
+ const item = stackData.find((s) => s.id === selectedFileId);
430
+ if (item) {
431
+ expandGroupForItem(item);
432
+ expandItemAncestors(item);
433
+ }
434
+ }
406
435
  if (pushState) pushFileState(selectedFileId);
407
436
  renderTree();
408
437
  renderPreview();
@@ -413,47 +442,76 @@ function scrollToSelected() {
413
442
  if (el) el.scrollIntoView({ block: 'nearest' });
414
443
  }
415
444
 
445
+ function currentItem() {
446
+ return stackData.find((s) => s.id === selectedFileId) || null;
447
+ }
448
+
449
+ function selectNav(id) {
450
+ selectedFileId = id;
451
+ pushFileState(id);
452
+ renderTree();
453
+ renderPreview();
454
+ scrollToSelected();
455
+ }
456
+
457
+ // Visible traversal order: collapsed subtrees and collapsed groups are skipped.
458
+ function getVisibleOrder() {
459
+ const { groups, childrenOf } = getTreeIndex();
460
+ const order = [];
461
+ const walk = (item) => {
462
+ order.push(item);
463
+ if (isItemExpanded(item.id)) {
464
+ const children = childrenOf[item.id];
465
+ if (children) for (const c of children) walk(c);
466
+ }
467
+ };
468
+ for (const scope of SCOPE_ORDER) {
469
+ if (isGroupCollapsed(scope)) continue;
470
+ const items = groups[scope];
471
+ if (items) for (const item of items) walk(item);
472
+ }
473
+ return order;
474
+ }
475
+
416
476
  function navigateTree(direction) {
417
- const { navOrder } = getTreeIndex();
418
- if (!navOrder.length) return;
419
- let idx = navOrder.findIndex((s) => s.id === selectedFileId);
477
+ const order = getVisibleOrder();
478
+ if (!order.length) return;
479
+ let idx = order.findIndex((s) => s.id === selectedFileId);
420
480
  if (idx === -1) {
421
- idx = direction > 0 ? 0 : navOrder.length - 1;
481
+ idx = direction > 0 ? 0 : order.length - 1;
422
482
  } else {
423
483
  idx += direction;
424
- if (idx < 0) idx = navOrder.length - 1;
425
- if (idx >= navOrder.length) idx = 0;
484
+ if (idx < 0) idx = order.length - 1;
485
+ if (idx >= order.length) idx = 0;
426
486
  }
427
- const target = navOrder[idx];
428
- selectedFileId = target.id;
429
- expandGroupForItem(target);
430
- pushFileState(selectedFileId);
431
- renderTree();
432
- renderPreview();
433
- scrollToSelected();
487
+ selectNav(order[idx].id);
434
488
  }
435
489
 
436
- function navigateGroup(direction) {
437
- const { groups } = getTreeIndex();
438
- const activeScopes = SCOPE_ORDER.filter((sc) => groups[sc]?.length);
439
- if (!activeScopes.length) return;
440
-
441
- const current = stackData.find((s) => s.id === selectedFileId);
442
- let scopeIdx = activeScopes.indexOf(getItemScope(current));
443
- if (scopeIdx === -1) {
444
- scopeIdx = direction > 0 ? 0 : activeScopes.length - 1;
490
+ // l / Right: expand a collapsed node, descend into an expanded one, leaf = no-op.
491
+ function expandOrDescend() {
492
+ const item = currentItem();
493
+ if (!item) return navigateTree(1);
494
+ const children = getTreeIndex().childrenOf[item.id];
495
+ if (!children?.length) return;
496
+ if (!isItemExpanded(item.id)) {
497
+ expandedItems.add(item.id);
498
+ renderTree();
445
499
  } else {
446
- scopeIdx += direction;
447
- if (scopeIdx < 0) scopeIdx = activeScopes.length - 1;
448
- if (scopeIdx >= activeScopes.length) scopeIdx = 0;
449
- }
450
- const target = groups[activeScopes[scopeIdx]][0];
451
- selectedFileId = target.id;
452
- expandGroupForItem(target);
453
- pushFileState(selectedFileId);
454
- renderTree();
455
- renderPreview();
456
- scrollToSelected();
500
+ selectNav(children[0].id);
501
+ }
502
+ }
503
+
504
+ // h / Left: collapse an expanded node, otherwise ascend to its parent.
505
+ function collapseOrAscend() {
506
+ const item = currentItem();
507
+ if (!item) return navigateTree(-1);
508
+ const children = getTreeIndex().childrenOf[item.id];
509
+ if (children?.length && isItemExpanded(item.id)) {
510
+ expandedItems.delete(item.id);
511
+ renderTree();
512
+ } else if (item.parentId) {
513
+ selectNav(item.parentId);
514
+ }
457
515
  }
458
516
 
459
517
  // #endregion RENDER_TREE
@@ -821,21 +879,23 @@ document.addEventListener('keydown', (e) => {
821
879
  e.preventDefault();
822
880
  changeProject();
823
881
  }
824
- if (e.key === 'j' || e.key === 'ArrowDown') {
882
+ // Skip when modifiers are held (e.g. hub's Ctrl+Alt+Arrow app switching).
883
+ const plain = !e.ctrlKey && !e.altKey && !e.metaKey;
884
+ if (plain && (e.key === 'j' || e.key === 'ArrowDown')) {
825
885
  e.preventDefault();
826
886
  navigateTree(1);
827
887
  }
828
- if (e.key === 'k' || e.key === 'ArrowUp') {
888
+ if (plain && (e.key === 'k' || e.key === 'ArrowUp')) {
829
889
  e.preventDefault();
830
890
  navigateTree(-1);
831
891
  }
832
- if (e.key === 'h' || e.key === 'ArrowLeft') {
892
+ if (plain && (e.key === 'h' || e.key === 'ArrowLeft')) {
833
893
  e.preventDefault();
834
- navigateGroup(-1);
894
+ collapseOrAscend();
835
895
  }
836
- if (e.key === 'l' || e.key === 'ArrowRight') {
896
+ if (plain && (e.key === 'l' || e.key === 'ArrowRight')) {
837
897
  e.preventDefault();
838
- navigateGroup(1);
898
+ expandOrDescend();
839
899
  }
840
900
  if (e.key === 'Enter' && selectedFileId) renderPreview();
841
901
  if (e.key === 'e' && selectedFileId) {
@@ -979,8 +1039,11 @@ document.addEventListener('DOMContentLoaded', async () => {
979
1039
  await loadData();
980
1040
  // Restore file selection from hash
981
1041
  const hash = decodeURIComponent(location.hash.slice(1));
982
- if (hash && stackData.find((s) => s.id === hash)) {
1042
+ const hashItem = hash && stackData.find((s) => s.id === hash);
1043
+ if (hashItem) {
983
1044
  selectedFileId = hash;
1045
+ expandGroupForItem(hashItem);
1046
+ expandItemAncestors(hashItem);
984
1047
  renderTree();
985
1048
  renderPreview();
986
1049
  }
package/public/index.html CHANGED
@@ -157,8 +157,8 @@
157
157
  <table class="shortcut-table">
158
158
  <tr><td><kbd>j</kbd> / <kbd>&darr;</kbd></td><td>Next item</td></tr>
159
159
  <tr><td><kbd>k</kbd> / <kbd>&uarr;</kbd></td><td>Previous item</td></tr>
160
- <tr><td><kbd>h</kbd> / <kbd>&larr;</kbd></td><td>Previous group</td></tr>
161
- <tr><td><kbd>l</kbd> / <kbd>&rarr;</kbd></td><td>Next group</td></tr>
160
+ <tr><td><kbd>l</kbd> / <kbd>&rarr;</kbd></td><td>Expand / enter child</td></tr>
161
+ <tr><td><kbd>h</kbd> / <kbd>&larr;</kbd></td><td>Collapse / parent</td></tr>
162
162
  <tr><td><kbd>Enter</kbd></td><td>Select item</td></tr>
163
163
  <tr><td><kbd>Esc</kbd></td><td>Close modal</td></tr>
164
164
  </table>
package/public/style.css CHANGED
@@ -42,13 +42,12 @@ body {
42
42
  background: var(--bg-deep);
43
43
  color: var(--text-primary);
44
44
  font-family: var(--font-sans);
45
- font-size: 13px;
45
+ font-size: 14px;
46
46
  line-height: 1.5;
47
- height: calc(100vh / 1.25);
47
+ height: 100vh;
48
48
  display: flex;
49
49
  flex-direction: column;
50
50
  overflow: hidden;
51
- zoom: 1.25;
52
51
  }
53
52
  .mono {
54
53
  font-family: var(--font-mono);
@@ -299,10 +298,10 @@ body {
299
298
  color: var(--text-muted);
300
299
  }
301
300
  .tree-agent-item .file-name {
302
- opacity: 0.7;
301
+ opacity: 0.78;
303
302
  }
304
303
  body.light .tree-agent-item .file-name {
305
- opacity: 0.8;
304
+ opacity: 0.86;
306
305
  }
307
306
  .tree-item {
308
307
  padding: 5px 12px 5px 24px;
@@ -321,6 +320,25 @@ body.light .tree-agent-item .file-name {
321
320
  background: var(--accent-dim);
322
321
  color: var(--accent);
323
322
  }
323
+ .tree-item .tree-chevron,
324
+ .tree-item .tree-chevron-spacer {
325
+ width: 12px;
326
+ flex-shrink: 0;
327
+ text-align: center;
328
+ }
329
+ .tree-item .tree-chevron {
330
+ font-size: 9px;
331
+ color: var(--text-muted);
332
+ cursor: pointer;
333
+ border-radius: 3px;
334
+ transition:
335
+ color 0.1s,
336
+ background 0.1s;
337
+ }
338
+ .tree-item .tree-chevron:hover {
339
+ color: var(--accent-text);
340
+ background: var(--accent-dim);
341
+ }
324
342
  .tree-item .load-icon {
325
343
  font-size: 10px;
326
344
  width: 12px;
@@ -329,7 +347,7 @@ body.light .tree-agent-item .file-name {
329
347
  }
330
348
  .tree-item .file-name {
331
349
  font-family: var(--font-mono);
332
- font-size: 11px;
350
+ font-size: 13px;
333
351
  flex: 1;
334
352
  overflow: hidden;
335
353
  text-overflow: ellipsis;
@@ -342,13 +360,13 @@ body.light .tree-agent-item .file-name {
342
360
  flex-shrink: 0;
343
361
  }
344
362
  .tree-child .file-name {
345
- opacity: 0.75;
363
+ opacity: 0.82;
346
364
  }
347
365
  .tree-conditional .file-name {
348
- opacity: 0.55;
366
+ opacity: 0.68;
349
367
  }
350
368
  body.light .tree-conditional .file-name {
351
- opacity: 0.75;
369
+ opacity: 0.8;
352
370
  }
353
371
  /* #endregion TREE */
354
372
 
@@ -366,8 +384,8 @@ body.light .tree-conditional .file-name {
366
384
  }
367
385
  .preview-title .file-path {
368
386
  font-family: var(--font-mono);
369
- font-size: 11px;
370
- color: var(--text-muted);
387
+ font-size: 12px;
388
+ color: var(--text-tertiary);
371
389
  overflow: hidden;
372
390
  text-overflow: ellipsis;
373
391
  }
@@ -383,10 +401,10 @@ body.light .tree-conditional .file-name {
383
401
  margin-top: 6px;
384
402
  }
385
403
  .preview-filepath {
386
- padding: 4px 16px;
404
+ padding: 5px 16px;
387
405
  font-family: var(--font-mono);
388
- font-size: 10px;
389
- color: var(--text-muted);
406
+ font-size: 12px;
407
+ color: var(--text-tertiary);
390
408
  background: var(--bg-deep);
391
409
  border-bottom: 1px solid var(--border);
392
410
  overflow: hidden;
@@ -395,9 +413,9 @@ body.light .tree-conditional .file-name {
395
413
  }
396
414
  .preview-code {
397
415
  font-family: var(--font-mono);
398
- font-size: 11px;
416
+ font-size: 13px;
399
417
  line-height: 1.6;
400
- color: var(--text-secondary);
418
+ color: var(--text-primary);
401
419
  white-space: pre-wrap;
402
420
  word-break: break-word;
403
421
  padding: 12px 16px;
@@ -860,8 +878,8 @@ kbd {
860
878
  color: var(--error);
861
879
  }
862
880
  .toast.success {
863
- border-color: #2ea043;
864
- color: #2ea043;
881
+ border-color: var(--success);
882
+ color: var(--success);
865
883
  }
866
884
  @keyframes slideIn {
867
885
  from {
@@ -878,15 +896,18 @@ kbd {
878
896
  /* #region LIGHT_THEME */
879
897
  body.light {
880
898
  --bg-deep: #e8e6e3;
881
- --bg-surface: #f4f3f1;
882
- --bg-elevated: #dddbd8;
899
+ --bg-surface: #efede9;
900
+ --bg-elevated: #fbfaf9;
883
901
  --bg-hover: #d2d0cc;
884
- --border: #a09b94;
902
+ --border: #cfcbc4;
885
903
  --text-primary: #0a0a0a;
886
904
  --text-secondary: #444444;
887
905
  --text-tertiary: #666666;
888
906
  --text-muted: #888888;
889
907
  --accent-text: #b85a20;
908
+ --accent-dim: rgba(232, 111, 51, 0.18);
909
+ --accent-glow: rgba(232, 111, 51, 0.5);
910
+ --success: #1a8a5a;
890
911
  }
891
912
  /* #endregion LIGHT_THEME */
892
913
 
package/server.js CHANGED
@@ -17,21 +17,19 @@ function getArg(name) {
17
17
  return process.argv[eqIdx + 1] || null;
18
18
  }
19
19
 
20
+ const expandHome = (p) => (p || '').replace(/^~/, os.homedir());
21
+
20
22
  const PORT = getArg('port') || process.env.PORT || 3459;
21
23
  const AUTO_OPEN = process.argv.includes('--open');
22
24
  const claudeDirArg = getArg('dir');
23
- const CLAUDE_DIR = claudeDirArg
24
- ? claudeDirArg.replace(/^~/, os.homedir())
25
- : path.join(os.homedir(), '.claude');
25
+ const CLAUDE_DIR = claudeDirArg ? expandHome(claudeDirArg) : path.join(os.homedir(), '.claude');
26
26
  const projectDirArg = getArg('project');
27
27
 
28
28
  // #endregion CLI_ARGS
29
29
 
30
30
  // #region STATE
31
31
 
32
- let currentProjectPath = projectDirArg
33
- ? path.resolve(projectDirArg.replace(/^~/, os.homedir()))
34
- : process.cwd();
32
+ let currentProjectPath = projectDirArg ? path.resolve(expandHome(projectDirArg)) : process.cwd();
35
33
 
36
34
  const cache = {};
37
35
  const CACHE_TTL = 30_000;
@@ -129,7 +127,7 @@ function parseImports(content) {
129
127
  function resolveImport(importPath, fromFile) {
130
128
  let resolved = importPath;
131
129
  if (resolved.startsWith('~')) {
132
- resolved = resolved.replace(/^~/, os.homedir());
130
+ resolved = expandHome(resolved);
133
131
  } else {
134
132
  resolved = path.resolve(path.dirname(fromFile), resolved);
135
133
  }
@@ -325,7 +323,7 @@ function discoverMemorySources(projectPath) {
325
323
  }
326
324
  }
327
325
 
328
- // 6. Auto memory
326
+ // 6. Auto memory (projects base honors `autoMemoryDirectory` user setting)
329
327
  const memoryDir = findMemoryDir(projectPath);
330
328
  if (memoryDir && fs.existsSync(memoryDir)) {
331
329
  pushMemoryDir(memoryDir, 'memory', 'memory');
@@ -376,10 +374,10 @@ function discoverMemorySources(projectPath) {
376
374
  if (!info) continue;
377
375
  const { resolved: imports, resolvedSoft: softImports, unresolved: unresolvedImports } = resolveAllImports(imp, info.content);
378
376
  const source = {
379
- id: `import-${path.basename(imp, '.md')}-${depth}`,
377
+ id: `import-${imp.replace(/[^a-zA-Z0-9]/g, '-')}`,
380
378
  name: path.basename(imp),
381
379
  scope: parent.scope,
382
- load: 'import',
380
+ load: hard ? 'import' : 'link',
383
381
  ...info,
384
382
  importedBy: parent.path,
385
383
  parentId: parent.id,
@@ -428,7 +426,7 @@ function getAncestorDirs(projectPath) {
428
426
  }
429
427
 
430
428
  function findMemoryDir(projectPath) {
431
- const projectsDir = path.join(CLAUDE_DIR, 'projects');
429
+ const projectsDir = getProjectsBaseDir();
432
430
  if (!fs.existsSync(projectsDir)) return null;
433
431
  const encoded = encodeProjectPath(projectPath);
434
432
  const memDir = path.join(projectsDir, encoded, 'memory');
@@ -467,8 +465,17 @@ function encodeProjectPath(projectPath) {
467
465
  .replace(/\//g, '-');
468
466
  }
469
467
 
468
+ // User-scope `autoMemoryDirectory` only — Claude Code rejects this key from project/local settings for security.
469
+ function getProjectsBaseDir() {
470
+ let settings = {};
471
+ try { settings = JSON.parse(fs.readFileSync(path.join(CLAUDE_DIR, 'settings.json'), 'utf-8')); } catch { /* no settings */ }
472
+ const raw = settings.autoMemoryDirectory;
473
+ if (typeof raw === 'string' && raw.trim()) return path.resolve(expandHome(raw));
474
+ return path.join(CLAUDE_DIR, 'projects');
475
+ }
476
+
470
477
  function findMemoryDirBySubstring(projectPath) {
471
- const projectsDir = path.join(CLAUDE_DIR, 'projects');
478
+ const projectsDir = getProjectsBaseDir();
472
479
  if (!fs.existsSync(projectsDir)) return null;
473
480
  // Match by last 2-3 path segments
474
481
  const segments = projectPath.replace(/\\/g, '/').split('/').filter(Boolean);
@@ -525,7 +532,7 @@ app.get('/api/project', (_req, res) => {
525
532
  app.put('/api/project', (req, res) => {
526
533
  const { path: dirPath } = req.body;
527
534
  if (!dirPath) return res.status(400).json({ error: 'path required' });
528
- const resolved = path.resolve(dirPath.replace(/^~/, os.homedir()));
535
+ const resolved = path.resolve(expandHome(dirPath));
529
536
  if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'directory not found' });
530
537
  if (resolved !== path.resolve(currentProjectPath)) {
531
538
  currentProjectPath = resolved;
@@ -541,8 +548,12 @@ app.post('/api/refresh', (_req, res) => {
541
548
 
542
549
  function resolveAllowedPath(filePath) {
543
550
  if (!filePath) return { error: { status: 400, message: 'path required' } };
544
- const resolved = path.resolve(filePath.replace(/^~/, os.homedir()));
545
- const roots = [path.resolve(CLAUDE_DIR), path.resolve(currentProjectPath)];
551
+ const resolved = path.resolve(expandHome(filePath));
552
+ const roots = [
553
+ path.resolve(CLAUDE_DIR),
554
+ path.resolve(currentProjectPath),
555
+ getProjectsBaseDir(),
556
+ ];
546
557
  const inRoot = roots.some((r) => resolved === r || resolved.startsWith(r + path.sep));
547
558
  if (!inRoot) return { error: { status: 403, message: 'path outside allowed roots' } };
548
559
  return { resolved };
@@ -597,7 +608,7 @@ app.post('/api/memory/cleanup-orphans', (req, res) => {
597
608
  app.post('/api/open-in-editor', (req, res) => {
598
609
  const { path: filePath } = req.body;
599
610
  if (!filePath) return res.status(400).json({ error: 'path required' });
600
- const resolved = filePath.replace(/^~/, os.homedir());
611
+ const resolved = expandHome(filePath);
601
612
  if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'file not found' });
602
613
  const { exec } = require('child_process');
603
614
  exec(`code "${resolved}"`, (err) => {