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.
@@ -0,0 +1,122 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { renderMarkdown } from '/markdown.mjs';
4
+ import { api, toast } from '/api.mjs';
5
+ import { state } from '/state.mjs';
6
+ import { paint } from '/bus.mjs';
7
+ import { openStore, selectMemory, restoreFromTrash } from '/store.mjs';
8
+ import { cutMarker } from '/parts.mjs';
9
+ import { memoryLookup, openTarget } from '/views/memories.mjs';
10
+
11
+ const INDEX_VIEWS = [
12
+ { id: 'rendered', label: 'Rendered' },
13
+ { id: 'source', label: 'Source' },
14
+ ];
15
+
16
+ function setIndexView(view) {
17
+ state.indexView = view;
18
+ localStorage.setItem('memoryIndexView', view);
19
+ paint('tab');
20
+ }
21
+
22
+ function indexViewPicker() {
23
+ return node('div', { class: ui.segmentGroup, role: 'tablist' }, INDEX_VIEWS.map((view) => node('button', {
24
+ class: ui.segment(state.indexView === view.id),
25
+ text: view.label,
26
+ role: 'tab',
27
+ 'aria-selected': String(state.indexView === view.id),
28
+ onclick: () => setIndexView(view.id),
29
+ })));
30
+ }
31
+
32
+ export function renderIndex(container) {
33
+ const project = state.store;
34
+ if (!project.hasIndex) {
35
+ container.append(node('p', { class: ui.note, text: 'This project has no MEMORY.md.' }));
36
+ return;
37
+ }
38
+
39
+ const card = node('div', { class: ui.card });
40
+ card.append(node('div', { class: ui.cardHeadRow }, [
41
+ node('p', { class: ui.note, text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }),
42
+ indexViewPicker(),
43
+ ]));
44
+
45
+ if (state.indexView === 'source') renderIndexSource(card, project);
46
+ else renderIndexRendered(card, project);
47
+
48
+ container.append(card);
49
+ }
50
+
51
+ function renderIndexRendered(card, project) {
52
+ const { cutoff } = project.stats.index;
53
+ const files = new Set(project.memories.map((memory) => memory.file));
54
+ const byName = memoryLookup(project);
55
+ const options = {
56
+ resolveWikilink: (target) => openTarget(byName.get(target)),
57
+ resolveHref: (href) => openTarget(files.has(href) ? href : null),
58
+ };
59
+
60
+ const lines = project.index.raw.split('\n');
61
+ const loaded = node('div', { class: ui.prose });
62
+ renderMarkdown(loaded, cutoff ? lines.slice(0, cutoff.rawLine).join('\n') : project.index.raw, options);
63
+ card.append(loaded);
64
+
65
+ if (!cutoff) return;
66
+ card.append(cutMarker(cutoff));
67
+ const dropped = node('div', { class: ui.proseDim });
68
+ renderMarkdown(dropped, lines.slice(cutoff.rawLine).join('\n'), options);
69
+ card.append(dropped);
70
+ }
71
+
72
+ function renderIndexSource(card, project) {
73
+ const missing = new Set(project.health.danglingIndex.map((d) => d.index));
74
+ const { cutoff } = project.stats.index;
75
+
76
+ for (const line of project.index.lines) {
77
+ if (cutoff && line.index === cutoff.rawLine) card.append(cutMarker(cutoff));
78
+
79
+ const dangling = missing.has(line.index);
80
+ const dropped = Boolean(cutoff) && line.index >= cutoff.rawLine;
81
+ const clickable = line.kind === 'index' && !dangling;
82
+ const content = node('span', {
83
+ class: ui.indexLineText({ kind: line.kind, clickable, dropped }),
84
+ text: line.text || ' ',
85
+ });
86
+ if (clickable) content.onclick = () => selectMemory(line.file);
87
+
88
+ card.append(node('div', { class: ui.indexLine({ dangling, dropped }) }, [
89
+ node('span', { class: ui.indexLineNumber, text: String(line.index + 1) }),
90
+ content,
91
+ ]));
92
+ }
93
+ }
94
+
95
+ export function indexEntryAt(lineIndex) {
96
+ return state.store.index?.entries.find((entry) => entry.index === lineIndex) || null;
97
+ }
98
+
99
+ export function sectionsAbove(limit) {
100
+ const seen = [];
101
+ for (const line of state.store.index?.lines || []) {
102
+ if (line.kind !== 'heading') continue;
103
+ if (limit !== null && line.index >= limit) continue;
104
+ if (line.section && !seen.includes(line.section)) seen.push(line.section);
105
+ }
106
+ return seen;
107
+ }
108
+
109
+ export async function runIndexEdit(action, body, message) {
110
+ try {
111
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/${action}`, {
112
+ method: 'POST',
113
+ body: JSON.stringify(body),
114
+ });
115
+ await openStore(state.storeId, { keepTab: true });
116
+ toast(message(result), { action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) } });
117
+ return result;
118
+ } catch (err) {
119
+ toast(err.message, { error: true });
120
+ return null;
121
+ }
122
+ }
@@ -0,0 +1,270 @@
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, goToTab } 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
+
11
+ async function removeIndexLine(lineIndex, expectedText, done) {
12
+ const go = await confirmDialog({
13
+ title: `Remove line ${lineIndex + 1} from MEMORY.md?`,
14
+ confirmLabel: 'Remove',
15
+ tone: 'danger',
16
+ body: [
17
+ node('div', { class: ui.willBlock }, [
18
+ node('div', { class: ui.willTitle, text: 'Will be removed' }),
19
+ node('div', { class: ui.willItem('remove'), text: expectedText }),
20
+ ]),
21
+ node('p', { class: ui.noteTight, text: 'Only this line goes. The memory file it points at is left on disk.' }),
22
+ ],
23
+ });
24
+ if (!go) return;
25
+ try {
26
+ await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
27
+ method: 'POST',
28
+ body: JSON.stringify({ lineIndex, expectedText }),
29
+ });
30
+ toast(done);
31
+ await openStore(state.storeId, { keepTab: true });
32
+ } catch (err) {
33
+ toast(err.message, { error: true });
34
+ }
35
+ }
36
+
37
+ export function renderIssue(item, memories) {
38
+ const bad = item.severity === 'bad';
39
+
40
+ if (item.kind === 'dangling-index') {
41
+ const { entry } = item;
42
+ return issue(
43
+ `MEMORY.md points at a file that does not exist: ${entry.file}`,
44
+ `line ${entry.index + 1}: ${entry.text}`,
45
+ {
46
+ bad,
47
+ action: {
48
+ label: 'Remove pointer',
49
+ run: () => removeIndexLine(entry.index, entry.text, 'Pointer removed'),
50
+ },
51
+ },
52
+ );
53
+ }
54
+
55
+ if (item.kind === 'dangling-wikilink') {
56
+ const { link } = item;
57
+ return issue(
58
+ `Broken [[${link.target}]]`,
59
+ `referenced from ${link.from} - no memory has that name`,
60
+ {
61
+ bad,
62
+ secondary: { label: 'Open source', run: () => selectMemory(link.from) },
63
+ action: {
64
+ label: 'Remove link',
65
+ run: async () => {
66
+ const go = await confirmDialog({
67
+ title: `Remove [[${link.target}]] from ${link.from}?`,
68
+ confirmLabel: 'Remove link',
69
+ tone: 'danger',
70
+ body: [
71
+ node('p', { class: ui.noteTight, text: 'The link markup goes, the words stay.' }),
72
+ node('div', { class: ui.willBlock }, [
73
+ node('div', { class: ui.willTitle, text: 'Will change' }),
74
+ node('div', { class: ui.willItem('remove'), text: `see [[${link.target}]]` }),
75
+ node('div', { class: ui.willItem('keep'), text: `see ${link.target}` }),
76
+ ]),
77
+ node('p', { class: ui.noteTight, text: 'A copy of the file is kept in the trash, so this can be undone.' }),
78
+ ],
79
+ });
80
+ if (!go) return;
81
+ try {
82
+ const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/wikilink/remove`, {
83
+ method: 'POST',
84
+ body: JSON.stringify({ file: link.from, target: link.target }),
85
+ });
86
+ await openStore(state.storeId, { keepTab: true });
87
+ toast(`Unlinked ${result.occurrences} reference(s)`, {
88
+ action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
89
+ });
90
+ } catch (err) {
91
+ toast(err.message, { error: true });
92
+ }
93
+ },
94
+ },
95
+ },
96
+ );
97
+ }
98
+
99
+ if (item.kind === 'orphan') {
100
+ const memory = memories.find((m) => m.file === item.file);
101
+ return issue(
102
+ `Not referenced anywhere in MEMORY.md: ${item.file}`,
103
+ `"${memory?.name || item.file}" exists on disk but nothing points to it, so Claude will not load it.`,
104
+ {
105
+ bad,
106
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
107
+ action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
108
+ },
109
+ );
110
+ }
111
+
112
+ if (item.kind === 'referenced-only') {
113
+ const memory = memories.find((m) => m.file === item.file);
114
+ return issue(
115
+ `Linked mid-sentence, not indexed: ${item.file}`,
116
+ `"${memory?.name || item.file}" is mentioned inside prose in MEMORY.md but has no index bullet of its own.`,
117
+ {
118
+ bad,
119
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
120
+ action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
121
+ },
122
+ );
123
+ }
124
+
125
+ if (item.kind === 'name-mismatch') {
126
+ return issue(
127
+ `Frontmatter name differs from filename: ${item.mismatch.file}`,
128
+ `name: "${item.mismatch.name}" - [[wikilinks]] must use this name, not the filename.`,
129
+ { bad, action: { label: 'Open', run: () => selectMemory(item.mismatch.file) } },
130
+ );
131
+ }
132
+
133
+ if (item.kind === 'missing-frontmatter') {
134
+ return issue(
135
+ `No YAML frontmatter: ${item.file}`,
136
+ 'Type and description cannot be read from this file.',
137
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
138
+ );
139
+ }
140
+
141
+ if (item.kind === 'long-hooks') {
142
+ return issue(
143
+ `${item.count} index hook${item.count === 1 ? '' : 's'} over 200 characters`,
144
+ `longest is ${item.longest.hookLength} chars on "${item.longest.title}" - hooks load every session, so these are the cheapest thing to trim.`,
145
+ {
146
+ bad,
147
+ action: { label: 'Open Prune', run: () => goToTab('prune') },
148
+ },
149
+ );
150
+ }
151
+
152
+ if (item.kind === 'duplicate-name') {
153
+ return issue(
154
+ `Two memories claim the name "${item.name}"`,
155
+ `${item.files.join(', ')} - every [[${item.name}]] resolves to ${item.reachable}, so the other one cannot be linked at all.`,
156
+ {
157
+ bad,
158
+ secondary: { label: 'Open first', run: () => selectMemory(item.files[0]) },
159
+ action: { label: 'Open second', run: () => selectMemory(item.files[1]) },
160
+ },
161
+ );
162
+ }
163
+
164
+ if (item.kind === 'duplicate-index-entry') {
165
+ return issue(
166
+ `Listed twice in MEMORY.md: ${item.file}`,
167
+ `lines ${item.lines.map((line) => line + 1).join(' and ')} - one memory, two index lines, both loaded every session.`,
168
+ {
169
+ bad,
170
+ action: {
171
+ label: 'Remove duplicate',
172
+ run: () => removeIndexLine(item.removable.index, item.removable.text, 'Duplicate removed'),
173
+ },
174
+ },
175
+ );
176
+ }
177
+
178
+ if (item.kind === 'missing-description') {
179
+ return issue(
180
+ `No description: ${item.file}`,
181
+ 'The description is what a memory is recalled by, so this one is found on its name alone.',
182
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
183
+ );
184
+ }
185
+
186
+ if (item.kind === 'unknown-type') {
187
+ return issue(
188
+ `Unrecognised type: ${item.file}`,
189
+ `type: "${item.type}" - expected one of user, feedback, project, reference.`,
190
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
191
+ );
192
+ }
193
+
194
+ if (item.kind === 'empty-body') {
195
+ const memory = memories.find((m) => m.file === item.file);
196
+ return issue(
197
+ `Almost nothing in it: ${item.file}`,
198
+ `"${memory?.name || item.file}" has ${item.chars} characters of body - it costs an index line every session and says nothing.`,
199
+ {
200
+ bad,
201
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
202
+ action: { label: 'Delete', run: () => openDeleteDialog(item.file) },
203
+ },
204
+ );
205
+ }
206
+
207
+ if (item.kind === 'hook-repeats-description') {
208
+ return issue(
209
+ `Hook repeats the description: ${item.file}`,
210
+ `MEMORY.md line ${item.index + 1} - ${item.hookLength} characters restating what the file's own description already says. Only the hook loads every session.`,
211
+ {
212
+ bad,
213
+ secondary: { label: 'Open', run: () => selectMemory(item.file) },
214
+ action: { label: 'Open Prune', run: () => goToTab('prune') },
215
+ },
216
+ );
217
+ }
218
+
219
+ if (item.kind === 'empty-section') {
220
+ return issue(
221
+ `Empty section in MEMORY.md: "${item.section}"`,
222
+ `line ${item.index + 1} - the heading has no entries under it, and headings count against the 200-line budget too.`,
223
+ { bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
224
+ );
225
+ }
226
+
227
+ if (item.kind === 'stale-path') {
228
+ return issue(
229
+ `No file matches ${item.token}`,
230
+ `named by "${item.name || item.file}"`,
231
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
232
+ );
233
+ }
234
+
235
+ if (item.kind === 'index-continuation') {
236
+ return issue(
237
+ `${item.count} index ${item.count === 1 ? 'entry spills' : 'entries spill'} onto extra lines`,
238
+ `${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.`,
239
+ { bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
240
+ );
241
+ }
242
+
243
+ if (item.kind === 'provenance-expired') {
244
+ return issue(
245
+ `Provenance gone: ${item.name}`,
246
+ `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.`,
247
+ { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
248
+ );
249
+ }
250
+
251
+ if (item.kind === 'path-evidence-expiring') {
252
+ return issue(
253
+ item.days === 0
254
+ ? 'The last transcript naming this project is due to be swept'
255
+ : `The last transcript naming this project is swept in ${item.days} days`,
256
+ `${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.`,
257
+ { bad, action: { label: 'Remember path\u2026', run: openRememberPathDialog } },
258
+ );
259
+ }
260
+
261
+ if (item.kind === 'no-memory-despite-sessions') {
262
+ return issue(
263
+ `${item.sessionCount} ${item.sessionCount === 1 ? 'session' : 'sessions'}, and no memory written`,
264
+ `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.`,
265
+ { bad },
266
+ );
267
+ }
268
+
269
+ return issue(item.kind, JSON.stringify(item));
270
+ }
@@ -0,0 +1,183 @@
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 { goToTab, sessionDate } from '/parts.mjs';
7
+ import { openDeleteDialog } from '/dialogs/delete.mjs';
8
+
9
+ export function memoryLookup(project) {
10
+ const byName = new Map();
11
+ for (const memory of project.memories) {
12
+ byName.set(memory.name, memory.file);
13
+ byName.set(memory.stem, memory.file);
14
+ }
15
+ return byName;
16
+ }
17
+
18
+ export function openTarget(file) {
19
+ return file ? { open: () => selectMemory(file) } : null;
20
+ }
21
+
22
+ export function renderBody(container, memory, project) {
23
+ const byName = memoryLookup(project);
24
+ renderMarkdown(container, memory.body, {
25
+ resolveWikilink: (target) => openTarget(byName.get(target)),
26
+ });
27
+ }
28
+
29
+ function memoryButton(memory) {
30
+ const active = state.selected === memory.file;
31
+ return node('button', {
32
+ class: ui.memoryItem(active),
33
+ 'data-memory': memory.file,
34
+ 'data-active': active ? '' : null,
35
+ onclick: () => selectMemory(memory.file),
36
+ }, [
37
+ node('span', { class: ui.memoryTop }, [
38
+ node('span', { class: ui.memoryName, text: memory.name }),
39
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
40
+ memory.status !== 'indexed'
41
+ ? node('span', { class: ui.badge('warn'), text: memory.status })
42
+ : null,
43
+ ]),
44
+ memory.description ? node('span', { class: ui.memoryDesc, text: memory.description }) : null,
45
+ ]);
46
+ }
47
+
48
+ export function renderMemories(container) {
49
+ const project = state.store;
50
+ if (!project.memories.length) {
51
+ container.append(node('p', { class: ui.note, text: 'This project has no memory files yet.' }));
52
+ return;
53
+ }
54
+
55
+ const left = node('div');
56
+ const groups = new Map();
57
+ for (const memory of project.memories) {
58
+ const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
59
+ if (!groups.has(key)) groups.set(key, []);
60
+ groups.get(key).push(memory);
61
+ }
62
+ for (const [section, memories] of groups) {
63
+ left.append(node('div', { class: ui.sectionLabel, text: `${section} · ${memories.length}` }));
64
+ for (const memory of memories) left.append(memoryButton(memory));
65
+ }
66
+
67
+ const right = node('div', { id: 'detail', class: ui.detailPane });
68
+ container.append(node('div', { class: ui.split }, [left, right]));
69
+ renderDetail();
70
+ }
71
+
72
+ function renderDetail() {
73
+ const host = el('detail');
74
+ if (!host) return;
75
+ host.textContent = '';
76
+
77
+ const memory = state.store.memories.find((m) => m.file === state.selected);
78
+ if (!memory) {
79
+ host.append(node('p', { class: ui.note, text: 'Select a memory to read it.' }));
80
+ return;
81
+ }
82
+
83
+ const card = node('div', { class: ui.card });
84
+ card.append(node('div', { class: ui.detailHead }, [
85
+ node('h3', { class: ui.detailTitle, text: memory.name }),
86
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
87
+ node('button', {
88
+ class: ui.buttonDangerSmall,
89
+ text: 'Delete',
90
+ onclick: () => openDeleteDialog(memory.file),
91
+ }),
92
+ ]));
93
+
94
+ if (memory.description) card.append(node('p', { class: ui.detailDesc, text: memory.description }));
95
+
96
+ const meta = node('dl', { class: ui.metaList });
97
+ const rows = [
98
+ ['file', memory.file],
99
+ ['status', memory.status],
100
+ ['modified', memory.modified
101
+ ? `${memory.modified.replace('T', ' ').replace(/\..*$/, '')}${memory.modifiedFrom === 'mtime' ? ' (file mtime)' : ''}`
102
+ : '-'],
103
+ ['size', `${(memory.bytes / 1024).toFixed(1)} KB`],
104
+ ];
105
+ if (!memory.nameMatchesFile) rows.push(['name', `${memory.name} (differs from filename)`]);
106
+ for (const [key, value] of Object.entries(memory.metadata)) {
107
+ if (key !== 'type' && key !== 'modified' && key !== 'originSessionId') rows.push([key, value]);
108
+ }
109
+ for (const [key, value] of rows) {
110
+ meta.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: String(value) }));
111
+ }
112
+ card.append(meta);
113
+
114
+ const origin = provenanceLine(memory);
115
+ if (origin) card.append(origin);
116
+
117
+ const body = node('div', { class: ui.prose });
118
+ card.append(body);
119
+ renderBody(body, memory, state.store);
120
+
121
+ const outbound = memory.outboundResolved || [];
122
+ if (outbound.length || memory.inbound.length) {
123
+ const links = node('div', { class: ui.linkSection });
124
+ if (outbound.length) {
125
+ links.append(node('div', { class: ui.sectionLabel, text: 'Links out' }));
126
+ const chips = node('div', { class: ui.linkRow });
127
+ for (const link of outbound) {
128
+ chips.append(node('button', {
129
+ class: ui.chip(Boolean(link.file)),
130
+ text: link.file ? link.target : `${link.target} (missing)`,
131
+ onclick: link.file ? () => selectMemory(link.file) : null,
132
+ }));
133
+ }
134
+ links.append(chips);
135
+ }
136
+ if (memory.inbound.length) {
137
+ links.append(node('div', { class: ui.sectionLabel, text: 'Linked from' }));
138
+ const chips = node('div', { class: ui.linkRow });
139
+ for (const link of memory.inbound) {
140
+ const source = state.store.memories.find((m) => m.file === link.from);
141
+ chips.append(node('button', {
142
+ class: ui.chip(true),
143
+ text: source ? source.name : link.from,
144
+ onclick: () => selectMemory(link.from),
145
+ }));
146
+ }
147
+ links.append(chips);
148
+ }
149
+ card.append(links);
150
+ }
151
+
152
+ host.append(card);
153
+ }
154
+
155
+ function provenanceLine(memory) {
156
+ const origin = memory.origin;
157
+ if (!origin) return null;
158
+
159
+ const known = state.aux.sessions?.sessions.find((session) => session.id === origin.sessionId);
160
+ const label = origin.present ? (known?.title || origin.sessionId) : origin.sessionId;
161
+
162
+ const row = node('div', { class: ui.provenanceRow }, [node('span', { text: 'written in' })]);
163
+
164
+ if (origin.present) {
165
+ row.append(node('button', {
166
+ class: ui.provenanceLink(true),
167
+ text: label,
168
+ title: 'Open the Sessions tab',
169
+ onclick: () => goToTab('sessions'),
170
+ }));
171
+ if (known?.gitBranch) row.append(node('span', { text: `on ${known.gitBranch}` }));
172
+ if (origin.modified) row.append(node('span', { text: sessionDate(origin.modified).slice(0, 10) }));
173
+ if (!known) loadSessionsForProvenance();
174
+ } else {
175
+ row.append(node('span', {
176
+ class: ui.provenanceLink(false),
177
+ text: label,
178
+ title: 'This transcript is no longer on disk',
179
+ }));
180
+ row.append(node('span', { text: '- transcript swept, so why this memory exists can no longer be traced' }));
181
+ }
182
+ return row;
183
+ }
@@ -0,0 +1,102 @@
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 { paint } from '/bus.mjs';
6
+ import { renderIssue } from '/views/issue.mjs';
7
+
8
+ function storeHasProjectDir() {
9
+ return state.store.kind === 'auto'
10
+ ? state.store.resolvedBy !== 'unresolved'
11
+ : Boolean(state.store.projectPath);
12
+ }
13
+
14
+ const pathCheckKey = () => `pathCheck:${state.storeId}`;
15
+
16
+ async function fetchPathCheck() {
17
+ const id = state.storeId;
18
+ let next;
19
+ try {
20
+ next = { running: false, data: await api(`/api/stores/${encodeURIComponent(id)}/path-check`) };
21
+ } catch (err) {
22
+ next = { running: false, error: err.message };
23
+ }
24
+ if (state.storeId !== id) return;
25
+ state.pathCheck = next;
26
+ if (state.tab === 'health') paint('tab');
27
+ }
28
+
29
+ function startPathCheck({ remember = true } = {}) {
30
+ if (remember) localStorage.setItem(pathCheckKey(), '1');
31
+ state.pathCheck = { running: true };
32
+ fetchPathCheck();
33
+ }
34
+
35
+ function runPathCheck() {
36
+ startPathCheck();
37
+ paint('tab');
38
+ }
39
+
40
+ function stopPathCheck() {
41
+ localStorage.removeItem(pathCheckKey());
42
+ state.pathCheck = null;
43
+ paint('tab');
44
+ }
45
+
46
+ export function renderPathCheck(container) {
47
+ if (!storeHasProjectDir()) return;
48
+
49
+ if (!state.pathCheck && localStorage.getItem(pathCheckKey()) === '1') {
50
+ startPathCheck({ remember: false });
51
+ }
52
+ const check = state.pathCheck;
53
+
54
+ const head = node('div', { class: ui.issue(false) });
55
+ const body = node('div', { class: ui.issueBody });
56
+
57
+ if (!check) {
58
+ body.append(
59
+ node('div', { class: ui.issueTitle, text: 'Paths named in memories are not being checked' }),
60
+ 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.' }),
61
+ );
62
+ head.append(body, node('button', { class: ui.buttonSmall, text: 'Check against the repo', onclick: () => runPathCheck() }));
63
+ return container.append(head);
64
+ }
65
+
66
+ if (check.running) {
67
+ body.append(node('div', { class: ui.issueTitle, text: 'Checking paths against the repo…' }));
68
+ head.append(body);
69
+ return container.append(head);
70
+ }
71
+
72
+ if (check.error) {
73
+ body.append(
74
+ node('div', { class: ui.issueTitle, text: 'Path check could not run' }),
75
+ node('div', { class: ui.issueDetail, text: check.error }),
76
+ );
77
+ head.append(body, node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }));
78
+ return container.append(head);
79
+ }
80
+
81
+ const { checked, capped, missing, indexed, truncated } = check.data;
82
+ const scanned = `${indexed.toLocaleString()} files and folders scanned${truncated ? ', stopped at the index limit' : ''}`;
83
+ body.append(
84
+ node('div', { class: ui.issueTitle, text: missing.length
85
+ ? `${missing.length} of ${checked} paths named in memories match no file in the project`
86
+ : `All ${checked} paths named in memories still match a file` }),
87
+ node('div', { class: ui.issueDetail, text: capped
88
+ ? `${scanned}; stopped after the first ${checked} paths, so there may be more`
89
+ : scanned }),
90
+ );
91
+ if (missing.length) {
92
+ 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.' }));
93
+ }
94
+ head.append(
95
+ body,
96
+ node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }),
97
+ node('button', { class: ui.buttonSmall, text: 'Re-check', onclick: () => runPathCheck() }),
98
+ );
99
+ container.append(head);
100
+
101
+ for (const item of missing) container.append(renderIssue(item, state.store.memories));
102
+ }