claude-memory-admin 1.5.1 → 1.5.2

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/bus.mjs ADDED
@@ -0,0 +1,7 @@
1
+ const regions = new Map();
2
+
3
+ export const register = (name, render) => regions.set(name, render);
4
+
5
+ export const paint = (...names) => {
6
+ for (const name of names) regions.get(name)?.();
7
+ };
@@ -0,0 +1,69 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { openDialog } from '/dialog.mjs';
4
+ import { api, toast } from '/api.mjs';
5
+ import { state } from '/state.mjs';
6
+ import { runIndexEdit } from '/views/index.mjs';
7
+
8
+ export async function openAddEntryDialog(file) {
9
+ let preview;
10
+ try {
11
+ preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/index/add-preview`, {
12
+ method: 'POST',
13
+ body: JSON.stringify({ file }),
14
+ });
15
+ } catch (err) {
16
+ return toast(err.message, { error: true });
17
+ }
18
+
19
+ const titleInput = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
20
+ titleInput.value = preview.name;
21
+ const hook = node('textarea', { class: ui.textArea, spellcheck: 'false' });
22
+ hook.value = preview.description;
23
+
24
+ const picker = node('select', { class: ui.select });
25
+ for (const section of preview.sections) picker.append(node('option', { value: section, text: section }));
26
+ picker.append(node('option', { value: '', text: 'End of MEMORY.md' }));
27
+ if (preview.sections.length) picker.value = preview.sections[preview.sections.length - 1];
28
+
29
+ const line = node('div', { class: ui.willItem('keep') });
30
+ const update = () => {
31
+ const label = titleInput.value.trim().replace(/[[\]]/g, '') || preview.name;
32
+ const text = hook.value.trim();
33
+ line.textContent = text ? `- [${label}](${file}) — ${text}` : `- [${label}](${file})`;
34
+ };
35
+ titleInput.addEventListener('input', update);
36
+ hook.addEventListener('input', update);
37
+ update();
38
+
39
+ const go = await openDialog({
40
+ title: `Add ${file} to MEMORY.md`,
41
+ body: [
42
+ node('p', { class: ui.noteTight, text: preview.hasIndex
43
+ ? '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.'
44
+ : 'This project has no MEMORY.md yet. Adding this entry creates one.' }),
45
+ node('label', { class: ui.willTitle, text: 'Title' }),
46
+ titleInput,
47
+ node('label', { class: ui.willTitle, text: 'Hook' }),
48
+ hook,
49
+ node('label', { class: ui.willTitle, text: 'Section' }),
50
+ picker,
51
+ node('div', { class: ui.willBlock }, [
52
+ node('div', { class: ui.willTitle, text: 'Will be added' }),
53
+ line,
54
+ ]),
55
+ ],
56
+ actions: [
57
+ { label: 'Cancel' },
58
+ { label: 'Add entry', tone: 'primary', value: true },
59
+ ],
60
+ focus: titleInput,
61
+ });
62
+ if (!go) return;
63
+
64
+ await runIndexEdit(
65
+ 'index/add',
66
+ { file, section: picker.value || null, title: titleInput.value, hook: hook.value },
67
+ () => 'Added to MEMORY.md',
68
+ );
69
+ }
@@ -0,0 +1,58 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { openDialog } from '/dialog.mjs';
4
+ import { api, toast } from '/api.mjs';
5
+ import { state } from '/state.mjs';
6
+ import { openStore, restoreFromTrash } from '/store.mjs';
7
+
8
+ export async function openBulkDeleteDialog(files) {
9
+ if (!files.length) return;
10
+ const memories = files.map((file) => state.store.memories.find((m) => m.file === file)).filter(Boolean);
11
+
12
+ const body = [];
13
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${memories.length} memories will be trashed` })]);
14
+ for (const memory of memories) {
15
+ removals.append(node('div', { class: ui.willItem('remove'), text: `${memory.file}${memory.entry ? ` · MEMORY.md line ${memory.entry.index + 1}` : ' · not in the index'}` }));
16
+ }
17
+ body.push(removals);
18
+
19
+ const doomed = new Set(files);
20
+ const breaking = [];
21
+ for (const memory of memories) {
22
+ for (const link of memory.inbound) {
23
+ if (!doomed.has(link.from)) breaking.push({ from: link.from, target: link.target });
24
+ }
25
+ }
26
+ if (breaking.length) {
27
+ const block = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${breaking.length} link(s) from memories you are keeping will break` })]);
28
+ for (const link of breaking) block.append(node('div', { class: ui.willItem('keep'), text: `${link.from} → [[${link.target}]]` }));
29
+ body.push(block);
30
+ }
31
+
32
+ body.push(node('p', { class: ui.noteTight, text: 'All of them go to memory/.trash/ as one restore point.' }));
33
+
34
+ const go = await openDialog({
35
+ title: `Delete ${memories.length} memories?`,
36
+ body,
37
+ actions: [
38
+ { label: 'Cancel' },
39
+ { label: `Delete ${memories.length}`, tone: 'danger', value: true },
40
+ ],
41
+ });
42
+ if (!go) return;
43
+
44
+ try {
45
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete-many`, {
46
+ method: 'POST',
47
+ body: JSON.stringify({ files, label: `${memories.length} pruned memories` }),
48
+ });
49
+ state.pruneSelection.clear();
50
+ if (doomed.has(state.selected)) state.selected = null;
51
+ await openStore(state.storeId, { keepTab: true });
52
+ toast(`Deleted ${memories.length} memories`, {
53
+ action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
54
+ });
55
+ } catch (err) {
56
+ toast(err.message, { error: true });
57
+ }
58
+ }
@@ -0,0 +1,114 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { openDialog } from '/dialog.mjs';
4
+ import { api, toast } from '/api.mjs';
5
+ import { state } from '/state.mjs';
6
+ import { openStore, restoreFromTrash } from '/store.mjs';
7
+
8
+ export async function openDeleteDialog(file) {
9
+ let preview;
10
+ try {
11
+ preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete-preview`, {
12
+ method: 'POST',
13
+ body: JSON.stringify({ file }),
14
+ });
15
+ } catch (err) {
16
+ return toast(err.message, { error: true });
17
+ }
18
+
19
+ const body = [];
20
+
21
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
22
+ removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.file} → memory/.trash/` }));
23
+ for (const line of [...preview.indexLines, ...preview.continuations]) {
24
+ removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
25
+ }
26
+ if (!preview.indexLines.length && preview.hasIndex) {
27
+ removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for this file - nothing to unlink there.' }));
28
+ }
29
+ body.push(removals);
30
+
31
+ if (preview.inlineRefs.length) {
32
+ const kept = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' })]);
33
+ for (const ref of preview.inlineRefs) {
34
+ kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
35
+ }
36
+ body.push(kept);
37
+ }
38
+
39
+ const cascade = new Set();
40
+ if (preview.inboundWikilinks.length) {
41
+ const block = node('div', { class: ui.willBlock });
42
+ const selectAll = node('button', { class: ui.linkButton, text: 'select all' });
43
+ block.append(node('div', { class: ui.cascadeHead }, [
44
+ node('div', { class: ui.willTitle, text: `${preview.inboundWikilinks.length} memory(s) link here and will break` }),
45
+ selectAll,
46
+ ]));
47
+
48
+ const list = node('div', { class: ui.cascadeList });
49
+ const rows = [];
50
+ for (const link of preview.inboundWikilinks) {
51
+ const box = node('input', { type: 'checkbox', class: ui.checkbox });
52
+ const row = node('label', { class: ui.cascadeRow(false) }, [
53
+ box,
54
+ node('span', { class: ui.cascadeText }, [
55
+ node('span', { class: ui.cascadeName, text: link.fromName || link.from }),
56
+ node('span', { class: ui.cascadeDetail, text: link.indexLine
57
+ ? `${link.from} · MEMORY.md line ${link.indexLine.index + 1}`
58
+ : `${link.from} · not in the index` }),
59
+ ]),
60
+ ]);
61
+ box.addEventListener('change', () => {
62
+ row.className = ui.cascadeRow(box.checked);
63
+ if (box.checked) cascade.add(link.from);
64
+ else cascade.delete(link.from);
65
+ updateButton();
66
+ });
67
+ rows.push({ box, row });
68
+ list.append(row);
69
+ }
70
+ block.append(list);
71
+ block.append(node('p', { class: ui.noteTight, text: 'Tick any you also want deleted - they go to the trash together and restore as one step.' }));
72
+ body.push(block);
73
+
74
+ selectAll.onclick = (event) => {
75
+ event.preventDefault();
76
+ const turnOn = rows.some((r) => !r.box.checked);
77
+ for (const { box } of rows) {
78
+ if (box.checked !== turnOn) { box.checked = turnOn; box.dispatchEvent(new Event('change')); }
79
+ }
80
+ selectAll.textContent = turnOn ? 'select none' : 'select all';
81
+ };
82
+ }
83
+
84
+ body.push(node('p', { class: ui.noteTight, text: 'The file moves to memory/.trash/ with a restore record, so this can be undone.' }));
85
+
86
+ const remove = { label: 'Delete', tone: 'danger', value: true };
87
+ const answer = openDialog({
88
+ title: `Delete "${preview.name || preview.file}"?`,
89
+ subtitle: preview.description || '',
90
+ body,
91
+ actions: [{ label: 'Cancel' }, remove],
92
+ });
93
+ updateButton();
94
+ if (!await answer) return;
95
+
96
+ try {
97
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete`, {
98
+ method: 'POST',
99
+ body: JSON.stringify({ file, alsoDelete: [...cascade] }),
100
+ });
101
+ if (state.selected === file || cascade.has(state.selected)) state.selected = null;
102
+ await openStore(state.storeId, { keepTab: true });
103
+ const count = result.record.files.length;
104
+ toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
105
+ action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
106
+ });
107
+ } catch (err) {
108
+ toast(err.message, { error: true });
109
+ }
110
+
111
+ function updateButton() {
112
+ remove.el.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
113
+ }
114
+ }
@@ -0,0 +1,54 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { state } from '/state.mjs';
4
+ import { toast } from '/api.mjs';
5
+ import { paint } from '/bus.mjs';
6
+ import { byteLength } from '/parts.mjs';
7
+ import { indexEntryAt, runIndexEdit } from '/views/index.mjs';
8
+
9
+ export function openHookEditor(row, lineIndex) {
10
+ const entry = indexEntryAt(lineIndex);
11
+ if (!entry) return toast('MEMORY.md has changed - reload and try again', { error: true });
12
+
13
+ const stats = state.store.stats.index;
14
+ const editor = node('div', { class: ui.hookEditor });
15
+ const field = node('textarea', { class: ui.textArea, spellcheck: 'false' });
16
+ field.value = entry.hook;
17
+
18
+ const counter = node('span');
19
+ const projection = node('span');
20
+ const readout = node('div', { class: ui.charCount }, [counter, projection]);
21
+
22
+ const save = node('button', { class: ui.buttonPrimarySmall, text: 'Save' });
23
+ const cancel = node('button', { class: ui.buttonSmall, text: 'Cancel', onclick: () => paint('tab') });
24
+
25
+ function update() {
26
+ const next = field.value.trim();
27
+ const delta = byteLength(next) - byteLength(entry.hook);
28
+ const projected = Math.max(stats.linePercent, ((stats.bytes + delta) / stats.byteLimit) * 100);
29
+
30
+ counter.textContent = `${next.length} characters, was ${entry.hook.length}`;
31
+ projection.textContent = `index ${Math.round(stats.worstPercent)}% → ${Math.round(projected)}%`;
32
+ readout.className = next.length > 200 ? ui.charCountOver : ui.charCount;
33
+ save.disabled = next === entry.hook;
34
+ }
35
+
36
+ field.addEventListener('input', update);
37
+ save.onclick = () => runIndexEdit(
38
+ 'index/hook',
39
+ { lineIndex, expectedText: entry.text, hook: field.value },
40
+ () => 'Hook shortened',
41
+ );
42
+
43
+ editor.append(
44
+ node('div', { class: ui.willTitle, text: 'Hook' }),
45
+ field,
46
+ readout,
47
+ node('div', { class: ui.hookEditorFoot }, [cancel, save]),
48
+ );
49
+ row.textContent = '';
50
+ row.append(editor);
51
+ update();
52
+ field.focus();
53
+ field.select();
54
+ }
@@ -0,0 +1,81 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { openDialog } from '/dialog.mjs';
4
+ import { api, toast } from '/api.mjs';
5
+ import { state } from '/state.mjs';
6
+ import { openStore, restoreFromTrash } from '/store.mjs';
7
+
8
+ export async function openMergeDialog(into, from) {
9
+ let preview;
10
+ try {
11
+ preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge-preview`, {
12
+ method: 'POST',
13
+ body: JSON.stringify({ into, from }),
14
+ });
15
+ } catch (err) {
16
+ return toast(err.message, { error: true });
17
+ }
18
+
19
+ const heading = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
20
+ heading.value = preview.heading;
21
+
22
+ const body = [];
23
+ body.push(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.` }));
24
+
25
+ const changes = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will change' })]);
26
+ changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} + ${preview.bodyLines} line(s) from ${preview.from}` }));
27
+ for (const entry of preview.inbound) {
28
+ changes.append(node('div', { class: ui.willItem('keep'), text: `${entry.file} ${entry.targets.map((t) => `[[${t}]]`).join(' ')} → [[${preview.intoName}]]` }));
29
+ }
30
+ for (const link of preview.selfLinks) {
31
+ changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} [[${link}]] → plain text, to avoid a self-link` }));
32
+ }
33
+ body.push(changes);
34
+
35
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
36
+ removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.from} → memory/.trash/` }));
37
+ for (const line of preview.indexLines) {
38
+ removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
39
+ }
40
+ if (!preview.indexLines.length) {
41
+ removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for the source - nothing to remove there.' }));
42
+ }
43
+ body.push(removals);
44
+
45
+ if (preview.inlineRefs.length) {
46
+ const kept = node('div', { class: ui.willBlock }, [
47
+ node('div', { class: ui.willTitle, text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' }),
48
+ ]);
49
+ for (const ref of preview.inlineRefs) {
50
+ kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
51
+ }
52
+ body.push(kept);
53
+ }
54
+
55
+ body.push(node('label', { class: ui.willTitle, text: 'Heading for the merged section' }), heading);
56
+
57
+ const go = await openDialog({
58
+ title: `Merge "${preview.fromName}" into "${preview.intoName}"?`,
59
+ body,
60
+ actions: [
61
+ { label: 'Cancel' },
62
+ { label: 'Merge', tone: 'primary', value: true },
63
+ ],
64
+ focus: heading,
65
+ });
66
+ if (!go) return;
67
+
68
+ try {
69
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge`, {
70
+ method: 'POST',
71
+ body: JSON.stringify({ into, from, heading: heading.value }),
72
+ });
73
+ if (state.selected === from) state.selected = into;
74
+ await openStore(state.storeId, { keepTab: true });
75
+ toast(`Merged into ${preview.intoName}`, {
76
+ action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
77
+ });
78
+ } catch (err) {
79
+ toast(err.message, { error: true });
80
+ }
81
+ }
@@ -0,0 +1,43 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { openDialog } from '/dialog.mjs';
4
+ import { state } from '/state.mjs';
5
+ import { toast } from '/api.mjs';
6
+ import { indexEntryAt, sectionsAbove, runIndexEdit } from '/views/index.mjs';
7
+
8
+ export async function openMoveDialog(entry) {
9
+ const current = indexEntryAt(entry.index);
10
+ if (!current) return toast('MEMORY.md has changed - reload and try again', { error: true });
11
+
12
+ const { cutoff } = state.store.stats.index;
13
+ const sections = sectionsAbove(cutoff ? cutoff.rawLine : null);
14
+ const picker = node('select', { class: ui.select });
15
+ picker.append(node('option', { value: '', text: 'Top of MEMORY.md' }));
16
+ for (const section of sections) picker.append(node('option', { value: section, text: `Start of "${section}"` }));
17
+
18
+ const go = await openDialog({
19
+ title: 'Move this entry above the cutoff',
20
+ body: [
21
+ 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.` }),
22
+ node('div', { class: ui.willBlock }, [
23
+ node('div', { class: ui.willTitle, text: 'Will move' }),
24
+ node('div', { class: ui.willItem('keep'), text: current.text }),
25
+ ]),
26
+ node('label', { class: ui.willTitle, text: 'Move to' }),
27
+ picker,
28
+ ],
29
+ actions: [
30
+ { label: 'Cancel' },
31
+ { label: 'Move', tone: 'primary', value: true },
32
+ ],
33
+ });
34
+ if (!go) return;
35
+
36
+ await runIndexEdit(
37
+ 'index/move',
38
+ picker.value
39
+ ? { lineIndex: current.index, expectedText: current.text, section: picker.value }
40
+ : { lineIndex: current.index, expectedText: current.text, top: true },
41
+ () => 'Entry moved',
42
+ );
43
+ }
@@ -0,0 +1,65 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { openDialog } from '/dialog.mjs';
4
+ import { api, toast } from '/api.mjs';
5
+ import { state } from '/state.mjs';
6
+ import { openStore, reloadStores } from '/store.mjs';
7
+
8
+ export async function openRememberPathDialog() {
9
+ const store = state.store;
10
+ const input = node('input', {
11
+ type: 'text',
12
+ class: ui.pathInput,
13
+ value: store.guess || '',
14
+ placeholder: '/Users/you/repos/the-project',
15
+ spellcheck: 'false',
16
+ });
17
+
18
+ const remembered = await openDialog({
19
+ title: 'Remember this project\u2019s path',
20
+ body: [
21
+ node('p', { class: ui.note, text: `The folder on disk is "${store.slug}", and its name is a lossy encoding of a path. No session transcript remains to recover the real one from, so tell it once and it will be used from now on.` }),
22
+ input,
23
+ node('p', { class: ui.noteTight, text: 'Saved to ~/.claude-memory-admin/paths.json. That file records slugs and folder paths only, never memory content, and is the one thing this app writes outside a memory/ directory.' }),
24
+ ],
25
+ actions: [
26
+ { label: 'Cancel' },
27
+ {
28
+ label: 'Remember',
29
+ tone: 'primary',
30
+ value: true,
31
+ guard: async () => {
32
+ const value = input.value.trim();
33
+ if (!value) return false;
34
+ try {
35
+ await api(`/api/stores/${encodeURIComponent(state.storeId)}/path/remember`, {
36
+ method: 'POST',
37
+ body: JSON.stringify({ path: value }),
38
+ });
39
+ } catch (err) {
40
+ toast(err.message, { error: true });
41
+ return false;
42
+ }
43
+ return true;
44
+ },
45
+ },
46
+ ],
47
+ focus: input,
48
+ });
49
+ if (!remembered) return;
50
+
51
+ await reloadStores();
52
+ await openStore(state.storeId, { keepTab: true });
53
+ toast(`Remembered ${input.value.trim()}`);
54
+ }
55
+
56
+ export async function forgetProjectPath() {
57
+ try {
58
+ await api(`/api/stores/${encodeURIComponent(state.storeId)}/path/forget`, { method: 'POST' });
59
+ } catch (err) {
60
+ return toast(err.message, { error: true });
61
+ }
62
+ await reloadStores();
63
+ await openStore(state.storeId, { keepTab: true });
64
+ toast('Forgot the remembered path');
65
+ }
@@ -0,0 +1,64 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { openDialog } from '/dialog.mjs';
4
+ import { api, toast } from '/api.mjs';
5
+ import { state } from '/state.mjs';
6
+ import { openStore, restoreFromTrash } from '/store.mjs';
7
+
8
+ export async function openStoreDeleteDialog() {
9
+ let preview;
10
+ try {
11
+ preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/project/delete-preview`, { method: 'POST' });
12
+ } catch (err) {
13
+ return toast(err.message, { error: true });
14
+ }
15
+
16
+ if (!preview.files.length && !preview.hasIndex) {
17
+ return toast('This store has no memory to delete');
18
+ }
19
+
20
+ const body = [];
21
+ const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
22
+ if (preview.hasIndex) {
23
+ removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md (${preview.indexLines} lines) → memory/.trash/` }));
24
+ }
25
+ for (const entry of preview.files) {
26
+ removals.append(node('div', { class: ui.willItem('remove'), text: `${entry.file} → memory/.trash/` }));
27
+ }
28
+ body.push(removals);
29
+
30
+ body.push(node('div', { class: ui.willBlock }, [
31
+ node('div', { class: ui.willTitle, text: 'Will be kept' }),
32
+ node('div', { class: ui.willItem('keep'), text: state.store.kind === 'auto'
33
+ ? 'Session transcripts (*.jsonl) and the project folder itself'
34
+ : 'The agent-memory folder itself, and every other agent\u2019s memory' }),
35
+ ]));
36
+
37
+ body.push(node('p', { class: ui.noteTight, text: 'Everything moves to .trash/ inside the store as one restore point, so it can be put back in a single step from the Trash tab.' }));
38
+
39
+ if (state.store.kind === 'agent-project') {
40
+ body.push(node('p', { class: ui.subWarn, text: 'This store is checked into the repository. Deleting from it changes tracked files, and will show up in git status.' }));
41
+ }
42
+
43
+ const go = await openDialog({
44
+ title: `Delete all memory for ${state.store.label}?`,
45
+ subtitle: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}`,
46
+ body,
47
+ actions: [
48
+ { label: 'Cancel' },
49
+ { label: `Delete ${preview.files.length + (preview.hasIndex ? 1 : 0)} files`, tone: 'danger', value: true },
50
+ ],
51
+ });
52
+ if (!go) return;
53
+
54
+ try {
55
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/project/delete`, { method: 'POST' });
56
+ state.selected = null;
57
+ await openStore(state.storeId, { keepTab: true });
58
+ toast(`Cleared ${state.store.label}`, {
59
+ action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
60
+ });
61
+ } catch (err) {
62
+ toast(err.message, { error: true });
63
+ }
64
+ }
@@ -0,0 +1,58 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { state } from '/state.mjs';
4
+ import { paint } from '/bus.mjs';
5
+
6
+ export function issue(title, detail, { bad = false, action, secondary } = {}) {
7
+ return node('div', { class: ui.issue(bad) }, [
8
+ node('div', { class: ui.issueBody }, [
9
+ node('div', { class: ui.issueTitle, text: title }),
10
+ node('div', { class: ui.issueDetail, text: detail }),
11
+ ]),
12
+ secondary ? node('button', { class: ui.buttonSmall, text: secondary.label, onclick: secondary.run }) : null,
13
+ action ? node('button', { class: ui.buttonSmall, text: action.label, onclick: action.run }) : null,
14
+ ]);
15
+ }
16
+
17
+ export function goToTab(id) {
18
+ state.tab = id;
19
+ paint('tabs', 'tab');
20
+ }
21
+
22
+ export function cutMarker(cutoff) {
23
+ return node('div', { class: ui.cutLine }, [
24
+ node('span', { class: ui.cutLabel, text: `not loaded past here — ${cutoff.droppedLines} lines dropped on ${cutoff.by}` }),
25
+ ]);
26
+ }
27
+
28
+ export function highlight(text, terms) {
29
+ const wrapper = node('span');
30
+ if (!terms.length) { wrapper.textContent = text; return wrapper; }
31
+ const pattern = new RegExp(`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'ig');
32
+ let last = 0;
33
+ for (const match of text.matchAll(pattern)) {
34
+ if (match.index > last) wrapper.append(document.createTextNode(text.slice(last, match.index)));
35
+ wrapper.append(node('mark', { class: ui.resultMark, text: match[0] }));
36
+ last = match.index + match[0].length;
37
+ }
38
+ if (last < text.length) wrapper.append(document.createTextNode(text.slice(last)));
39
+ return wrapper;
40
+ }
41
+
42
+ export function formatAge(days) {
43
+ if (days === null || days === undefined) return 'undated';
44
+ if (days < 1) return 'today';
45
+ if (days < 31) return `${days}d`;
46
+ if (days < 365) return `${Math.round(days / 30)}mo`;
47
+ return `${(days / 365).toFixed(1)}y`;
48
+ }
49
+
50
+ export const sizeLabel = (bytes) => (bytes >= 1048576
51
+ ? `${(bytes / 1048576).toFixed(1)} MB`
52
+ : `${(bytes / 1024).toFixed(1)} KB`);
53
+
54
+ export function sessionDate(iso) {
55
+ return iso ? iso.replace('T', ' ').replace(/\..*$/, '') : 'unknown';
56
+ }
57
+
58
+ export const byteLength = (text) => new TextEncoder().encode(text).length;
@@ -0,0 +1,22 @@
1
+ export const state = {
2
+ stores: [],
3
+ storeId: null,
4
+ store: null,
5
+ tab: 'memories',
6
+ selected: null,
7
+ showAll: false,
8
+ collapsed: localStorage.getItem('sidebarCollapsed') === '1',
9
+ theme: document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light',
10
+ spread: Number(localStorage.getItem('graphSpread')) || 1.6,
11
+ query: '',
12
+ search: null,
13
+ pruneSort: 'oldest',
14
+ pruneSelection: new Set(),
15
+ indexView: localStorage.getItem('memoryIndexView') === 'source' ? 'source' : 'rendered',
16
+ aux: { instructions: null, settings: null, sessions: null, sessionDay: null },
17
+ pathCheck: null,
18
+ };
19
+
20
+ const RANK = { ok: 0, warn: 1, bad: 2 };
21
+ export const worst = (...severities) => severities.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), 'ok');
22
+ export const worstSeverity = (items) => (!items.length ? 'ok' : items.some((item) => item.severity === 'bad') ? 'bad' : 'warn');