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/README.md +79 -11
- package/package.json +1 -1
- package/public/app.mjs +521 -20
- package/public/index.html +7 -4
- package/public/styles.css +1 -1
- package/public/ui.mjs +21 -1
- package/server.mjs +82 -5
- package/src/instructions.mjs +123 -21
- package/src/model.mjs +4 -1
- package/src/mutate.mjs +355 -1
- package/src/parse.mjs +176 -0
- package/src/settings.mjs +120 -19
- package/src/stores.mjs +34 -4
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
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
:
|
|
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
|
-
|
|
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.
|
|
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();
|