claude-memory-admin 1.1.0 → 1.2.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/public/app.mjs CHANGED
@@ -80,6 +80,7 @@ function renderBody(container, memory, project) {
80
80
  }
81
81
 
82
82
  function storeSubtitle(store) {
83
+ if (store.kind === 'global') return store.dir;
83
84
  if (store.kind === 'auto') return store.path;
84
85
  const scope = store.kind === 'agent-user' ? 'user'
85
86
  : store.kind === 'agent-project' ? 'project' : 'local';
@@ -87,10 +88,11 @@ function storeSubtitle(store) {
87
88
  }
88
89
 
89
90
  function storeButton(store) {
90
- const health = store.memoryCount === 0 ? 'none' : 'ok';
91
+ const global = store.kind === 'global';
92
+ const health = global ? 'ok' : store.memoryCount === 0 ? 'none' : 'ok';
91
93
  const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
92
94
  return node('button', {
93
- class: ui.storeItem({ active: store.id === state.storeId, empty: !store.hasMemoryDir }),
95
+ class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
94
96
  onclick: () => openStore(store.id),
95
97
  title: store.kind === 'auto' && store.resolvedBy === 'unresolved'
96
98
  ? 'Real path could not be resolved - showing the raw folder name'
@@ -100,7 +102,7 @@ function storeButton(store) {
100
102
  node('span', { class: ui.dot(health) }),
101
103
  node('span', { class: ui.storeName, text: store.label }),
102
104
  off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
103
- node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
105
+ global ? null : node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
104
106
  ]),
105
107
  node('span', { class: ui.storePath, text: storeSubtitle(store) }),
106
108
  ]);
@@ -110,22 +112,21 @@ function renderStores() {
110
112
  const list = el('project-list');
111
113
  list.textContent = '';
112
114
 
113
- const visible = state.stores.filter((s) => state.showAll || s.hasMemoryDir);
115
+ const visible = state.stores.filter((s) => s.kind === 'global' || state.showAll || s.hasMemoryDir);
114
116
  if (!visible.length) {
115
117
  list.append(node('p', { class: ui.note, text: 'No memory stores found.' }));
116
118
  return;
117
119
  }
118
120
 
119
121
  const groups = [
122
+ ['Global', visible.filter((s) => s.kind === 'global')],
120
123
  ['Projects', visible.filter((s) => s.kind === 'auto')],
121
- ['Subagents', visible.filter((s) => s.kind !== 'auto')],
124
+ ['Subagents', visible.filter((s) => s.kind !== 'auto' && s.kind !== 'global')],
122
125
  ];
123
126
 
124
- for (const [title, stores] of groups) {
125
- if (!stores.length) continue;
126
- if (groups.every(([, group]) => group.length) ) {
127
- list.append(node('div', { class: ui.sidebarGroup, text: title }));
128
- }
127
+ const filled = groups.filter(([, stores]) => stores.length);
128
+ for (const [title, stores] of filled) {
129
+ if (filled.length > 1) list.append(node('div', { class: ui.sidebarGroup, text: title }));
129
130
  for (const store of stores) list.append(storeButton(store));
130
131
  }
131
132
  }
@@ -137,18 +138,21 @@ const TABS = [
137
138
  { id: 'prune', label: 'Prune' },
138
139
  { id: 'context', label: 'Context' },
139
140
  { id: 'health', label: 'Health' },
141
+ { id: 'settings', label: 'Settings' },
140
142
  { id: 'trash', label: 'Trash' },
141
143
  ];
142
144
 
143
145
  function renderTabs() {
144
146
  const container = el('tabs');
145
147
  container.textContent = '';
148
+ const global = state.store.kind === 'global';
146
149
  const hasProjectDir = state.store.kind === 'auto'
147
150
  ? state.store.resolvedBy !== 'unresolved'
148
151
  : Boolean(state.store.projectPath);
149
152
 
150
153
  for (const tab of TABS) {
151
- if (tab.id === 'context' && !hasProjectDir) continue;
154
+ if (global && tab.id !== 'context') continue;
155
+ if (!global && tab.id === 'context' && !hasProjectDir) continue;
152
156
  let badge = null;
153
157
  if (tab.id === 'memories') badge = String(state.store.memories.length);
154
158
  if (tab.id === 'health' && state.store.health.issueCount) badge = String(state.store.health.issueCount);
@@ -389,6 +393,7 @@ const CONTEXT_PROBLEMS = {
389
393
  'glob-budget': (p) => ['Rule has too many brace expansions', `${p.file} expands to ${p.expansions} patterns, past the 1,000 budget, so it is used unexpanded and its literal braces match no file`],
390
394
  'long-claude-md': (p) => [`${p.lines} lines, over the 200-line guidance`, `${p.file} - long files cost context every session and reduce adherence`],
391
395
  'agents-md-not-imported': (p) => ['AGENTS.md is not loaded', `${p.file} exists, but Claude Code reads CLAUDE.md. Import it with @AGENTS.md, or symlink it.`],
396
+ 'unreferenced-user-file': (p) => ['Nothing in the chain reaches this file', `${p.file} sits next to CLAUDE.md and loads nothing. Import it with @${p.file.split('/').pop()}, or delete it.`],
392
397
  };
393
398
 
394
399
  async function renderContext(container) {
@@ -403,7 +408,8 @@ async function renderContext(container) {
403
408
  if (state.tab !== 'context') return;
404
409
  container.textContent = '';
405
410
 
406
- if (!data.projectDir) {
411
+ const global = state.store.kind === 'global';
412
+ if (!data.projectDir && !global) {
407
413
  return container.append(node('p', { class: ui.note, text: 'This store is not tied to a project directory, so there are no instruction files to resolve.' }));
408
414
  }
409
415
 
@@ -446,13 +452,33 @@ async function renderContext(container) {
446
452
  if (file.kind === 'managed-settings') tags.push(node('span', { class: ui.badge(), text: 'claudeMd setting' }));
447
453
  if (file.conditional) tags.push(node('span', { class: ui.badge('warn'), text: 'only on a path match' }));
448
454
 
449
- card.append(node('div', { class: ui.contextRow }, [
455
+ const body = node('div', { class: ui.contextBody, hidden: true });
456
+ const bodyProse = node('div', { class: ui.prose });
457
+ body.append(bodyProse);
458
+ const caret = node('span', { class: ui.contextCaret, text: '\u25b8' });
459
+ let rendered = false;
460
+
461
+ card.append(node('button', {
462
+ class: ui.contextRowButton,
463
+ 'aria-expanded': 'false',
464
+ onclick: (event) => {
465
+ const open = body.hidden;
466
+ body.hidden = !open;
467
+ caret.textContent = open ? '\u25be' : '\u25b8';
468
+ event.currentTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
469
+ if (open && !rendered) {
470
+ rendered = true;
471
+ renderMarkdown(bodyProse, file.text);
472
+ }
473
+ },
474
+ }, [
450
475
  node('div', { class: ui.contextMain }, [
451
476
  node('div', { class: ui.contextTags }, tags),
452
- node('div', { class: ui.contextFile, text: file.file }),
477
+ node('div', { class: ui.contextFile }, [caret, document.createTextNode(file.file)]),
453
478
  ]),
454
479
  node('div', { class: ui.contextSize, text: `${file.lines} L · ~${file.tokens.toLocaleString()} tok` }),
455
480
  ]));
481
+ card.append(body);
456
482
  }
457
483
  container.append(card);
458
484
 
@@ -559,7 +585,11 @@ function renderIssue(item, memories) {
559
585
  return issue(
560
586
  `Not referenced anywhere in MEMORY.md: ${item.file}`,
561
587
  `"${memory?.name || item.file}" exists on disk but nothing points to it, so Claude will not load it.`,
562
- { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
588
+ {
589
+ bad,
590
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
591
+ action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
592
+ },
563
593
  );
564
594
  }
565
595
 
@@ -568,7 +598,11 @@ function renderIssue(item, memories) {
568
598
  return issue(
569
599
  `Linked mid-sentence, not indexed: ${item.file}`,
570
600
  `"${memory?.name || item.file}" is mentioned inside prose in MEMORY.md but has no index bullet of its own.`,
571
- { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
601
+ {
602
+ bad,
603
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
604
+ action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
605
+ },
572
606
  );
573
607
  }
574
608
 
@@ -605,6 +639,402 @@ function renderIssue(item, memories) {
605
639
  return issue(item.kind, JSON.stringify(item));
606
640
  }
607
641
 
642
+ const SETTINGS_PROBLEMS = {
643
+ unparseable: (p) => [
644
+ `Not valid JSON: ${p.scope} settings`,
645
+ `${p.file} — Claude Code cannot read this file either, so every value in it is being ignored. ${p.detail}`,
646
+ ],
647
+ unreadable: (p) => [
648
+ `Cannot be read: ${p.scope} settings`,
649
+ `${p.file} — ${p.detail}`,
650
+ ],
651
+ 'not-object': (p) => [
652
+ `Not a settings object: ${p.scope} settings`,
653
+ `${p.file} — ${p.detail}`,
654
+ ],
655
+ 'invalid-auto-memory-directory': (p) => [
656
+ 'autoMemoryDirectory cannot be used',
657
+ `${p.file} — ${p.detail}. Claude Code accepts only an absolute or ~/-prefixed path.`,
658
+ ],
659
+ };
660
+
661
+ const show = (value) => (value === undefined ? 'unset' : JSON.stringify(value));
662
+
663
+ async function renderSettings(container) {
664
+ container.append(node('p', { class: ui.note, text: 'Reading settings files…' }));
665
+ let data;
666
+ try {
667
+ data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/settings`);
668
+ } catch (err) {
669
+ container.textContent = '';
670
+ return container.append(node('p', { class: ui.note, text: err.message }));
671
+ }
672
+ if (state.tab !== 'settings') return;
673
+ container.textContent = '';
674
+
675
+ container.append(node('p', { class: ui.noteTight, text: 'What Claude Code would read for this store, in precedence order: managed policy first, then project and local settings, then your user file. This tab is read-only — it reports what is configured, it never changes it.' }));
676
+
677
+ if (!data.projectDir) {
678
+ container.append(node('p', { class: ui.subWarn, text: 'This store has no resolved project directory, so its project and local settings layers could not be consulted. Values below come from managed policy and your user file only.' }));
679
+ }
680
+
681
+ if (data.problems.length) {
682
+ container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
683
+ for (const problem of data.problems) {
684
+ const describe = SETTINGS_PROBLEMS[problem.kind];
685
+ const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
686
+ container.append(node('div', { class: ui.issue(true) }, [
687
+ node('div', { class: ui.issueBody }, [
688
+ node('div', { class: ui.issueTitle, text: title }),
689
+ node('div', { class: ui.issueDetail, text: detail }),
690
+ ]),
691
+ ]));
692
+ }
693
+ }
694
+
695
+ if (data.env.overrides) {
696
+ container.append(node('div', { class: ui.sectionLabel, text: 'Environment override' }));
697
+ container.append(node('div', { class: ui.issue(false) }, [
698
+ node('div', { class: ui.issueBody }, [
699
+ node('div', { class: ui.issueTitle, text: `${data.env.name}=${data.env.value}` }),
700
+ node('div', { class: ui.issueDetail, text: `Set in this shell, so it outranks every settings file and forces ${data.env.overrides} off. The store will not grow while it is set.` }),
701
+ ]),
702
+ ]));
703
+ }
704
+
705
+ for (const entry of data.keys) {
706
+ const card = node('div', { class: ui.card });
707
+ const head = node('div', { class: ui.settingsKeyHead }, [
708
+ node('span', { class: ui.settingsKeyName, text: entry.key }),
709
+ node('span', {
710
+ class: ui.settingsEffective,
711
+ text: entry.effective ? show(entry.effective.value) : `${show(entry.fallback)} (default)`,
712
+ }),
713
+ ]);
714
+ if (entry.effective) {
715
+ head.append(node('span', { class: ui.scopeBadge(entry.effective.scope), text: entry.effective.scope }));
716
+ }
717
+ card.append(head);
718
+ card.append(node('p', { class: ui.noteTight, text: entry.detail }));
719
+
720
+ if (entry.normalized !== undefined && entry.effective && entry.normalized !== entry.effective.value) {
721
+ card.append(node('div', { class: ui.issue(false) }, [
722
+ node('div', { class: ui.issueBody }, [
723
+ node('div', { class: ui.issueTitle, text: `Configured ${show(entry.effective.value)}, but ${show(entry.normalized)} applies` }),
724
+ node('div', { class: ui.issueDetail, text: 'Claude Code ignores a value it cannot use and falls back to the default.' }),
725
+ ]),
726
+ ]));
727
+ }
728
+
729
+ if (!entry.values.length) {
730
+ card.append(node('p', { class: ui.note, text: 'No settings file sets this, so the built-in default applies.' }));
731
+ } else {
732
+ for (const value of entry.values) {
733
+ card.append(node('div', { class: ui.settingsLayerRow }, [
734
+ node('span', { class: ui.scopeBadge(value.scope), text: value.scope }),
735
+ node('span', { class: ui.settingsLayerValue(value.wins), text: show(value.value) }),
736
+ node('span', { class: ui.settingsLayerFile, text: value.file }),
737
+ value.wins ? node('span', { class: ui.badge('ok'), text: 'wins' }) : null,
738
+ ]));
739
+ }
740
+ }
741
+ container.append(card);
742
+ }
743
+
744
+ container.append(node('div', { class: ui.sectionLabel, text: `Files consulted · ${data.layers.length}` }));
745
+ const files = node('div', { class: ui.card });
746
+ for (const layer of data.layers) {
747
+ files.append(node('div', { class: ui.settingsLayerRow }, [
748
+ node('span', { class: ui.scopeBadge(layer.scope), text: layer.scope }),
749
+ node('span', { class: ui.settingsLayerValue(layer.status === 'ok'), text: layer.status }),
750
+ node('span', { class: ui.settingsLayerFile, text: layer.file }),
751
+ ]));
752
+ }
753
+ container.append(files);
754
+ }
755
+
756
+ const byteLength = (text) => new TextEncoder().encode(text).length;
757
+
758
+ function indexEntryAt(lineIndex) {
759
+ return state.store.index?.entries.find((entry) => entry.index === lineIndex) || null;
760
+ }
761
+
762
+ function sectionsAbove(limit) {
763
+ const seen = [];
764
+ for (const line of state.store.index?.lines || []) {
765
+ if (line.kind !== 'heading') continue;
766
+ if (limit !== null && line.index >= limit) continue;
767
+ if (line.section && !seen.includes(line.section)) seen.push(line.section);
768
+ }
769
+ return seen;
770
+ }
771
+
772
+ async function runIndexEdit(action, body, message) {
773
+ try {
774
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/${action}`, {
775
+ method: 'POST',
776
+ body: JSON.stringify(body),
777
+ });
778
+ await openStore(state.storeId, { keepTab: true });
779
+ toast(message(result), { action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) } });
780
+ return result;
781
+ } catch (err) {
782
+ toast(err.message, { error: true });
783
+ return null;
784
+ }
785
+ }
786
+
787
+ function openHookEditor(row, lineIndex) {
788
+ const entry = indexEntryAt(lineIndex);
789
+ if (!entry) return toast('MEMORY.md has changed - reload and try again', { error: true });
790
+
791
+ const stats = state.store.stats.index;
792
+ const editor = node('div', { class: ui.hookEditor });
793
+ const field = node('textarea', { class: ui.textArea, spellcheck: 'false' });
794
+ field.value = entry.hook;
795
+
796
+ const counter = node('span');
797
+ const projection = node('span');
798
+ const readout = node('div', { class: ui.charCount }, [counter, projection]);
799
+
800
+ const save = node('button', { class: ui.buttonPrimarySmall, text: 'Save' });
801
+ const cancel = node('button', { class: ui.buttonSmall, text: 'Cancel', onclick: () => renderTab() });
802
+
803
+ function update() {
804
+ const next = field.value.trim();
805
+ const delta = byteLength(next) - byteLength(entry.hook);
806
+ const projected = Math.max(stats.linePercent, ((stats.bytes + delta) / stats.byteLimit) * 100);
807
+
808
+ counter.textContent = `${next.length} characters, was ${entry.hook.length}`;
809
+ projection.textContent = `index ${Math.round(stats.worstPercent)}% → ${Math.round(projected)}%`;
810
+ readout.className = next.length > 200 ? ui.charCountOver : ui.charCount;
811
+ save.disabled = next === entry.hook;
812
+ }
813
+
814
+ field.addEventListener('input', update);
815
+ save.onclick = () => runIndexEdit(
816
+ 'index/hook',
817
+ { lineIndex, expectedText: entry.text, hook: field.value },
818
+ () => 'Hook shortened',
819
+ );
820
+
821
+ editor.append(
822
+ node('div', { class: ui.willTitle, text: 'Hook' }),
823
+ field,
824
+ readout,
825
+ node('div', { class: ui.hookEditorFoot }, [cancel, save]),
826
+ );
827
+ row.textContent = '';
828
+ row.append(editor);
829
+ update();
830
+ field.focus();
831
+ field.select();
832
+ }
833
+
834
+ function openMoveDialog(entry) {
835
+ const current = indexEntryAt(entry.index);
836
+ if (!current) return toast('MEMORY.md has changed - reload and try again', { error: true });
837
+
838
+ const { cutoff } = state.store.stats.index;
839
+ const sections = sectionsAbove(cutoff ? cutoff.rawLine : null);
840
+ const picker = node('select', { class: ui.select });
841
+ picker.append(node('option', { value: '', text: 'Top of MEMORY.md' }));
842
+ for (const section of sections) picker.append(node('option', { value: section, text: `Start of "${section}"` }));
843
+
844
+ const body = node('div', { class: ui.modalBody }, [
845
+ node('p', { class: ui.noteTight, text: `"${current.title}" sits at line ${current.index + 1}, past the cutoff at line ${cutoff ? cutoff.rawLine + 1 : '-'}, so Claude never loads it. Moving it up puts it back inside the loaded part of the index - which pushes whatever is now last past the cutoff instead.` }),
846
+ node('div', { class: ui.willBlock }, [
847
+ node('div', { class: ui.willTitle, text: 'Will move' }),
848
+ node('div', { class: ui.willItem('keep'), text: current.text }),
849
+ ]),
850
+ node('label', { class: ui.willTitle, text: 'Move to' }),
851
+ picker,
852
+ ]);
853
+
854
+ const modal = node('div', { class: ui.modal }, [
855
+ node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Move this entry above the cutoff' })]),
856
+ body,
857
+ node('footer', { class: ui.modalFoot }, [
858
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
859
+ node('button', {
860
+ class: ui.button({ tone: 'primary' }),
861
+ text: 'Move',
862
+ onclick: async () => {
863
+ closeModal();
864
+ await runIndexEdit(
865
+ 'index/move',
866
+ picker.value
867
+ ? { lineIndex: current.index, expectedText: current.text, section: picker.value }
868
+ : { lineIndex: current.index, expectedText: current.text, top: true },
869
+ () => 'Entry moved',
870
+ );
871
+ },
872
+ }),
873
+ ]),
874
+ ]);
875
+
876
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
877
+ el('modal-root').append(backdrop);
878
+ }
879
+
880
+ async function openAddEntryDialog(file) {
881
+ let preview;
882
+ try {
883
+ preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/index/add-preview`, {
884
+ method: 'POST',
885
+ body: JSON.stringify({ file }),
886
+ });
887
+ } catch (err) {
888
+ return toast(err.message, { error: true });
889
+ }
890
+
891
+ const title = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
892
+ title.value = preview.name;
893
+ const hook = node('textarea', { class: ui.textArea, spellcheck: 'false' });
894
+ hook.value = preview.description;
895
+
896
+ const picker = node('select', { class: ui.select });
897
+ for (const section of preview.sections) picker.append(node('option', { value: section, text: section }));
898
+ picker.append(node('option', { value: '', text: 'End of MEMORY.md' }));
899
+ if (preview.sections.length) picker.value = preview.sections[preview.sections.length - 1];
900
+
901
+ const line = node('div', { class: ui.willItem('keep') });
902
+ const update = () => {
903
+ const label = title.value.trim().replace(/[[\]]/g, '') || preview.name;
904
+ const text = hook.value.trim();
905
+ line.textContent = text ? `- [${label}](${file}) — ${text}` : `- [${label}](${file})`;
906
+ };
907
+ title.addEventListener('input', update);
908
+ hook.addEventListener('input', update);
909
+ update();
910
+
911
+ const body = node('div', { class: ui.modalBody }, [
912
+ node('p', { class: ui.noteTight, text: preview.hasIndex
913
+ ? 'MEMORY.md is loaded at the start of every session, so a memory with no bullet here is one Claude never sees. This adds one line and nothing else.'
914
+ : 'This project has no MEMORY.md yet. Adding this entry creates one.' }),
915
+ node('label', { class: ui.willTitle, text: 'Title' }),
916
+ title,
917
+ node('label', { class: ui.willTitle, text: 'Hook' }),
918
+ hook,
919
+ node('label', { class: ui.willTitle, text: 'Section' }),
920
+ picker,
921
+ node('div', { class: ui.willBlock }, [
922
+ node('div', { class: ui.willTitle, text: 'Will be added' }),
923
+ line,
924
+ ]),
925
+ ]);
926
+
927
+ const modal = node('div', { class: ui.modal }, [
928
+ node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Add ${file} to MEMORY.md` })]),
929
+ body,
930
+ node('footer', { class: ui.modalFoot }, [
931
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
932
+ node('button', {
933
+ class: ui.button({ tone: 'primary' }),
934
+ text: 'Add entry',
935
+ onclick: async () => {
936
+ closeModal();
937
+ await runIndexEdit(
938
+ 'index/add',
939
+ { file, section: picker.value || null, title: title.value, hook: hook.value },
940
+ () => 'Added to MEMORY.md',
941
+ );
942
+ },
943
+ }),
944
+ ]),
945
+ ]);
946
+
947
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
948
+ el('modal-root').append(backdrop);
949
+ title.focus();
950
+ title.select();
951
+ }
952
+
953
+ async function openMergeDialog(into, from) {
954
+ let preview;
955
+ try {
956
+ preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge-preview`, {
957
+ method: 'POST',
958
+ body: JSON.stringify({ into, from }),
959
+ });
960
+ } catch (err) {
961
+ return toast(err.message, { error: true });
962
+ }
963
+
964
+ const heading = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
965
+ heading.value = preview.heading;
966
+
967
+ const body = node('div', { class: ui.modalBody });
968
+ body.append(node('p', { class: ui.noteTight, text: `Everything in "${preview.fromName}" moves into "${preview.intoName}" under a new heading, and "${preview.fromName}" goes to the trash. This rewrites prose, which nothing else in this app does - the whole operation is one undo.` }));
969
+
970
+ const changes = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will change' })]);
971
+ changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} + ${preview.bodyLines} line(s) from ${preview.from}` }));
972
+ for (const entry of preview.inbound) {
973
+ changes.append(node('div', { class: ui.willItem('keep'), text: `${entry.file} ${entry.targets.map((t) => `[[${t}]]`).join(' ')} → [[${preview.intoName}]]` }));
974
+ }
975
+ for (const link of preview.selfLinks) {
976
+ changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} [[${link}]] → plain text, to avoid a self-link` }));
977
+ }
978
+ body.append(changes);
979
+
980
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
981
+ removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.from} → memory/.trash/` }));
982
+ for (const line of preview.indexLines) {
983
+ removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
984
+ }
985
+ if (!preview.indexLines.length) {
986
+ removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for the source - nothing to remove there.' }));
987
+ }
988
+ body.append(removals);
989
+
990
+ if (preview.inlineRefs.length) {
991
+ const kept = node('div', { class: ui.willBlock }, [
992
+ node('div', { class: ui.willTitle, text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' }),
993
+ ]);
994
+ for (const ref of preview.inlineRefs) {
995
+ kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
996
+ }
997
+ body.append(kept);
998
+ }
999
+
1000
+ body.append(node('label', { class: ui.willTitle, text: 'Heading for the merged section' }), heading);
1001
+
1002
+ const modal = node('div', { class: ui.modal }, [
1003
+ node('header', { class: ui.modalHead }, [
1004
+ node('h3', { class: ui.modalTitle, text: `Merge "${preview.fromName}" into "${preview.intoName}"?` }),
1005
+ ]),
1006
+ body,
1007
+ node('footer', { class: ui.modalFoot }, [
1008
+ node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1009
+ node('button', {
1010
+ class: ui.button({ tone: 'primary' }),
1011
+ text: 'Merge',
1012
+ onclick: async () => {
1013
+ closeModal();
1014
+ try {
1015
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge`, {
1016
+ method: 'POST',
1017
+ body: JSON.stringify({ into, from, heading: heading.value }),
1018
+ });
1019
+ if (state.selected === from) state.selected = into;
1020
+ await openStore(state.storeId, { keepTab: true });
1021
+ toast(`Merged into ${preview.intoName}`, {
1022
+ action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
1023
+ });
1024
+ } catch (err) {
1025
+ toast(err.message, { error: true });
1026
+ }
1027
+ },
1028
+ }),
1029
+ ]),
1030
+ ]);
1031
+
1032
+ const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1033
+ el('modal-root').append(backdrop);
1034
+ heading.focus();
1035
+ heading.select();
1036
+ }
1037
+
608
1038
  async function restoreFromTrash(id) {
609
1039
  try {
610
1040
  const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/restore`, {
@@ -620,6 +1050,12 @@ async function restoreFromTrash(id) {
620
1050
  }
621
1051
  }
622
1052
 
1053
+ const INDEX_EDIT_LABELS = {
1054
+ hook: 'MEMORY.md hook shortened',
1055
+ move: 'MEMORY.md entry moved',
1056
+ add: 'MEMORY.md entry added',
1057
+ };
1058
+
623
1059
  function renderTrash(container) {
624
1060
  const trash = state.store.trash;
625
1061
  if (!trash.length) {
@@ -630,7 +1066,11 @@ function renderTrash(container) {
630
1066
  const when = String(record.deletedAt).replace('T', ' ').replace(/\..*$/, '');
631
1067
  const detail = record.kind === 'wikilink'
632
1068
  ? `unlinked in ${record.sourceFile} · ${when}`
633
- : `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
1069
+ : record.kind === 'index-edit'
1070
+ ? `${INDEX_EDIT_LABELS[record.op] || 'MEMORY.md edited'} · ${when}`
1071
+ : record.kind === 'merge'
1072
+ ? `merged into ${record.into} · ${when} · ${record.backups?.length || 0} file(s) rewritten`
1073
+ : `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
634
1074
  container.append(node('div', { class: ui.issue(!record.present) }, [
635
1075
  node('div', { class: ui.issueBody }, [
636
1076
  node('div', { class: ui.issueTitle, text: record.label || record.id }),
@@ -950,6 +1390,7 @@ function renderBudget(container) {
950
1390
  node('div', { class: ui.issueDetail, text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
951
1391
  ]),
952
1392
  node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(entry.file) }),
1393
+ node('button', { class: ui.buttonPrimarySmall, text: 'Move up', onclick: () => openMoveDialog(entry) }),
953
1394
  ]));
954
1395
  }
955
1396
  if (dropped.length > 20) {
@@ -1048,13 +1489,18 @@ function renderPrune(container) {
1048
1489
  container.append(node('div', { class: ui.sectionLabel, text: `Long index hooks · ${longHooks.length}` }));
1049
1490
  container.append(node('p', { class: ui.noteTight, text: 'The hook is the text after the dash in MEMORY.md. It is loaded every session, so a 400-character hook costs more than the memory it points at. Shortening these is the cheapest win.' }));
1050
1491
  for (const hook of longHooks.slice(0, 12)) {
1051
- container.append(node('div', { class: ui.issue(false) }, [
1492
+ const row = node('div', { class: ui.issue(false) });
1493
+ const editButton = node('button', { class: ui.buttonPrimarySmall, text: 'Edit hook' });
1494
+ row.append(
1052
1495
  node('div', { class: ui.issueBody }, [
1053
1496
  node('div', { class: ui.issueTitle, text: `${hook.hookLength} chars: ${hook.title}` }),
1054
1497
  node('div', { class: ui.issueDetail, text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
1055
1498
  ]),
1056
1499
  node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(hook.file) }),
1057
- ]));
1500
+ editButton,
1501
+ );
1502
+ editButton.onclick = () => openHookEditor(row, hook.index);
1503
+ container.append(row);
1058
1504
  }
1059
1505
  }
1060
1506
 
@@ -1076,6 +1522,18 @@ function renderPrune(container) {
1076
1522
  node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
1077
1523
  ])),
1078
1524
  ]),
1525
+ node('div', { class: ui.dupeActions }, [
1526
+ node('button', {
1527
+ class: ui.buttonSmall,
1528
+ text: `Merge into ${pair.a.name}`,
1529
+ onclick: () => openMergeDialog(pair.a.file, pair.b.file),
1530
+ }),
1531
+ node('button', {
1532
+ class: ui.buttonSmall,
1533
+ text: `Merge into ${pair.b.name}`,
1534
+ onclick: () => openMergeDialog(pair.b.file, pair.a.file),
1535
+ }),
1536
+ ]),
1079
1537
  ]));
1080
1538
  }
1081
1539
  }
@@ -1250,6 +1708,7 @@ function renderTab() {
1250
1708
  else if (state.tab === 'context') renderContext(container);
1251
1709
  else if (state.tab === 'index') renderIndex(container);
1252
1710
  else if (state.tab === 'health') renderHealth(container);
1711
+ else if (state.tab === 'settings') renderSettings(container);
1253
1712
  else if (state.tab === 'trash') renderTrash(container);
1254
1713
  else if (state.tab === 'graph') {
1255
1714
  const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
@@ -1289,10 +1748,17 @@ async function reloadStores() {
1289
1748
  function renderStoreHeader() {
1290
1749
  const store = state.store;
1291
1750
  el('project-title').textContent = store.label;
1751
+ el('delete-project').hidden = store.kind === 'global';
1292
1752
 
1293
1753
  const sub = el('project-sub');
1294
1754
  sub.textContent = '';
1295
1755
 
1756
+ if (store.kind === 'global') {
1757
+ sub.append(node('span', { text: store.dir }));
1758
+ sub.append(node('span', { class: ui.subNote, text: 'instructions every session loads, before any project is chosen' }));
1759
+ return;
1760
+ }
1761
+
1296
1762
  if (store.kind !== 'auto') {
1297
1763
  const scope = store.kind === 'agent-user' ? 'user'
1298
1764
  : store.kind === 'agent-project' ? 'project' : 'local';
@@ -1333,7 +1799,8 @@ async function openStore(id, { keepTab = false } = {}) {
1333
1799
  if (id !== state.storeId) state.pruneSelection.clear();
1334
1800
  state.storeId = id;
1335
1801
  if (!keepTab) {
1336
- state.tab = 'memories';
1802
+ const opening = state.stores.find((store) => store.id === id);
1803
+ state.tab = opening && opening.kind === 'global' ? 'context' : 'memories';
1337
1804
  state.selected = null;
1338
1805
  }
1339
1806
  renderStores();