claude-memory-admin 1.1.0 → 1.3.0

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