claude-memory-admin 1.5.1 → 1.6.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.
Files changed (39) hide show
  1. package/README.md +79 -124
  2. package/package.json +1 -1
  3. package/public/api.mjs +26 -0
  4. package/public/app.mjs +35 -2270
  5. package/public/bus.mjs +7 -0
  6. package/public/dialog.mjs +7 -0
  7. package/public/dialogs/add-entry.mjs +69 -0
  8. package/public/dialogs/bulk-delete.mjs +59 -0
  9. package/public/dialogs/delete.mjs +114 -0
  10. package/public/dialogs/hook-editor.mjs +54 -0
  11. package/public/dialogs/merge.mjs +81 -0
  12. package/public/dialogs/move.mjs +43 -0
  13. package/public/dialogs/remember-path.mjs +65 -0
  14. package/public/dialogs/store-delete.mjs +64 -0
  15. package/public/dialogs/trash.mjs +53 -0
  16. package/public/index.html +1 -0
  17. package/public/parts.mjs +65 -0
  18. package/public/state.mjs +48 -0
  19. package/public/store.mjs +121 -0
  20. package/public/styles.css +1 -1
  21. package/public/ui.mjs +44 -16
  22. package/public/views/cleanup.mjs +63 -0
  23. package/public/views/context.mjs +121 -0
  24. package/public/views/cost-issue.mjs +70 -0
  25. package/public/views/environment.mjs +12 -0
  26. package/public/views/header.mjs +183 -0
  27. package/public/views/index.mjs +122 -0
  28. package/public/views/issue.mjs +262 -0
  29. package/public/views/memories.mjs +156 -0
  30. package/public/views/memory-list.mjs +121 -0
  31. package/public/views/memory.mjs +32 -0
  32. package/public/views/path-check.mjs +103 -0
  33. package/public/views/search.mjs +100 -0
  34. package/public/views/segments.mjs +37 -0
  35. package/public/views/sessions.mjs +235 -0
  36. package/public/views/settings.mjs +121 -0
  37. package/public/views/stores.mjs +65 -0
  38. package/public/views/worklist.mjs +33 -0
  39. package/src/sessions.mjs +69 -1
@@ -0,0 +1,262 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { confirmDialog } from '/dialog.mjs';
4
+ import { api, toast } from '/api.mjs';
5
+ import { state } from '/state.mjs';
6
+ import { issue, goTo } from '/parts.mjs';
7
+ import { openStore, selectMemory, restoreFromTrash } from '/store.mjs';
8
+ import { openDeleteDialog } from '/dialogs/delete.mjs';
9
+ import { openAddEntryDialog } from '/dialogs/add-entry.mjs';
10
+ import { openHookEditor } from '/dialogs/hook-editor.mjs';
11
+
12
+ async function removeIndexLine(lineIndex, expectedText, done) {
13
+ const go = await confirmDialog({
14
+ title: `Remove line ${lineIndex + 1} from MEMORY.md?`,
15
+ confirmLabel: 'Remove',
16
+ tone: 'danger',
17
+ body: [
18
+ node('div', { class: ui.willBlock }, [
19
+ node('div', { class: ui.willTitle, text: 'Will be removed' }),
20
+ node('div', { class: ui.willItem('remove'), text: expectedText }),
21
+ ]),
22
+ node('p', { class: ui.noteTight, text: 'Only this line goes. The memory file it points at is left on disk.' }),
23
+ ],
24
+ });
25
+ if (!go) return;
26
+ try {
27
+ await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
28
+ method: 'POST',
29
+ body: JSON.stringify({ lineIndex, expectedText }),
30
+ });
31
+ toast(done);
32
+ await openStore(state.storeId, { keepTab: true });
33
+ } catch (err) {
34
+ toast(err.message, { error: true });
35
+ }
36
+ }
37
+
38
+ export function renderIssue(item, memories) {
39
+ const bad = item.severity === 'bad';
40
+
41
+ if (item.kind === 'dangling-index') {
42
+ const { entry } = item;
43
+ return issue(
44
+ `MEMORY.md points at a file that does not exist: ${entry.file}`,
45
+ `line ${entry.index + 1}: ${entry.text}`,
46
+ {
47
+ bad,
48
+ action: {
49
+ label: 'Remove pointer',
50
+ run: () => removeIndexLine(entry.index, entry.text, 'Pointer removed'),
51
+ },
52
+ },
53
+ );
54
+ }
55
+
56
+ if (item.kind === 'dangling-wikilink') {
57
+ const { link } = item;
58
+ return issue(
59
+ `Broken [[${link.target}]]`,
60
+ `referenced from ${link.from} - no memory has that name`,
61
+ {
62
+ bad,
63
+ secondary: { label: 'Open source', run: () => selectMemory(link.from) },
64
+ action: {
65
+ label: 'Remove link',
66
+ run: async () => {
67
+ const go = await confirmDialog({
68
+ title: `Remove [[${link.target}]] from ${link.from}?`,
69
+ confirmLabel: 'Remove link',
70
+ tone: 'danger',
71
+ body: [
72
+ node('p', { class: ui.noteTight, text: 'The link markup goes, the words stay.' }),
73
+ node('div', { class: ui.willBlock }, [
74
+ node('div', { class: ui.willTitle, text: 'Will change' }),
75
+ node('div', { class: ui.willItem('remove'), text: `see [[${link.target}]]` }),
76
+ node('div', { class: ui.willItem('keep'), text: `see ${link.target}` }),
77
+ ]),
78
+ node('p', { class: ui.noteTight, text: 'A copy of the file is kept in the trash, so this can be undone.' }),
79
+ ],
80
+ });
81
+ if (!go) return;
82
+ try {
83
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/wikilink/remove`, {
84
+ method: 'POST',
85
+ body: JSON.stringify({ file: link.from, target: link.target }),
86
+ });
87
+ await openStore(state.storeId, { keepTab: true });
88
+ toast(`Unlinked ${result.occurrences} reference(s)`, {
89
+ action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
90
+ });
91
+ } catch (err) {
92
+ toast(err.message, { error: true });
93
+ }
94
+ },
95
+ },
96
+ },
97
+ );
98
+ }
99
+
100
+ if (item.kind === 'orphan') {
101
+ const memory = memories.find((m) => m.file === item.file);
102
+ return issue(
103
+ `Not referenced anywhere in MEMORY.md: ${item.file}`,
104
+ `"${memory?.name || item.file}" exists on disk but nothing points to it, so Claude will not load it.`,
105
+ {
106
+ bad,
107
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
108
+ action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
109
+ },
110
+ );
111
+ }
112
+
113
+ if (item.kind === 'referenced-only') {
114
+ const memory = memories.find((m) => m.file === item.file);
115
+ return issue(
116
+ `Linked mid-sentence, not indexed: ${item.file}`,
117
+ `"${memory?.name || item.file}" is mentioned inside prose in MEMORY.md but has no index bullet of its own.`,
118
+ {
119
+ bad,
120
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
121
+ action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
122
+ },
123
+ );
124
+ }
125
+
126
+ if (item.kind === 'name-mismatch') {
127
+ return issue(
128
+ `Frontmatter name differs from filename: ${item.mismatch.file}`,
129
+ `name: "${item.mismatch.name}" - [[wikilinks]] must use this name, not the filename.`,
130
+ { bad, action: { label: 'Open', run: () => selectMemory(item.mismatch.file) } },
131
+ );
132
+ }
133
+
134
+ if (item.kind === 'missing-frontmatter') {
135
+ return issue(
136
+ `No YAML frontmatter: ${item.file}`,
137
+ 'Type and description cannot be read from this file.',
138
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
139
+ );
140
+ }
141
+
142
+ if (item.kind === 'duplicate-name') {
143
+ return issue(
144
+ `Two memories claim the name "${item.name}"`,
145
+ `${item.files.join(', ')} - every [[${item.name}]] resolves to ${item.reachable}, so the other one cannot be linked at all.`,
146
+ {
147
+ bad,
148
+ secondary: { label: 'Open first', run: () => selectMemory(item.files[0]) },
149
+ action: { label: 'Open second', run: () => selectMemory(item.files[1]) },
150
+ },
151
+ );
152
+ }
153
+
154
+ if (item.kind === 'duplicate-index-entry') {
155
+ return issue(
156
+ `Listed twice in MEMORY.md: ${item.file}`,
157
+ `lines ${item.lines.map((line) => line + 1).join(' and ')} - one memory, two index lines, both loaded every session.`,
158
+ {
159
+ bad,
160
+ action: {
161
+ label: 'Remove duplicate',
162
+ run: () => removeIndexLine(item.removable.index, item.removable.text, 'Duplicate removed'),
163
+ },
164
+ },
165
+ );
166
+ }
167
+
168
+ if (item.kind === 'missing-description') {
169
+ return issue(
170
+ `No description: ${item.file}`,
171
+ 'The description is what a memory is recalled by, so this one is found on its name alone.',
172
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
173
+ );
174
+ }
175
+
176
+ if (item.kind === 'unknown-type') {
177
+ return issue(
178
+ `Unrecognised type: ${item.file}`,
179
+ `type: "${item.type}" - expected one of user, feedback, project, reference.`,
180
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
181
+ );
182
+ }
183
+
184
+ if (item.kind === 'empty-body') {
185
+ const memory = memories.find((m) => m.file === item.file);
186
+ return issue(
187
+ `Almost nothing in it: ${item.file}`,
188
+ `"${memory?.name || item.file}" has ${item.chars} characters of body - it costs an index line every session and says nothing.`,
189
+ {
190
+ bad,
191
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
192
+ action: { label: 'Delete', run: () => openDeleteDialog(item.file) },
193
+ },
194
+ );
195
+ }
196
+
197
+ if (item.kind === 'hook-repeats-description') {
198
+ const row = issue(
199
+ `Hook repeats the description: ${item.file}`,
200
+ `MEMORY.md line ${item.index + 1} - ${item.hookLength} characters restating what the file's own description already says. Only the hook loads every session.`,
201
+ { bad, secondary: { label: 'Open', run: () => selectMemory(item.file) } },
202
+ );
203
+ row.append(node('button', {
204
+ class: ui.buttonPrimarySmall,
205
+ text: 'Edit hook',
206
+ onclick: () => openHookEditor(row, item.index),
207
+ }));
208
+ return row;
209
+ }
210
+
211
+ if (item.kind === 'empty-section') {
212
+ return issue(
213
+ `Empty section in MEMORY.md: "${item.section}"`,
214
+ `line ${item.index + 1} - the heading has no entries under it, and headings count against the 200-line budget too.`,
215
+ { bad, action: { label: 'Open MEMORY.md', run: () => goTo('memory', 'index') } },
216
+ );
217
+ }
218
+
219
+ if (item.kind === 'stale-path') {
220
+ return issue(
221
+ `No file matches ${item.token}`,
222
+ `named by "${item.name || item.file}"`,
223
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
224
+ );
225
+ }
226
+
227
+ if (item.kind === 'index-continuation') {
228
+ return issue(
229
+ `${item.count} index ${item.count === 1 ? 'entry spills' : 'entries spill'} onto extra lines`,
230
+ `${item.extraLines} extra line${item.extraLines === 1 ? '' : 's'} of the 200-line budget - the guidance is one line per entry, with the detail in the topic file.`,
231
+ { bad, action: { label: 'Open MEMORY.md', run: () => goTo('memory', 'index') } },
232
+ );
233
+ }
234
+
235
+ if (item.kind === 'provenance-expired') {
236
+ return issue(
237
+ `Provenance gone: ${item.name}`,
238
+ `the session it was written in (${item.sessionId}) has been swept, so why this memory exists can no longer be traced. Nothing recovers it - judge the memory on what it says.`,
239
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
240
+ );
241
+ }
242
+
243
+ if (item.kind === 'path-evidence-expiring') {
244
+ return issue(
245
+ item.days === 0
246
+ ? 'The last transcript naming this project is due to be swept'
247
+ : `The last transcript naming this project is swept in ${item.days} days`,
248
+ `${item.path} is recovered from the session transcripts, and Claude Code drops those after ${item.retentionDays} days while never touching memory/. Once the last one goes this store shows its raw folder name instead. Remember path records it now.`,
249
+ { bad, action: { label: 'Remember path\u2026', run: openRememberPathDialog } },
250
+ );
251
+ }
252
+
253
+ if (item.kind === 'no-memory-despite-sessions') {
254
+ return issue(
255
+ `${item.sessionCount} ${item.sessionCount === 1 ? 'session' : 'sessions'}, and no memory written`,
256
+ `auto memory is on for this project, but nothing has been saved in the ${item.retentionDays} days of transcripts kept here. Either nothing was worth remembering, or memory is not being written.`,
257
+ { bad },
258
+ );
259
+ }
260
+
261
+ return issue(item.kind, JSON.stringify(item));
262
+ }
@@ -0,0 +1,156 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { el, node } from '/dom.mjs';
3
+ import { renderMarkdown } from '/markdown.mjs';
4
+ import { state } from '/state.mjs';
5
+ import { selectMemory, loadSessionsForProvenance } from '/store.mjs';
6
+ import { goTo, sessionDate } from '/parts.mjs';
7
+ import { openDeleteDialog } from '/dialogs/delete.mjs';
8
+ import { renderMemoryList } from '/views/memory-list.mjs';
9
+
10
+ export function memoryLookup(project) {
11
+ const byName = new Map();
12
+ for (const memory of project.memories) {
13
+ byName.set(memory.name, memory.file);
14
+ byName.set(memory.stem, memory.file);
15
+ }
16
+ return byName;
17
+ }
18
+
19
+ export function openTarget(file) {
20
+ return file ? { open: () => selectMemory(file) } : null;
21
+ }
22
+
23
+ export function renderBody(container, memory, project) {
24
+ const byName = memoryLookup(project);
25
+ renderMarkdown(container, memory.body, {
26
+ resolveWikilink: (target) => openTarget(byName.get(target)),
27
+ });
28
+ }
29
+
30
+ export function renderMemories(container) {
31
+ const project = state.store;
32
+ if (!project.memories.length) {
33
+ container.append(node('p', { class: ui.note, text: 'This project has no memory files yet.' }));
34
+ return;
35
+ }
36
+
37
+ const left = node('div');
38
+ renderMemoryList(left);
39
+
40
+ const right = node('div', { id: 'detail', class: ui.detailPane });
41
+ container.append(node('div', { class: ui.split }, [left, right]));
42
+ renderDetail();
43
+ }
44
+
45
+ function renderDetail() {
46
+ const host = el('detail');
47
+ if (!host) return;
48
+ host.textContent = '';
49
+
50
+ const memory = state.store.memories.find((m) => m.file === state.selected);
51
+ if (!memory) {
52
+ host.append(node('p', { class: ui.note, text: 'Select a memory to read it.' }));
53
+ return;
54
+ }
55
+
56
+ const card = node('div', { class: ui.card });
57
+ card.append(node('div', { class: ui.detailHead }, [
58
+ node('h3', { class: ui.detailTitle, text: memory.name }),
59
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
60
+ node('button', {
61
+ class: ui.buttonDangerSmall,
62
+ text: 'Delete',
63
+ onclick: () => openDeleteDialog(memory.file),
64
+ }),
65
+ ]));
66
+
67
+ if (memory.description) card.append(node('p', { class: ui.detailDesc, text: memory.description }));
68
+
69
+ const meta = node('dl', { class: ui.metaList });
70
+ const rows = [
71
+ ['file', memory.file],
72
+ ['status', memory.status],
73
+ ['modified', memory.modified
74
+ ? `${memory.modified.replace('T', ' ').replace(/\..*$/, '')}${memory.modifiedFrom === 'mtime' ? ' (file mtime)' : ''}`
75
+ : '-'],
76
+ ['size', `${(memory.bytes / 1024).toFixed(1)} KB`],
77
+ ];
78
+ if (!memory.nameMatchesFile) rows.push(['name', `${memory.name} (differs from filename)`]);
79
+ for (const [key, value] of Object.entries(memory.metadata)) {
80
+ if (key !== 'type' && key !== 'modified' && key !== 'originSessionId') rows.push([key, value]);
81
+ }
82
+ for (const [key, value] of rows) {
83
+ meta.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: String(value) }));
84
+ }
85
+ card.append(meta);
86
+
87
+ const origin = provenanceLine(memory);
88
+ if (origin) card.append(origin);
89
+
90
+ const body = node('div', { class: ui.prose });
91
+ card.append(body);
92
+ renderBody(body, memory, state.store);
93
+
94
+ const outbound = memory.outboundResolved || [];
95
+ if (outbound.length || memory.inbound.length) {
96
+ const links = node('div', { class: ui.linkSection });
97
+ if (outbound.length) {
98
+ links.append(node('div', { class: ui.sectionLabel, text: 'Links out' }));
99
+ const chips = node('div', { class: ui.linkRow });
100
+ for (const link of outbound) {
101
+ chips.append(node('button', {
102
+ class: ui.chip(Boolean(link.file)),
103
+ text: link.file ? link.target : `${link.target} (missing)`,
104
+ onclick: link.file ? () => selectMemory(link.file) : null,
105
+ }));
106
+ }
107
+ links.append(chips);
108
+ }
109
+ if (memory.inbound.length) {
110
+ links.append(node('div', { class: ui.sectionLabel, text: 'Linked from' }));
111
+ const chips = node('div', { class: ui.linkRow });
112
+ for (const link of memory.inbound) {
113
+ const source = state.store.memories.find((m) => m.file === link.from);
114
+ chips.append(node('button', {
115
+ class: ui.chip(true),
116
+ text: source ? source.name : link.from,
117
+ onclick: () => selectMemory(link.from),
118
+ }));
119
+ }
120
+ links.append(chips);
121
+ }
122
+ card.append(links);
123
+ }
124
+
125
+ host.append(card);
126
+ }
127
+
128
+ function provenanceLine(memory) {
129
+ const origin = memory.origin;
130
+ if (!origin) return null;
131
+
132
+ const known = state.aux.sessions?.sessions.find((session) => session.id === origin.sessionId);
133
+ const label = origin.present ? (known?.title || origin.sessionId) : origin.sessionId;
134
+
135
+ const row = node('div', { class: ui.provenanceRow }, [node('span', { text: 'written in' })]);
136
+
137
+ if (origin.present) {
138
+ row.append(node('button', {
139
+ class: ui.provenanceLink(true),
140
+ text: label,
141
+ title: 'Open Environment \u203a Sessions',
142
+ onclick: () => goTo('environment', 'sessions'),
143
+ }));
144
+ if (known?.gitBranch) row.append(node('span', { text: `on ${known.gitBranch}` }));
145
+ if (origin.modified) row.append(node('span', { text: sessionDate(origin.modified).slice(0, 10) }));
146
+ if (!known) loadSessionsForProvenance();
147
+ } else {
148
+ row.append(node('span', {
149
+ class: ui.provenanceLink(false),
150
+ text: label,
151
+ title: 'This transcript is no longer on disk',
152
+ }));
153
+ row.append(node('span', { text: '- transcript swept, so why this memory exists can no longer be traced' }));
154
+ }
155
+ return row;
156
+ }
@@ -0,0 +1,121 @@
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
+ import { formatAge } from '/parts.mjs';
6
+ import { selectMemory } from '/store.mjs';
7
+ import { openBulkDeleteDialog } from '/dialogs/bulk-delete.mjs';
8
+
9
+ const SORTS = {
10
+ section: { label: 'by section', compare: null },
11
+ oldest: { label: 'oldest first', compare: (a, b) => (b.ageDays ?? -1) - (a.ageDays ?? -1) },
12
+ largest: { label: 'largest first', compare: (a, b) => b.bytes - a.bytes },
13
+ unlinked: { label: 'least linked', compare: (a, b) => a.inbound.length - b.inbound.length || (b.ageDays ?? 0) - (a.ageDays ?? 0) },
14
+ name: { label: 'by name', compare: (a, b) => a.name.localeCompare(b.name) },
15
+ };
16
+
17
+ function memoryButton(memory) {
18
+ const active = state.selected === memory.file;
19
+ return node('button', {
20
+ class: ui.memoryItem(active),
21
+ 'data-memory': memory.file,
22
+ 'data-active': active ? '' : null,
23
+ onclick: () => selectMemory(memory.file),
24
+ }, [
25
+ node('span', { class: ui.memoryTop }, [
26
+ node('span', { class: ui.memoryName, text: memory.name }),
27
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
28
+ memory.status !== 'indexed'
29
+ ? node('span', { class: ui.badge('warn'), text: memory.status })
30
+ : null,
31
+ ]),
32
+ memory.description ? node('span', { class: ui.memoryDesc, text: memory.description }) : null,
33
+ node('span', { class: ui.memoryFacts }, [
34
+ node('span', { class: ui.memoryFact((memory.ageDays ?? 0) > 180), text: formatAge(memory.ageDays) }),
35
+ node('span', { text: `${(memory.bytes / 1024).toFixed(1)}K` }),
36
+ node('span', { class: ui.memoryFact(!memory.inbound.length), text: `${memory.inbound.length} in` }),
37
+ ]),
38
+ ]);
39
+ }
40
+
41
+ function memoryRow(memory, onToggle) {
42
+ const row = node('div', { class: ui.listItemRow });
43
+ if (state.selecting) {
44
+ const box = node('input', { type: 'checkbox', class: ui.checkbox, 'aria-label': `Select ${memory.name}` });
45
+ box.checked = state.listSelection.has(memory.file);
46
+ box.addEventListener('change', () => {
47
+ if (box.checked) state.listSelection.add(memory.file);
48
+ else state.listSelection.delete(memory.file);
49
+ onToggle();
50
+ });
51
+ row.append(box);
52
+ }
53
+ row.append(memoryButton(memory));
54
+ return row;
55
+ }
56
+
57
+ function setSort(value) {
58
+ state.listSort = value;
59
+ localStorage.setItem('memoryListSort', value);
60
+ paint('tab');
61
+ }
62
+
63
+ function toolbar() {
64
+ const bar = node('div', { class: ui.listBar });
65
+ const sort = node('select', { class: ui.select, 'aria-label': 'Sort memories' });
66
+ for (const [key, config] of Object.entries(SORTS)) {
67
+ sort.append(node('option', { value: key, text: config.label }));
68
+ }
69
+ sort.value = state.listSort;
70
+ sort.onchange = () => setSort(sort.value);
71
+
72
+ const count = node('span', { class: ui.note });
73
+ const deleteButton = node('button', { class: ui.buttonDangerSmall });
74
+ const selectButton = node('button', {
75
+ class: ui.buttonSmall,
76
+ text: state.selecting ? 'Done' : 'Select',
77
+ onclick: () => {
78
+ state.selecting = !state.selecting;
79
+ if (!state.selecting) state.listSelection.clear();
80
+ paint('tab');
81
+ },
82
+ });
83
+
84
+ bar.append(sort, node('span', { class: ui.listSpacer }), count, selectButton);
85
+ if (state.selecting) bar.append(deleteButton);
86
+ deleteButton.onclick = () => openBulkDeleteDialog([...state.listSelection]);
87
+
88
+ const update = () => {
89
+ const size = state.listSelection.size;
90
+ count.textContent = size ? `${size} selected` : '';
91
+ deleteButton.textContent = `Delete ${size || ''}`.trim();
92
+ deleteButton.disabled = size === 0;
93
+ };
94
+ update();
95
+ return { bar, update };
96
+ }
97
+
98
+ export function renderMemoryList(container) {
99
+ const project = state.store;
100
+ const { bar, update } = toolbar();
101
+ container.append(bar);
102
+
103
+ const compare = SORTS[state.listSort].compare;
104
+ if (!compare) {
105
+ const groups = new Map();
106
+ for (const memory of project.memories) {
107
+ const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
108
+ if (!groups.has(key)) groups.set(key, []);
109
+ groups.get(key).push(memory);
110
+ }
111
+ for (const [section, memories] of groups) {
112
+ container.append(node('div', { class: ui.sectionLabel, text: `${section} · ${memories.length}` }));
113
+ for (const memory of memories) container.append(memoryRow(memory, update));
114
+ }
115
+ return;
116
+ }
117
+
118
+ for (const memory of [...project.memories].sort(compare)) {
119
+ container.append(memoryRow(memory, update));
120
+ }
121
+ }
@@ -0,0 +1,32 @@
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
+ import { renderSegmented } from '/views/segments.mjs';
6
+ import { renderMemories } from '/views/memories.mjs';
7
+ import { renderIndex } from '/views/index.mjs';
8
+ import { renderGraph } from '/graph.mjs';
9
+ import { selectMemory } from '/store.mjs';
10
+
11
+ function renderMemoryGraph(container) {
12
+ const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
13
+ container.append(wrap);
14
+ renderGraph(wrap, state.store.graph, {
15
+ selected: state.selected,
16
+ spread: state.spread,
17
+ onSelect: (file) => selectMemory(file),
18
+ onSpreadChange: (value) => {
19
+ state.spread = value;
20
+ localStorage.setItem('graphSpread', String(value));
21
+ paint('tab');
22
+ },
23
+ });
24
+ }
25
+
26
+ export function renderMemory(container) {
27
+ return renderSegmented(container, 'memory', {
28
+ list: renderMemories,
29
+ index: renderIndex,
30
+ graph: renderMemoryGraph,
31
+ });
32
+ }
@@ -0,0 +1,103 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { api } from '/api.mjs';
4
+ import { state } from '/state.mjs';
5
+ import { isAt } from '/parts.mjs';
6
+ import { paint } from '/bus.mjs';
7
+ import { renderIssue } from '/views/issue.mjs';
8
+
9
+ function storeHasProjectDir() {
10
+ return state.store.kind === 'auto'
11
+ ? state.store.resolvedBy !== 'unresolved'
12
+ : Boolean(state.store.projectPath);
13
+ }
14
+
15
+ const pathCheckKey = () => `pathCheck:${state.storeId}`;
16
+
17
+ async function fetchPathCheck() {
18
+ const id = state.storeId;
19
+ let next;
20
+ try {
21
+ next = { running: false, data: await api(`/api/stores/${encodeURIComponent(id)}/path-check`) };
22
+ } catch (err) {
23
+ next = { running: false, error: err.message };
24
+ }
25
+ if (state.storeId !== id) return;
26
+ state.pathCheck = next;
27
+ if (isAt('cleanup')) paint('tab');
28
+ }
29
+
30
+ function startPathCheck({ remember = true } = {}) {
31
+ if (remember) localStorage.setItem(pathCheckKey(), '1');
32
+ state.pathCheck = { running: true };
33
+ fetchPathCheck();
34
+ }
35
+
36
+ function runPathCheck() {
37
+ startPathCheck();
38
+ paint('tab');
39
+ }
40
+
41
+ function stopPathCheck() {
42
+ localStorage.removeItem(pathCheckKey());
43
+ state.pathCheck = null;
44
+ paint('tab');
45
+ }
46
+
47
+ export function renderPathCheck(container) {
48
+ if (!storeHasProjectDir()) return;
49
+
50
+ if (!state.pathCheck && localStorage.getItem(pathCheckKey()) === '1') {
51
+ startPathCheck({ remember: false });
52
+ }
53
+ const check = state.pathCheck;
54
+
55
+ const head = node('div', { class: ui.issue(false) });
56
+ const body = node('div', { class: ui.issueBody });
57
+
58
+ if (!check) {
59
+ body.append(
60
+ node('div', { class: ui.issueTitle, text: 'Paths named in memories are not being checked' }),
61
+ node('div', { class: ui.issueDetail, text: 'Every other check on this tab reads only memory/. This one reads the project itself, to find memories naming files that no longer exist. It stays off until you ask, and is remembered per store.' }),
62
+ );
63
+ head.append(body, node('button', { class: ui.buttonSmall, text: 'Check against the repo', onclick: () => runPathCheck() }));
64
+ return container.append(head);
65
+ }
66
+
67
+ if (check.running) {
68
+ body.append(node('div', { class: ui.issueTitle, text: 'Checking paths against the repo…' }));
69
+ head.append(body);
70
+ return container.append(head);
71
+ }
72
+
73
+ if (check.error) {
74
+ body.append(
75
+ node('div', { class: ui.issueTitle, text: 'Path check could not run' }),
76
+ node('div', { class: ui.issueDetail, text: check.error }),
77
+ );
78
+ head.append(body, node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }));
79
+ return container.append(head);
80
+ }
81
+
82
+ const { checked, capped, missing, indexed, truncated } = check.data;
83
+ const scanned = `${indexed.toLocaleString()} files and folders scanned${truncated ? ', stopped at the index limit' : ''}`;
84
+ body.append(
85
+ node('div', { class: ui.issueTitle, text: missing.length
86
+ ? `${missing.length} of ${checked} paths named in memories match no file in the project`
87
+ : `All ${checked} paths named in memories still match a file` }),
88
+ node('div', { class: ui.issueDetail, text: capped
89
+ ? `${scanned}; stopped after the first ${checked} paths, so there may be more`
90
+ : scanned }),
91
+ );
92
+ if (missing.length) {
93
+ body.append(node('div', { class: ui.issueDetail, text: 'A pointer rather than a verdict: a file that moved reads the same as one the memory never got right.' }));
94
+ }
95
+ head.append(
96
+ body,
97
+ node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }),
98
+ node('button', { class: ui.buttonSmall, text: 'Re-check', onclick: () => runPathCheck() }),
99
+ );
100
+ container.append(head);
101
+
102
+ for (const item of missing) container.append(renderIssue(item, state.store.memories));
103
+ }