claude-memory-admin 1.5.0 → 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/app.mjs CHANGED
@@ -1,2112 +1,35 @@
1
1
  import { renderGraph } from '/graph.mjs';
2
- import { renderMarkdown } from '/markdown.mjs';
2
+ import { isDialogOpen } from '/dialog.mjs';
3
+ import { el, node } from '/dom.mjs';
3
4
  import * as ui from '/ui.mjs';
4
-
5
- const state = {
6
- stores: [],
7
- storeId: null,
8
- store: null,
9
- tab: 'memories',
10
- selected: null,
11
- showAll: false,
12
- collapsed: localStorage.getItem('sidebarCollapsed') === '1',
13
- theme: document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light',
14
- spread: Number(localStorage.getItem('graphSpread')) || 1.6,
15
- query: '',
16
- search: null,
17
- pruneSort: 'oldest',
18
- pruneSelection: new Set(),
19
- indexView: localStorage.getItem('memoryIndexView') === 'source' ? 'source' : 'rendered',
20
- aux: { instructions: null, settings: null, sessions: null },
21
- pathCheck: null,
22
- };
23
-
24
- const RANK = { ok: 0, warn: 1, bad: 2 };
25
- const worst = (...severities) => severities.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), 'ok');
26
- const worstSeverity = (items) => (!items.length ? 'ok' : items.some((item) => item.severity === 'bad') ? 'bad' : 'warn');
27
-
28
- const el = (id) => document.getElementById(id);
29
-
30
- async function api(path, options) {
31
- const response = await fetch(path, {
32
- headers: { 'content-type': 'application/json' },
33
- ...options,
34
- });
35
- const data = await response.json().catch(() => ({ error: 'Bad response from server' }));
36
- if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`);
37
- return data;
38
- }
39
-
40
- function toast(message, { error = false, action } = {}) {
41
- const node = document.createElement('div');
42
- node.className = ui.toast(error);
43
- node.append(document.createTextNode(message));
44
- if (action) {
45
- const button = document.createElement('button');
46
- button.className = ui.toastAction;
47
- button.textContent = action.label;
48
- button.onclick = () => { node.remove(); action.run(); };
49
- node.append(button);
50
- }
51
- el('toast-root').append(node);
52
- setTimeout(() => node.remove(), action ? 12000 : 4500);
53
- }
54
-
55
- function node(tag, props = {}, children = []) {
56
- const element = document.createElement(tag);
57
- for (const [key, value] of Object.entries(props)) {
58
- if (key === 'class') element.className = value;
59
- else if (key === 'text') element.textContent = value;
60
- else if (key.startsWith('on')) element.addEventListener(key.slice(2).toLowerCase(), value);
61
- else if (value !== null && value !== undefined) element.setAttribute(key, value);
62
- }
63
- for (const child of [].concat(children)) {
64
- if (child) element.append(child);
65
- }
66
- return element;
67
- }
68
-
69
- function memoryLookup(project) {
70
- const byName = new Map();
71
- for (const memory of project.memories) {
72
- byName.set(memory.name, memory.file);
73
- byName.set(memory.stem, memory.file);
74
- }
75
- return byName;
76
- }
77
-
78
- function openTarget(file) {
79
- return file ? { open: () => selectMemory(file) } : null;
80
- }
81
-
82
- function renderBody(container, memory, project) {
83
- const byName = memoryLookup(project);
84
- renderMarkdown(container, memory.body, {
85
- resolveWikilink: (target) => openTarget(byName.get(target)),
86
- });
87
- }
88
-
89
- function storeSubtitle(store) {
90
- if (store.kind === 'global') return store.dir;
91
- if (store.kind === 'auto') return store.path;
92
- const scope = store.kind === 'agent-user' ? 'user'
93
- : store.kind === 'agent-project' ? 'project' : 'local';
94
- return `${scope} · ${store.sublabel}`;
95
- }
96
-
97
- function issueTitle(store) {
98
- const parts = [];
99
- if (store.issueCount) parts.push(`${store.issueCount} in Health`);
100
- if (store.context) parts.push(`${store.context} in Context`);
101
- if (store.settings) parts.push(`${store.settings} in Settings`);
102
- if (store.kind === 'auto' && store.resolvedBy === 'unresolved') {
103
- parts.push('Real path could not be resolved - showing the raw folder name');
104
- }
105
- return parts.length ? `${store.dir}\n${parts.join(', ')}` : store.dir;
106
- }
107
-
108
- function storeButton(store) {
109
- const global = store.kind === 'global';
110
- const health = !global && !store.hasMemoryDir ? 'none' : store.severity || 'ok';
111
- const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
112
- return node('button', {
113
- class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
114
- onclick: () => openStore(store.id),
115
- title: issueTitle(store),
116
- }, [
117
- node('span', { class: ui.storeRow }, [
118
- node('span', { class: ui.dot(health) }),
119
- node('span', { class: ui.storeName, text: store.label }),
120
- off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
121
- global ? null : node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
122
- ]),
123
- node('span', { class: ui.storePath, text: storeSubtitle(store) }),
124
- ]);
125
- }
126
-
127
- function renderStores() {
128
- const list = el('project-list');
129
- list.textContent = '';
130
-
131
- const visible = state.stores.filter((s) => s.kind === 'global' || state.showAll || s.hasMemoryDir);
132
- if (!visible.length) {
133
- list.append(node('p', { class: ui.note, text: 'No memory stores found.' }));
134
- return;
135
- }
136
-
137
- const groups = [
138
- ['Global', visible.filter((s) => s.kind === 'global')],
139
- ['Projects', visible.filter((s) => s.kind === 'auto')],
140
- ['Subagents', visible.filter((s) => s.kind !== 'auto' && s.kind !== 'global')],
141
- ];
142
-
143
- const filled = groups.filter(([, stores]) => stores.length);
144
- for (const [title, stores] of filled) {
145
- if (filled.length > 1) list.append(node('div', { class: ui.sidebarGroup, text: title }));
146
- for (const store of stores) list.append(storeButton(store));
147
- }
148
- }
149
-
150
- const TABS = [
151
- { id: 'memories', label: 'Memories' },
152
- { id: 'index', label: 'MEMORY.md' },
153
- { id: 'graph', label: 'Graph' },
154
- { id: 'prune', label: 'Prune' },
155
- { id: 'context', label: 'Context' },
156
- { id: 'sessions', label: 'Sessions' },
157
- { id: 'health', label: 'Health' },
158
- { id: 'settings', label: 'Settings' },
159
- { id: 'trash', label: 'Trash' },
160
- ];
161
-
162
- function renderTabs() {
163
- const container = el('tabs');
164
- container.textContent = '';
165
- const global = state.store.kind === 'global';
166
- const hasProjectDir = state.store.kind === 'auto'
167
- ? state.store.resolvedBy !== 'unresolved'
168
- : Boolean(state.store.projectPath);
169
-
170
- for (const tab of TABS) {
171
- if (global && tab.id !== 'context') continue;
172
- if (!global && tab.id === 'context' && !hasProjectDir) continue;
173
- if (tab.id === 'sessions' && !state.store.sessions?.count) continue;
174
- let badge = null;
175
- let tone = 'neutral';
176
- if (tab.id === 'memories') badge = String(state.store.memories.length);
177
- if (tab.id === 'trash' && state.store.trash.length) badge = String(state.store.trash.length);
178
- if (tab.id === 'health' && state.store.health.issueCount) {
179
- badge = String(state.store.health.issueCount);
180
- tone = state.store.health.severity;
181
- }
182
- if (tab.id === 'prune' && state.store.stats.index.level !== 'ok') {
183
- badge = '!';
184
- tone = state.store.stats.index.level === 'over' ? 'bad' : 'warn';
185
- }
186
- if (tab.id === 'sessions' && state.store.sessions) {
187
- badge = String(state.store.sessions.count);
188
- if (state.store.sessions.expiringCount) tone = 'warn';
189
- }
190
- if (tab.id === 'context' && state.aux.instructions?.problems.length) {
191
- badge = String(state.aux.instructions.problems.length);
192
- tone = worstSeverity(state.aux.instructions.problems);
193
- }
194
- if (tab.id === 'settings' && state.aux.settings?.problems.length) {
195
- badge = String(state.aux.settings.problems.length);
196
- tone = worstSeverity(state.aux.settings.problems);
197
- }
198
-
199
- container.append(node('button', {
200
- class: ui.tab(state.tab === tab.id),
201
- onclick: () => { state.tab = tab.id; renderTabs(); renderTab(); },
202
- }, [
203
- document.createTextNode(tab.label),
204
- badge ? node('span', { class: ui.tabBadge(tone), text: badge }) : null,
205
- ]));
206
- }
207
- }
208
-
209
- function memoryButton(memory) {
210
- const active = state.selected === memory.file;
211
- return node('button', {
212
- class: ui.memoryItem(active),
213
- 'data-memory': memory.file,
214
- 'data-active': active ? '' : null,
215
- onclick: () => selectMemory(memory.file),
216
- }, [
217
- node('span', { class: ui.memoryTop }, [
218
- node('span', { class: ui.memoryName, text: memory.name }),
219
- node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
220
- memory.status !== 'indexed'
221
- ? node('span', { class: ui.badge('warn'), text: memory.status })
222
- : null,
223
- ]),
224
- memory.description ? node('span', { class: ui.memoryDesc, text: memory.description }) : null,
225
- ]);
226
- }
227
-
228
- function renderMemories(container) {
229
- const project = state.store;
230
- if (!project.memories.length) {
231
- container.append(node('p', { class: ui.note, text: 'This project has no memory files yet.' }));
232
- return;
233
- }
234
-
235
- const left = node('div');
236
- const groups = new Map();
237
- for (const memory of project.memories) {
238
- const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
239
- if (!groups.has(key)) groups.set(key, []);
240
- groups.get(key).push(memory);
241
- }
242
- for (const [section, memories] of groups) {
243
- left.append(node('div', { class: ui.sectionLabel, text: `${section} · ${memories.length}` }));
244
- for (const memory of memories) left.append(memoryButton(memory));
245
- }
246
-
247
- const right = node('div', { id: 'detail', class: ui.detailPane });
248
- container.append(node('div', { class: ui.split }, [left, right]));
249
- renderDetail();
250
- }
251
-
252
- function renderDetail() {
253
- const host = el('detail');
254
- if (!host) return;
255
- host.textContent = '';
256
-
257
- const memory = state.store.memories.find((m) => m.file === state.selected);
258
- if (!memory) {
259
- host.append(node('p', { class: ui.note, text: 'Select a memory to read it.' }));
260
- return;
261
- }
262
-
263
- const card = node('div', { class: ui.card });
264
- card.append(node('div', { class: ui.detailHead }, [
265
- node('h3', { class: ui.detailTitle, text: memory.name }),
266
- node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
267
- node('button', {
268
- class: ui.buttonDangerSmall,
269
- text: 'Delete',
270
- onclick: () => openDeleteDialog(memory.file),
271
- }),
272
- ]));
273
-
274
- if (memory.description) card.append(node('p', { class: ui.detailDesc, text: memory.description }));
275
-
276
- const meta = node('dl', { class: ui.metaList });
277
- const rows = [
278
- ['file', memory.file],
279
- ['status', memory.status],
280
- ['modified', memory.modified
281
- ? `${memory.modified.replace('T', ' ').replace(/\..*$/, '')}${memory.modifiedFrom === 'mtime' ? ' (file mtime)' : ''}`
282
- : '-'],
283
- ['size', `${(memory.bytes / 1024).toFixed(1)} KB`],
284
- ];
285
- if (!memory.nameMatchesFile) rows.push(['name', `${memory.name} (differs from filename)`]);
286
- for (const [key, value] of Object.entries(memory.metadata)) {
287
- if (key !== 'type' && key !== 'modified' && key !== 'originSessionId') rows.push([key, value]);
288
- }
289
- for (const [key, value] of rows) {
290
- meta.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: String(value) }));
291
- }
292
- card.append(meta);
293
-
294
- const origin = provenanceLine(memory);
295
- if (origin) card.append(origin);
296
-
297
- const body = node('div', { class: ui.prose });
298
- card.append(body);
299
- renderBody(body, memory, state.store);
300
-
301
- const outbound = memory.outboundResolved || [];
302
- if (outbound.length || memory.inbound.length) {
303
- const links = node('div', { class: ui.linkSection });
304
- if (outbound.length) {
305
- links.append(node('div', { class: ui.sectionLabel, text: 'Links out' }));
306
- const chips = node('div', { class: ui.linkRow });
307
- for (const link of outbound) {
308
- chips.append(node('button', {
309
- class: ui.chip(Boolean(link.file)),
310
- text: link.file ? link.target : `${link.target} (missing)`,
311
- onclick: link.file ? () => selectMemory(link.file) : null,
312
- }));
313
- }
314
- links.append(chips);
315
- }
316
- if (memory.inbound.length) {
317
- links.append(node('div', { class: ui.sectionLabel, text: 'Linked from' }));
318
- const chips = node('div', { class: ui.linkRow });
319
- for (const link of memory.inbound) {
320
- const source = state.store.memories.find((m) => m.file === link.from);
321
- chips.append(node('button', {
322
- class: ui.chip(true),
323
- text: source ? source.name : link.from,
324
- onclick: () => selectMemory(link.from),
325
- }));
326
- }
327
- links.append(chips);
328
- }
329
- card.append(links);
330
- }
331
-
332
- host.append(card);
333
- }
334
-
335
- const INDEX_VIEWS = [
336
- { id: 'rendered', label: 'Rendered' },
337
- { id: 'source', label: 'Source' },
338
- ];
339
-
340
- function setIndexView(view) {
341
- state.indexView = view;
342
- localStorage.setItem('memoryIndexView', view);
343
- renderTab();
344
- }
345
-
346
- function indexViewPicker() {
347
- return node('div', { class: ui.segmentGroup, role: 'tablist' }, INDEX_VIEWS.map((view) => node('button', {
348
- class: ui.segment(state.indexView === view.id),
349
- text: view.label,
350
- role: 'tab',
351
- 'aria-selected': String(state.indexView === view.id),
352
- onclick: () => setIndexView(view.id),
353
- })));
354
- }
355
-
356
- function cutMarker(cutoff) {
357
- return node('div', { class: ui.cutLine }, [
358
- node('span', { class: ui.cutLabel, text: `not loaded past here — ${cutoff.droppedLines} lines dropped on ${cutoff.by}` }),
359
- ]);
360
- }
361
-
362
- function renderIndex(container) {
363
- const project = state.store;
364
- if (!project.hasIndex) {
365
- container.append(node('p', { class: ui.note, text: 'This project has no MEMORY.md.' }));
366
- return;
367
- }
368
-
369
- const card = node('div', { class: ui.card });
370
- card.append(node('div', { class: ui.cardHeadRow }, [
371
- node('p', { class: ui.note, text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }),
372
- indexViewPicker(),
373
- ]));
374
-
375
- if (state.indexView === 'source') renderIndexSource(card, project);
376
- else renderIndexRendered(card, project);
377
-
378
- container.append(card);
379
- }
380
-
381
- function renderIndexRendered(card, project) {
382
- const { cutoff } = project.stats.index;
383
- const files = new Set(project.memories.map((memory) => memory.file));
384
- const byName = memoryLookup(project);
385
- const options = {
386
- resolveWikilink: (target) => openTarget(byName.get(target)),
387
- resolveHref: (href) => openTarget(files.has(href) ? href : null),
388
- };
389
-
390
- const lines = project.index.raw.split('\n');
391
- const loaded = node('div', { class: ui.prose });
392
- renderMarkdown(loaded, cutoff ? lines.slice(0, cutoff.rawLine).join('\n') : project.index.raw, options);
393
- card.append(loaded);
394
-
395
- if (!cutoff) return;
396
- card.append(cutMarker(cutoff));
397
- const dropped = node('div', { class: ui.proseDim });
398
- renderMarkdown(dropped, lines.slice(cutoff.rawLine).join('\n'), options);
399
- card.append(dropped);
400
- }
401
-
402
- function renderIndexSource(card, project) {
403
- const missing = new Set(project.health.danglingIndex.map((d) => d.index));
404
- const { cutoff } = project.stats.index;
405
-
406
- for (const line of project.index.lines) {
407
- if (cutoff && line.index === cutoff.rawLine) card.append(cutMarker(cutoff));
408
-
409
- const dangling = missing.has(line.index);
410
- const dropped = Boolean(cutoff) && line.index >= cutoff.rawLine;
411
- const clickable = line.kind === 'index' && !dangling;
412
- const content = node('span', {
413
- class: ui.indexLineText({ kind: line.kind, clickable, dropped }),
414
- text: line.text || ' ',
415
- });
416
- if (clickable) content.onclick = () => selectMemory(line.file);
417
-
418
- card.append(node('div', { class: ui.indexLine({ dangling, dropped }) }, [
419
- node('span', { class: ui.indexLineNumber, text: String(line.index + 1) }),
420
- content,
421
- ]));
422
- }
423
- }
424
-
425
- const SCOPE_ORDER = ['managed', 'user', 'project', 'local'];
426
-
427
- const CONTEXT_PROBLEMS = {
428
- missing: (p) => ['Import does not resolve', `${p.spec} from ${p.from} - nothing at ${p.file}`],
429
- 'too-deep': (p) => ['Import chain is too deep', `${p.file} is a fifth hop; Claude Code follows four, so this never loads`],
430
- cycle: (p) => ['Circular import', `${p.from} imports ${p.file}, which was already loaded`],
431
- external: (p) => ['Import resolves outside the project', `${p.file} - Claude Code asks you to approve these once, and a declined one stays disabled silently`],
432
- 'invalid-glob': (p) => [`Rule matches nothing: ${p.pattern}`, `${p.file} - ${p.reason}`],
433
- '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`],
434
- 'long-claude-md': (p) => [`${p.lines} lines, over the 200-line guidance`, `${p.file} - long files cost context every session and reduce adherence`],
435
- '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.`],
436
- '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.`],
437
- 'duplicate-load': (p) => [`Loaded ${p.count} times: ${p.file}`, `reached from ${[...new Set(p.via)].join(' and ')} - every copy is read into context, so you pay ~${p.wastedTokens.toLocaleString()} extra tokens a session`],
438
- 'empty-instruction-file': (p) => ['Loads, but says nothing', `${p.file} is empty apart from its frontmatter, so it costs a read and contributes no instruction`],
439
- };
440
-
441
- async function renderContext(container) {
442
- let data = state.aux.instructions;
443
- if (!data) {
444
- container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
445
- try {
446
- data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
447
- } catch (err) {
448
- container.textContent = '';
449
- return container.append(node('p', { class: ui.note, text: err.message }));
450
- }
451
- if (state.tab !== 'context') return;
452
- }
453
- container.textContent = '';
454
-
455
- const global = state.store.kind === 'global';
456
- if (!data.projectDir && !global) {
457
- 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.' }));
458
- }
459
-
460
- const { totals } = data;
461
- container.append(node('div', { class: ui.meter }, [
462
- node('div', { class: ui.meterTop }, [
463
- node('span', { class: ui.meterValue, text: `~${totals.alwaysTokens.toLocaleString()}` }),
464
- node('span', { class: ui.meterUnit, text: 'estimated tokens of instructions, every session' }),
465
- ]),
466
- node('div', { class: ui.meterFacts }, [
467
- node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.files) }), document.createTextNode(' files resolved')]),
468
- node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.alwaysLines) }), document.createTextNode(' lines always loaded')]),
469
- node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(totals.alwaysBytes / 1024).toFixed(1)} KB` }), document.createTextNode(' always loaded')]),
470
- node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.conditionalFiles) }), document.createTextNode(' path-scoped rules, loaded only on a match')]),
471
- ]),
472
- node('p', { class: ui.meterNote, text: 'Unlike MEMORY.md, none of this is truncated: CLAUDE.md files load in full however long they are. The 200-line figure is Claude Code’s guidance, not a cutoff, because long files cost context every session and are followed less reliably.' }),
473
- ]));
474
-
475
- if (data.problems.length) {
476
- container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
477
- for (const problem of data.problems) {
478
- const describe = CONTEXT_PROBLEMS[problem.kind];
479
- const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
480
- container.append(node('div', { class: ui.issue(problem.severity === 'bad') }, [
481
- node('div', { class: ui.issueBody }, [
482
- node('div', { class: ui.issueTitle, text: title }),
483
- node('div', { class: ui.issueDetail, text: detail }),
484
- ]),
485
- ]));
486
- }
487
- }
488
-
489
- container.append(node('div', { class: ui.sectionLabel, text: `Loaded in this order · ${data.files.length}` }));
490
- const card = node('div', { class: ui.card });
491
- for (const file of data.files) {
492
- const tags = [node('span', { class: ui.scopeBadge(file.scope), text: file.scope })];
493
- if (file.kind === 'import') tags.push(node('span', { class: ui.badge(), text: `import · depth ${file.depth}` }));
494
- if (file.kind === 'rule') tags.push(node('span', { class: ui.badge(), text: 'rule' }));
495
- if (file.kind === 'managed-settings') tags.push(node('span', { class: ui.badge(), text: 'claudeMd setting' }));
496
- if (file.conditional) tags.push(node('span', { class: ui.badge('warn'), text: 'only on a path match' }));
497
-
498
- const body = node('div', { class: ui.contextBody, hidden: true });
499
- const bodyProse = node('div', { class: ui.prose });
500
- body.append(bodyProse);
501
- const caret = node('span', { class: ui.contextCaret, text: '\u25b8' });
502
- let rendered = false;
503
-
504
- card.append(node('button', {
505
- class: ui.contextRowButton,
506
- 'aria-expanded': 'false',
507
- onclick: (event) => {
508
- const open = body.hidden;
509
- body.hidden = !open;
510
- caret.textContent = open ? '\u25be' : '\u25b8';
511
- event.currentTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
512
- if (open && !rendered) {
513
- rendered = true;
514
- renderMarkdown(bodyProse, file.text);
515
- }
516
- },
517
- }, [
518
- node('div', { class: ui.contextMain }, [
519
- node('div', { class: ui.contextTags }, tags),
520
- node('div', { class: ui.contextFile }, [caret, document.createTextNode(file.file)]),
521
- ]),
522
- node('div', { class: ui.contextSize, text: `${file.lines} L · ~${file.tokens.toLocaleString()} tok` }),
523
- ]));
524
- card.append(body);
525
- }
526
- container.append(card);
527
-
528
- if (data.excluded.length) {
529
- container.append(node('div', { class: ui.sectionLabel, text: `Excluded by claudeMdExcludes · ${data.excluded.length}` }));
530
- for (const file of data.excluded) {
531
- container.append(node('div', { class: ui.issue(false) }, [
532
- node('div', { class: ui.issueBody }, [node('div', { class: ui.issueDetail, text: file })]),
533
- ]));
534
- }
535
- }
536
-
537
- container.append(node('p', { class: ui.meterNote, text: 'This is re-derived from the documented resolution rules, not a report from Claude Code. Run /context in a session to see what it actually loaded.' }));
538
- }
539
-
540
- function issue(title, detail, { bad = false, action, secondary } = {}) {
541
- return node('div', { class: ui.issue(bad) }, [
542
- node('div', { class: ui.issueBody }, [
543
- node('div', { class: ui.issueTitle, text: title }),
544
- node('div', { class: ui.issueDetail, text: detail }),
545
- ]),
546
- secondary ? node('button', { class: ui.buttonSmall, text: secondary.label, onclick: secondary.run }) : null,
547
- action ? node('button', { class: ui.buttonSmall, text: action.label, onclick: action.run }) : null,
548
- ]);
549
- }
550
-
551
- function storeHasProjectDir() {
552
- return state.store.kind === 'auto'
553
- ? state.store.resolvedBy !== 'unresolved'
554
- : Boolean(state.store.projectPath);
555
- }
556
-
557
- const pathCheckKey = () => `pathCheck:${state.storeId}`;
558
-
559
- async function fetchPathCheck() {
560
- const id = state.storeId;
561
- let next;
562
- try {
563
- next = { running: false, data: await api(`/api/stores/${encodeURIComponent(id)}/path-check`) };
564
- } catch (err) {
565
- next = { running: false, error: err.message };
566
- }
567
- if (state.storeId !== id) return;
568
- state.pathCheck = next;
569
- if (state.tab === 'health') renderTab();
570
- }
571
-
572
- function startPathCheck({ remember = true } = {}) {
573
- if (remember) localStorage.setItem(pathCheckKey(), '1');
574
- state.pathCheck = { running: true };
575
- fetchPathCheck();
576
- }
577
-
578
- function runPathCheck() {
579
- startPathCheck();
580
- renderTab();
581
- }
582
-
583
- function stopPathCheck() {
584
- localStorage.removeItem(pathCheckKey());
585
- state.pathCheck = null;
586
- renderTab();
587
- }
588
-
589
- function renderPathCheck(container) {
590
- if (!storeHasProjectDir()) return;
591
-
592
- if (!state.pathCheck && localStorage.getItem(pathCheckKey()) === '1') {
593
- startPathCheck({ remember: false });
594
- }
595
- const check = state.pathCheck;
596
-
597
- const head = node('div', { class: ui.issue(false) });
598
- const body = node('div', { class: ui.issueBody });
599
-
600
- if (!check) {
601
- body.append(
602
- node('div', { class: ui.issueTitle, text: 'Paths named in memories are not being checked' }),
603
- 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.' }),
604
- );
605
- head.append(body, node('button', { class: ui.buttonSmall, text: 'Check against the repo', onclick: () => runPathCheck() }));
606
- return container.append(head);
607
- }
608
-
609
- if (check.running) {
610
- body.append(node('div', { class: ui.issueTitle, text: 'Checking paths against the repo…' }));
611
- head.append(body);
612
- return container.append(head);
613
- }
614
-
615
- if (check.error) {
616
- body.append(
617
- node('div', { class: ui.issueTitle, text: 'Path check could not run' }),
618
- node('div', { class: ui.issueDetail, text: check.error }),
619
- );
620
- head.append(body, node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }));
621
- return container.append(head);
622
- }
623
-
624
- const { checked, capped, missing, indexed, truncated } = check.data;
625
- const scanned = `${indexed.toLocaleString()} files and folders scanned${truncated ? ', stopped at the index limit' : ''}`;
626
- body.append(
627
- node('div', { class: ui.issueTitle, text: missing.length
628
- ? `${missing.length} of ${checked} paths named in memories match no file in the project`
629
- : `All ${checked} paths named in memories still match a file` }),
630
- node('div', { class: ui.issueDetail, text: capped
631
- ? `${scanned}; stopped after the first ${checked} paths, so there may be more`
632
- : scanned }),
633
- );
634
- if (missing.length) {
635
- 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.' }));
636
- }
637
- head.append(
638
- body,
639
- node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }),
640
- node('button', { class: ui.buttonSmall, text: 'Re-check', onclick: () => runPathCheck() }),
641
- );
642
- container.append(head);
643
-
644
- for (const item of missing) container.append(renderIssue(item, state.store.memories));
645
- }
646
-
647
- function renderHealth(container) {
648
- const { health, memories } = state.store;
649
-
650
- if (!health.issues.length) {
651
- container.append(node('div', { class: ui.card }, [
652
- node('p', { class: ui.okLine, text: '\u2713 No consistency problems found.' }),
653
- node('p', { class: ui.noteTight, text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
654
- ]));
655
- } else {
656
- for (const item of health.issues) container.append(renderIssue(item, memories));
657
- }
658
-
659
- container.append(node('div', { class: ui.sectionLabel, text: 'Beyond the memory directory' }));
660
- renderPathCheck(container);
661
- }
662
-
663
- function goToTab(id) {
664
- state.tab = id;
665
- renderTabs();
666
- renderTab();
667
- }
668
-
669
- async function removeIndexLine(lineIndex, expectedText, done) {
670
- if (!confirm(`Remove line ${lineIndex + 1} from MEMORY.md?\n\n${expectedText}`)) return;
671
- try {
672
- await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
673
- method: 'POST',
674
- body: JSON.stringify({ lineIndex, expectedText }),
675
- });
676
- toast(done);
677
- await openStore(state.storeId, { keepTab: true });
678
- } catch (err) {
679
- toast(err.message, { error: true });
680
- }
681
- }
682
-
683
- function renderIssue(item, memories) {
684
- const bad = item.severity === 'bad';
685
-
686
- if (item.kind === 'dangling-index') {
687
- const { entry } = item;
688
- return issue(
689
- `MEMORY.md points at a file that does not exist: ${entry.file}`,
690
- `line ${entry.index + 1}: ${entry.text}`,
691
- {
692
- bad,
693
- action: {
694
- label: 'Remove pointer',
695
- run: () => removeIndexLine(entry.index, entry.text, 'Pointer removed'),
696
- },
697
- },
698
- );
699
- }
700
-
701
- if (item.kind === 'dangling-wikilink') {
702
- const { link } = item;
703
- return issue(
704
- `Broken [[${link.target}]]`,
705
- `referenced from ${link.from} - no memory has that name`,
706
- {
707
- bad,
708
- secondary: { label: 'Open source', run: () => selectMemory(link.from) },
709
- action: {
710
- label: 'Remove link',
711
- run: async () => {
712
- if (!confirm(`Remove [[${link.target}]] from ${link.from}?\n\nThe link markup goes, the words stay:\n "see [[${link.target}]]" -> "see ${link.target}"\n\nA copy of the file is kept in the trash, so this can be undone.`)) return;
713
- try {
714
- const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/wikilink/remove`, {
715
- method: 'POST',
716
- body: JSON.stringify({ file: link.from, target: link.target }),
717
- });
718
- await openStore(state.storeId, { keepTab: true });
719
- toast(`Unlinked ${result.occurrences} reference(s)`, {
720
- action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
721
- });
722
- } catch (err) {
723
- toast(err.message, { error: true });
724
- }
725
- },
726
- },
727
- },
728
- );
729
- }
730
-
731
- if (item.kind === 'orphan') {
732
- const memory = memories.find((m) => m.file === item.file);
733
- return issue(
734
- `Not referenced anywhere in MEMORY.md: ${item.file}`,
735
- `"${memory?.name || item.file}" exists on disk but nothing points to it, so Claude will not load it.`,
736
- {
737
- bad,
738
- secondary: { label: 'Open', run: () => selectMemory(item.file) },
739
- action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
740
- },
741
- );
742
- }
743
-
744
- if (item.kind === 'referenced-only') {
745
- const memory = memories.find((m) => m.file === item.file);
746
- return issue(
747
- `Linked mid-sentence, not indexed: ${item.file}`,
748
- `"${memory?.name || item.file}" is mentioned inside prose in MEMORY.md but has no index bullet of its own.`,
749
- {
750
- bad,
751
- secondary: { label: 'Open', run: () => selectMemory(item.file) },
752
- action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
753
- },
754
- );
755
- }
756
-
757
- if (item.kind === 'name-mismatch') {
758
- return issue(
759
- `Frontmatter name differs from filename: ${item.mismatch.file}`,
760
- `name: "${item.mismatch.name}" - [[wikilinks]] must use this name, not the filename.`,
761
- { bad, action: { label: 'Open', run: () => selectMemory(item.mismatch.file) } },
762
- );
763
- }
764
-
765
- if (item.kind === 'missing-frontmatter') {
766
- return issue(
767
- `No YAML frontmatter: ${item.file}`,
768
- 'Type and description cannot be read from this file.',
769
- { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
770
- );
771
- }
772
-
773
- if (item.kind === 'long-hooks') {
774
- return issue(
775
- `${item.count} index hook${item.count === 1 ? '' : 's'} over 200 characters`,
776
- `longest is ${item.longest.hookLength} chars on "${item.longest.title}" - hooks load every session, so these are the cheapest thing to trim.`,
777
- {
778
- bad,
779
- action: { label: 'Open Prune', run: () => goToTab('prune') },
780
- },
781
- );
782
- }
783
-
784
- if (item.kind === 'duplicate-name') {
785
- return issue(
786
- `Two memories claim the name "${item.name}"`,
787
- `${item.files.join(', ')} - every [[${item.name}]] resolves to ${item.reachable}, so the other one cannot be linked at all.`,
788
- {
789
- bad,
790
- secondary: { label: 'Open first', run: () => selectMemory(item.files[0]) },
791
- action: { label: 'Open second', run: () => selectMemory(item.files[1]) },
792
- },
793
- );
794
- }
795
-
796
- if (item.kind === 'duplicate-index-entry') {
797
- return issue(
798
- `Listed twice in MEMORY.md: ${item.file}`,
799
- `lines ${item.lines.map((line) => line + 1).join(' and ')} - one memory, two index lines, both loaded every session.`,
800
- {
801
- bad,
802
- action: {
803
- label: 'Remove duplicate',
804
- run: () => removeIndexLine(item.removable.index, item.removable.text, 'Duplicate removed'),
805
- },
806
- },
807
- );
808
- }
809
-
810
- if (item.kind === 'missing-description') {
811
- return issue(
812
- `No description: ${item.file}`,
813
- 'The description is what a memory is recalled by, so this one is found on its name alone.',
814
- { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
815
- );
816
- }
817
-
818
- if (item.kind === 'unknown-type') {
819
- return issue(
820
- `Unrecognised type: ${item.file}`,
821
- `type: "${item.type}" - expected one of user, feedback, project, reference.`,
822
- { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
823
- );
824
- }
825
-
826
- if (item.kind === 'empty-body') {
827
- const memory = memories.find((m) => m.file === item.file);
828
- return issue(
829
- `Almost nothing in it: ${item.file}`,
830
- `"${memory?.name || item.file}" has ${item.chars} characters of body - it costs an index line every session and says nothing.`,
831
- {
832
- bad,
833
- secondary: { label: 'Open', run: () => selectMemory(item.file) },
834
- action: { label: 'Delete', run: () => openDeleteDialog(item.file) },
835
- },
836
- );
837
- }
838
-
839
- if (item.kind === 'hook-repeats-description') {
840
- return issue(
841
- `Hook repeats the description: ${item.file}`,
842
- `MEMORY.md line ${item.index + 1} - ${item.hookLength} characters restating what the file's own description already says. Only the hook loads every session.`,
843
- {
844
- bad,
845
- secondary: { label: 'Open', run: () => selectMemory(item.file) },
846
- action: { label: 'Open Prune', run: () => goToTab('prune') },
847
- },
848
- );
849
- }
850
-
851
- if (item.kind === 'empty-section') {
852
- return issue(
853
- `Empty section in MEMORY.md: "${item.section}"`,
854
- `line ${item.index + 1} - the heading has no entries under it, and headings count against the 200-line budget too.`,
855
- { bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
856
- );
857
- }
858
-
859
- if (item.kind === 'stale-path') {
860
- return issue(
861
- `No file matches ${item.token}`,
862
- `named by "${item.name || item.file}"`,
863
- { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
864
- );
865
- }
866
-
867
- if (item.kind === 'index-continuation') {
868
- return issue(
869
- `${item.count} index ${item.count === 1 ? 'entry spills' : 'entries spill'} onto extra lines`,
870
- `${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.`,
871
- { bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
872
- );
873
- }
874
-
875
- if (item.kind === 'provenance-expired') {
876
- return issue(
877
- `Provenance gone: ${item.name}`,
878
- `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.`,
879
- { bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
880
- );
881
- }
882
-
883
- if (item.kind === 'path-evidence-expiring') {
884
- return issue(
885
- item.days === 0
886
- ? 'The last transcript naming this project is due to be swept'
887
- : `The last transcript naming this project is swept in ${item.days} days`,
888
- `${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.`,
889
- { bad, action: { label: 'Remember path\u2026', run: openRememberPathDialog } },
890
- );
891
- }
892
-
893
- if (item.kind === 'no-memory-despite-sessions') {
894
- return issue(
895
- `${item.sessionCount} ${item.sessionCount === 1 ? 'session' : 'sessions'}, and no memory written`,
896
- `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.`,
897
- { bad },
898
- );
899
- }
900
-
901
- return issue(item.kind, JSON.stringify(item));
902
- }
903
-
904
- const SETTINGS_PROBLEMS = {
905
- unparseable: (p) => [
906
- `Not valid JSON: ${p.scope} settings`,
907
- `${p.file} — Claude Code cannot read this file either, so every value in it is being ignored. ${p.detail}`,
908
- ],
909
- unreadable: (p) => [
910
- `Cannot be read: ${p.scope} settings`,
911
- `${p.file} — ${p.detail}`,
912
- ],
913
- 'not-object': (p) => [
914
- `Not a settings object: ${p.scope} settings`,
915
- `${p.file} — ${p.detail}`,
916
- ],
917
- 'invalid-auto-memory-directory': (p) => [
918
- 'autoMemoryDirectory cannot be used',
919
- `${p.file} — ${p.detail}. Claude Code accepts only an absolute or ~/-prefixed path.`,
920
- ],
921
- };
922
-
923
- const show = (value) => (value === undefined ? 'unset' : JSON.stringify(value));
924
-
925
- const TITLE_SOURCE = {
926
- 'ai-title': 'title Claude Code generated for this session',
927
- slug: 'session slug, no generated title found in the part read',
928
- prompt: 'first prompt, no generated title found in the part read',
5
+ import { state } from '/state.mjs';
6
+ import { toast } from '/api.mjs';
7
+ import { register, paint } from '/bus.mjs';
8
+ import { openStore, reloadStores, selectMemory } from '/store.mjs';
9
+ import { renderStores } from '/views/stores.mjs';
10
+ import { renderTabs, renderStoreHeader } from '/views/header.mjs';
11
+ import { renderMemories } from '/views/memories.mjs';
12
+ import { renderIndex } from '/views/index.mjs';
13
+ import { renderContext } from '/views/context.mjs';
14
+ import { renderHealth } from '/views/health.mjs';
15
+ import { renderSessions } from '/views/sessions.mjs';
16
+ import { renderSettings } from '/views/settings.mjs';
17
+ import { renderTrash } from '/views/trash.mjs';
18
+ import { renderPrune } from '/views/prune.mjs';
19
+ import { renderSearch, scheduleSearch, clearSearch } from '/views/search.mjs';
20
+ import { openStoreDeleteDialog } from '/dialogs/store-delete.mjs';
21
+
22
+ const TAB_VIEWS = {
23
+ memories: renderMemories,
24
+ prune: renderPrune,
25
+ context: renderContext,
26
+ sessions: renderSessions,
27
+ index: renderIndex,
28
+ health: renderHealth,
29
+ settings: renderSettings,
30
+ trash: renderTrash,
929
31
  };
930
32
 
931
- const sizeLabel = (bytes) => (bytes >= 1048576
932
- ? `${(bytes / 1048576).toFixed(1)} MB`
933
- : `${(bytes / 1024).toFixed(1)} KB`);
934
-
935
- function sessionDate(iso) {
936
- return iso ? iso.replace('T', ' ').replace(/\..*$/, '') : 'unknown';
937
- }
938
-
939
- function sessionRow(session, memoriesFrom) {
940
- const soon = session.expiresInDays !== null && session.expiresInDays <= 7;
941
- const caret = node('span', { class: ui.contextCaret, text: '\u25b8' });
942
-
943
- const tags = [];
944
- if (session.gitBranch) tags.push(node('span', { class: ui.badge('neutral'), text: session.gitBranch }));
945
- if (session.model) tags.push(node('span', { class: ui.badge('neutral'), text: session.model }));
946
- if (session.expiresInDays !== null) {
947
- tags.push(node('span', {
948
- class: ui.badge(soon ? 'warn' : 'neutral'),
949
- text: session.expiresInDays === 0 ? 'due to be swept' : `swept in ${session.expiresInDays}d`,
950
- }));
951
- }
952
- for (const memory of memoriesFrom) {
953
- tags.push(node('span', { class: ui.badge('project'), text: memory.name }));
954
- }
955
-
956
- const body = node('div', { class: ui.contextBody, hidden: true });
957
- const facts = node('dl', { class: ui.metaList });
958
- const rows = [
959
- ['session', session.id],
960
- ['last active', `${sessionDate(session.modified)} (file mtime)`],
961
- ['size', sizeLabel(session.bytes)],
962
- ];
963
- if (session.cwd) rows.push(['cwd', session.cwd]);
964
- if (session.version) rows.push(['version', session.version]);
965
- if (session.titleFrom) rows.push(['title from', TITLE_SOURCE[session.titleFrom] || session.titleFrom]);
966
- for (const [key, value] of rows) {
967
- facts.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: value }));
968
- }
969
- body.append(facts);
970
- if (session.prompt) {
971
- body.append(node('div', { class: ui.sectionLabel, text: 'Opened with' }));
972
- body.append(node('p', { class: ui.note, text: session.prompt }));
973
- }
974
- if (memoriesFrom.length) {
975
- body.append(node('div', { class: ui.sectionLabel, text: 'Memories written here' }));
976
- const chips = node('div', { class: ui.linkRow });
977
- for (const memory of memoriesFrom) {
978
- chips.append(node('button', {
979
- class: ui.chip(true),
980
- text: memory.name,
981
- onclick: () => selectMemory(memory.file),
982
- }));
983
- }
984
- body.append(chips);
985
- }
986
-
987
- const button = node('button', {
988
- class: ui.contextRowButton,
989
- 'aria-expanded': 'false',
990
- onclick: (event) => {
991
- const open = body.hidden;
992
- body.hidden = !open;
993
- caret.textContent = open ? '\u25be' : '\u25b8';
994
- event.currentTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
995
- },
996
- }, [
997
- node('div', { class: ui.contextMain }, [
998
- node('div', { class: ui.contextTags }, tags),
999
- node('div', { class: ui.contextFile }, [caret, document.createTextNode(session.title || session.id)]),
1000
- ]),
1001
- node('div', { class: ui.contextSize, text: sessionDate(session.modified).slice(0, 10) }),
1002
- ]);
1003
-
1004
- return node('div', {}, [button, body]);
1005
- }
1006
-
1007
- function retentionTrack(sessions, days) {
1008
- const track = node('div', { class: ui.retentionTrack }, [node('span', { class: ui.retentionSweep })]);
1009
- for (const session of sessions) {
1010
- if (session.expiresInDays === null) continue;
1011
- const share = days > 0 ? Math.min(1, session.expiresInDays / days) : 1;
1012
- track.append(node('span', {
1013
- class: ui.retentionTick(session.expiresInDays <= 7),
1014
- style: `left: ${(share * 100).toFixed(2)}%`,
1015
- title: `${session.title || session.id}\nswept in ${session.expiresInDays} days`,
1016
- }));
1017
- }
1018
- return node('div', {}, [
1019
- track,
1020
- node('div', { class: ui.retentionScale }, [
1021
- node('span', { text: 'swept' }),
1022
- node('span', { text: `today · kept ${days} days` }),
1023
- ]),
1024
- ]);
1025
- }
1026
-
1027
- async function renderSessions(container) {
1028
- let data = state.aux.sessions;
1029
- if (!data) {
1030
- container.append(node('p', { class: ui.note, text: 'Reading session transcripts\u2026' }));
1031
- try {
1032
- data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/sessions`);
1033
- } catch (err) {
1034
- container.textContent = '';
1035
- return container.append(node('p', { class: ui.note, text: err.message }));
1036
- }
1037
- if (state.tab !== 'sessions') return;
1038
- state.aux.sessions = data;
1039
- }
1040
- container.textContent = '';
1041
-
1042
- const byOrigin = new Map();
1043
- for (const memory of state.store.memories) {
1044
- if (!memory.origin?.present) continue;
1045
- if (!byOrigin.has(memory.origin.sessionId)) byOrigin.set(memory.origin.sessionId, []);
1046
- byOrigin.get(memory.origin.sessionId).push(memory);
1047
- }
1048
-
1049
- container.append(node('p', { class: ui.noteTight, text: 'The transcripts sitting beside this store\u2019s memory. Claude Code deletes them once they pass the retention period and never touches memory/, so this is the evidence that expires while the memories stay. This tab only reads, and only the head of each file - nothing here deletes a transcript.' }));
1050
-
1051
- container.append(node('div', { class: ui.meter }, [
1052
- node('div', { class: ui.meterTop }, [
1053
- node('span', { class: ui.meterValue, text: String(data.count) }),
1054
- node('span', { class: ui.meterUnit, text: data.count === 1 ? 'session kept' : 'sessions kept' }),
1055
- ]),
1056
- retentionTrack(data.sessions, data.days),
1057
- node('div', { class: ui.meterFacts }, [
1058
- node('span', {}, [node('b', { class: ui.meterFactValue, text: sizeLabel(data.bytes) }), document.createTextNode(' on disk')]),
1059
- node('span', {}, [node('b', { class: ui.meterFactValue, text: `${data.days} days` }), document.createTextNode(' retention (cleanupPeriodDays)')]),
1060
- node('span', {}, [
1061
- node('b', { class: ui.meterFactValue, text: data.evidenceExpiresInDays === null ? '-' : `${data.evidenceExpiresInDays} days` }),
1062
- document.createTextNode(' until the last proof of this path goes'),
1063
- ]),
1064
- ]),
1065
- node('p', { class: ui.meterNote, text: 'Sweep dates come from each file\u2019s mtime, which any copy or restore resets, so they are an estimate of when Claude Code will drop them rather than something the transcript recorded about itself.' }),
1066
- ]));
1067
-
1068
- container.append(node('div', { class: ui.sectionLabel, text: `Sessions \u00b7 ${data.count}` }));
1069
- const card = node('div', { class: ui.card });
1070
- for (const session of data.sessions) {
1071
- card.append(sessionRow(session, byOrigin.get(session.id) || []));
1072
- }
1073
- container.append(card);
1074
- }
1075
-
1076
- async function renderSettings(container) {
1077
- let data = state.aux.settings;
1078
- if (!data) {
1079
- container.append(node('p', { class: ui.note, text: 'Reading settings files…' }));
1080
- try {
1081
- data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/settings`);
1082
- } catch (err) {
1083
- container.textContent = '';
1084
- return container.append(node('p', { class: ui.note, text: err.message }));
1085
- }
1086
- if (state.tab !== 'settings') return;
1087
- }
1088
- container.textContent = '';
1089
-
1090
- 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.' }));
1091
-
1092
- if (!data.projectDir) {
1093
- 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.' }));
1094
- }
1095
-
1096
- if (data.problems.length) {
1097
- container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
1098
- for (const problem of data.problems) {
1099
- const describe = SETTINGS_PROBLEMS[problem.kind];
1100
- const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
1101
- container.append(node('div', { class: ui.issue(problem.severity === 'bad') }, [
1102
- node('div', { class: ui.issueBody }, [
1103
- node('div', { class: ui.issueTitle, text: title }),
1104
- node('div', { class: ui.issueDetail, text: detail }),
1105
- ]),
1106
- ]));
1107
- }
1108
- }
1109
-
1110
- if (data.env.overrides) {
1111
- container.append(node('div', { class: ui.sectionLabel, text: 'Environment override' }));
1112
- container.append(node('div', { class: ui.issue(false) }, [
1113
- node('div', { class: ui.issueBody }, [
1114
- node('div', { class: ui.issueTitle, text: `${data.env.name}=${data.env.value}` }),
1115
- 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.` }),
1116
- ]),
1117
- ]));
1118
- }
1119
-
1120
- for (const entry of data.keys) {
1121
- const card = node('div', { class: ui.card });
1122
- const head = node('div', { class: ui.settingsKeyHead }, [
1123
- node('span', { class: ui.settingsKeyName, text: entry.key }),
1124
- node('span', {
1125
- class: ui.settingsEffective,
1126
- text: entry.effective ? show(entry.effective.value) : `${show(entry.fallback)} (default)`,
1127
- }),
1128
- ]);
1129
- if (entry.effective) {
1130
- head.append(node('span', { class: ui.scopeBadge(entry.effective.scope), text: entry.effective.scope }));
1131
- }
1132
- card.append(head);
1133
- card.append(node('p', { class: ui.noteTight, text: entry.detail }));
1134
-
1135
- if (entry.normalized !== undefined && entry.effective && entry.normalized !== entry.effective.value) {
1136
- card.append(node('div', { class: ui.issue(false) }, [
1137
- node('div', { class: ui.issueBody }, [
1138
- node('div', { class: ui.issueTitle, text: `Configured ${show(entry.effective.value)}, but ${show(entry.normalized)} applies` }),
1139
- node('div', { class: ui.issueDetail, text: 'Claude Code ignores a value it cannot use and falls back to the default.' }),
1140
- ]),
1141
- ]));
1142
- }
1143
-
1144
- if (!entry.values.length) {
1145
- card.append(node('p', { class: ui.note, text: 'No settings file sets this, so the built-in default applies.' }));
1146
- } else {
1147
- for (const value of entry.values) {
1148
- card.append(node('div', { class: ui.settingsLayerRow }, [
1149
- node('span', { class: ui.scopeBadge(value.scope), text: value.scope }),
1150
- node('span', { class: ui.settingsLayerValue(value.wins), text: show(value.value) }),
1151
- node('span', { class: ui.settingsLayerFile, text: value.file }),
1152
- value.wins ? node('span', { class: ui.badge('ok'), text: 'wins' }) : null,
1153
- ]));
1154
- }
1155
- }
1156
- container.append(card);
1157
- }
1158
-
1159
- container.append(node('div', { class: ui.sectionLabel, text: `Files consulted · ${data.layers.length}` }));
1160
- const files = node('div', { class: ui.card });
1161
- for (const layer of data.layers) {
1162
- files.append(node('div', { class: ui.settingsLayerRow }, [
1163
- node('span', { class: ui.scopeBadge(layer.scope), text: layer.scope }),
1164
- node('span', { class: ui.settingsLayerValue(layer.status === 'ok'), text: layer.status }),
1165
- node('span', { class: ui.settingsLayerFile, text: layer.file }),
1166
- ]));
1167
- }
1168
- container.append(files);
1169
- }
1170
-
1171
- const byteLength = (text) => new TextEncoder().encode(text).length;
1172
-
1173
- function indexEntryAt(lineIndex) {
1174
- return state.store.index?.entries.find((entry) => entry.index === lineIndex) || null;
1175
- }
1176
-
1177
- function sectionsAbove(limit) {
1178
- const seen = [];
1179
- for (const line of state.store.index?.lines || []) {
1180
- if (line.kind !== 'heading') continue;
1181
- if (limit !== null && line.index >= limit) continue;
1182
- if (line.section && !seen.includes(line.section)) seen.push(line.section);
1183
- }
1184
- return seen;
1185
- }
1186
-
1187
- async function runIndexEdit(action, body, message) {
1188
- try {
1189
- const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/${action}`, {
1190
- method: 'POST',
1191
- body: JSON.stringify(body),
1192
- });
1193
- await openStore(state.storeId, { keepTab: true });
1194
- toast(message(result), { action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) } });
1195
- return result;
1196
- } catch (err) {
1197
- toast(err.message, { error: true });
1198
- return null;
1199
- }
1200
- }
1201
-
1202
- function openHookEditor(row, lineIndex) {
1203
- const entry = indexEntryAt(lineIndex);
1204
- if (!entry) return toast('MEMORY.md has changed - reload and try again', { error: true });
1205
-
1206
- const stats = state.store.stats.index;
1207
- const editor = node('div', { class: ui.hookEditor });
1208
- const field = node('textarea', { class: ui.textArea, spellcheck: 'false' });
1209
- field.value = entry.hook;
1210
-
1211
- const counter = node('span');
1212
- const projection = node('span');
1213
- const readout = node('div', { class: ui.charCount }, [counter, projection]);
1214
-
1215
- const save = node('button', { class: ui.buttonPrimarySmall, text: 'Save' });
1216
- const cancel = node('button', { class: ui.buttonSmall, text: 'Cancel', onclick: () => renderTab() });
1217
-
1218
- function update() {
1219
- const next = field.value.trim();
1220
- const delta = byteLength(next) - byteLength(entry.hook);
1221
- const projected = Math.max(stats.linePercent, ((stats.bytes + delta) / stats.byteLimit) * 100);
1222
-
1223
- counter.textContent = `${next.length} characters, was ${entry.hook.length}`;
1224
- projection.textContent = `index ${Math.round(stats.worstPercent)}% → ${Math.round(projected)}%`;
1225
- readout.className = next.length > 200 ? ui.charCountOver : ui.charCount;
1226
- save.disabled = next === entry.hook;
1227
- }
1228
-
1229
- field.addEventListener('input', update);
1230
- save.onclick = () => runIndexEdit(
1231
- 'index/hook',
1232
- { lineIndex, expectedText: entry.text, hook: field.value },
1233
- () => 'Hook shortened',
1234
- );
1235
-
1236
- editor.append(
1237
- node('div', { class: ui.willTitle, text: 'Hook' }),
1238
- field,
1239
- readout,
1240
- node('div', { class: ui.hookEditorFoot }, [cancel, save]),
1241
- );
1242
- row.textContent = '';
1243
- row.append(editor);
1244
- update();
1245
- field.focus();
1246
- field.select();
1247
- }
1248
-
1249
- function openMoveDialog(entry) {
1250
- const current = indexEntryAt(entry.index);
1251
- if (!current) return toast('MEMORY.md has changed - reload and try again', { error: true });
1252
-
1253
- const { cutoff } = state.store.stats.index;
1254
- const sections = sectionsAbove(cutoff ? cutoff.rawLine : null);
1255
- const picker = node('select', { class: ui.select });
1256
- picker.append(node('option', { value: '', text: 'Top of MEMORY.md' }));
1257
- for (const section of sections) picker.append(node('option', { value: section, text: `Start of "${section}"` }));
1258
-
1259
- const body = node('div', { class: ui.modalBody }, [
1260
- 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.` }),
1261
- node('div', { class: ui.willBlock }, [
1262
- node('div', { class: ui.willTitle, text: 'Will move' }),
1263
- node('div', { class: ui.willItem('keep'), text: current.text }),
1264
- ]),
1265
- node('label', { class: ui.willTitle, text: 'Move to' }),
1266
- picker,
1267
- ]);
1268
-
1269
- const modal = node('div', { class: ui.modal }, [
1270
- node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Move this entry above the cutoff' })]),
1271
- body,
1272
- node('footer', { class: ui.modalFoot }, [
1273
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1274
- node('button', {
1275
- class: ui.button({ tone: 'primary' }),
1276
- text: 'Move',
1277
- onclick: async () => {
1278
- closeModal();
1279
- await runIndexEdit(
1280
- 'index/move',
1281
- picker.value
1282
- ? { lineIndex: current.index, expectedText: current.text, section: picker.value }
1283
- : { lineIndex: current.index, expectedText: current.text, top: true },
1284
- () => 'Entry moved',
1285
- );
1286
- },
1287
- }),
1288
- ]),
1289
- ]);
1290
-
1291
- const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1292
- el('modal-root').append(backdrop);
1293
- }
1294
-
1295
- async function openAddEntryDialog(file) {
1296
- let preview;
1297
- try {
1298
- preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/index/add-preview`, {
1299
- method: 'POST',
1300
- body: JSON.stringify({ file }),
1301
- });
1302
- } catch (err) {
1303
- return toast(err.message, { error: true });
1304
- }
1305
-
1306
- const title = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
1307
- title.value = preview.name;
1308
- const hook = node('textarea', { class: ui.textArea, spellcheck: 'false' });
1309
- hook.value = preview.description;
1310
-
1311
- const picker = node('select', { class: ui.select });
1312
- for (const section of preview.sections) picker.append(node('option', { value: section, text: section }));
1313
- picker.append(node('option', { value: '', text: 'End of MEMORY.md' }));
1314
- if (preview.sections.length) picker.value = preview.sections[preview.sections.length - 1];
1315
-
1316
- const line = node('div', { class: ui.willItem('keep') });
1317
- const update = () => {
1318
- const label = title.value.trim().replace(/[[\]]/g, '') || preview.name;
1319
- const text = hook.value.trim();
1320
- line.textContent = text ? `- [${label}](${file}) — ${text}` : `- [${label}](${file})`;
1321
- };
1322
- title.addEventListener('input', update);
1323
- hook.addEventListener('input', update);
1324
- update();
1325
-
1326
- const body = node('div', { class: ui.modalBody }, [
1327
- node('p', { class: ui.noteTight, text: preview.hasIndex
1328
- ? '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.'
1329
- : 'This project has no MEMORY.md yet. Adding this entry creates one.' }),
1330
- node('label', { class: ui.willTitle, text: 'Title' }),
1331
- title,
1332
- node('label', { class: ui.willTitle, text: 'Hook' }),
1333
- hook,
1334
- node('label', { class: ui.willTitle, text: 'Section' }),
1335
- picker,
1336
- node('div', { class: ui.willBlock }, [
1337
- node('div', { class: ui.willTitle, text: 'Will be added' }),
1338
- line,
1339
- ]),
1340
- ]);
1341
-
1342
- const modal = node('div', { class: ui.modal }, [
1343
- node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Add ${file} to MEMORY.md` })]),
1344
- body,
1345
- node('footer', { class: ui.modalFoot }, [
1346
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1347
- node('button', {
1348
- class: ui.button({ tone: 'primary' }),
1349
- text: 'Add entry',
1350
- onclick: async () => {
1351
- closeModal();
1352
- await runIndexEdit(
1353
- 'index/add',
1354
- { file, section: picker.value || null, title: title.value, hook: hook.value },
1355
- () => 'Added to MEMORY.md',
1356
- );
1357
- },
1358
- }),
1359
- ]),
1360
- ]);
1361
-
1362
- const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1363
- el('modal-root').append(backdrop);
1364
- title.focus();
1365
- title.select();
1366
- }
1367
-
1368
- async function openMergeDialog(into, from) {
1369
- let preview;
1370
- try {
1371
- preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge-preview`, {
1372
- method: 'POST',
1373
- body: JSON.stringify({ into, from }),
1374
- });
1375
- } catch (err) {
1376
- return toast(err.message, { error: true });
1377
- }
1378
-
1379
- const heading = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
1380
- heading.value = preview.heading;
1381
-
1382
- const body = node('div', { class: ui.modalBody });
1383
- 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.` }));
1384
-
1385
- const changes = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will change' })]);
1386
- changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} + ${preview.bodyLines} line(s) from ${preview.from}` }));
1387
- for (const entry of preview.inbound) {
1388
- changes.append(node('div', { class: ui.willItem('keep'), text: `${entry.file} ${entry.targets.map((t) => `[[${t}]]`).join(' ')} → [[${preview.intoName}]]` }));
1389
- }
1390
- for (const link of preview.selfLinks) {
1391
- changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} [[${link}]] → plain text, to avoid a self-link` }));
1392
- }
1393
- body.append(changes);
1394
-
1395
- const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
1396
- removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.from} → memory/.trash/` }));
1397
- for (const line of preview.indexLines) {
1398
- removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
1399
- }
1400
- if (!preview.indexLines.length) {
1401
- removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for the source - nothing to remove there.' }));
1402
- }
1403
- body.append(removals);
1404
-
1405
- if (preview.inlineRefs.length) {
1406
- const kept = node('div', { class: ui.willBlock }, [
1407
- node('div', { class: ui.willTitle, text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' }),
1408
- ]);
1409
- for (const ref of preview.inlineRefs) {
1410
- kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
1411
- }
1412
- body.append(kept);
1413
- }
1414
-
1415
- body.append(node('label', { class: ui.willTitle, text: 'Heading for the merged section' }), heading);
1416
-
1417
- const modal = node('div', { class: ui.modal }, [
1418
- node('header', { class: ui.modalHead }, [
1419
- node('h3', { class: ui.modalTitle, text: `Merge "${preview.fromName}" into "${preview.intoName}"?` }),
1420
- ]),
1421
- body,
1422
- node('footer', { class: ui.modalFoot }, [
1423
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1424
- node('button', {
1425
- class: ui.button({ tone: 'primary' }),
1426
- text: 'Merge',
1427
- onclick: async () => {
1428
- closeModal();
1429
- try {
1430
- const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge`, {
1431
- method: 'POST',
1432
- body: JSON.stringify({ into, from, heading: heading.value }),
1433
- });
1434
- if (state.selected === from) state.selected = into;
1435
- await openStore(state.storeId, { keepTab: true });
1436
- toast(`Merged into ${preview.intoName}`, {
1437
- action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
1438
- });
1439
- } catch (err) {
1440
- toast(err.message, { error: true });
1441
- }
1442
- },
1443
- }),
1444
- ]),
1445
- ]);
1446
-
1447
- const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1448
- el('modal-root').append(backdrop);
1449
- heading.focus();
1450
- heading.select();
1451
- }
1452
-
1453
- async function restoreFromTrash(id) {
1454
- try {
1455
- const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/restore`, {
1456
- method: 'POST',
1457
- body: JSON.stringify({ id }),
1458
- });
1459
- await openStore(state.storeId, { keepTab: true });
1460
- toast(result.indexRestored === 'appended'
1461
- ? 'Restored, but MEMORY.md had changed - the index line was appended at the end'
1462
- : 'Restored');
1463
- } catch (err) {
1464
- toast(err.message, { error: true });
1465
- }
1466
- }
1467
-
1468
- const INDEX_EDIT_LABELS = {
1469
- hook: 'MEMORY.md hook shortened',
1470
- move: 'MEMORY.md entry moved',
1471
- add: 'MEMORY.md entry added',
1472
- };
1473
-
1474
- function renderTrash(container) {
1475
- const trash = state.store.trash;
1476
- if (!trash.length) {
1477
- container.append(node('p', { class: ui.note, text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
1478
- return;
1479
- }
1480
- for (const record of trash) {
1481
- const when = String(record.deletedAt).replace('T', ' ').replace(/\..*$/, '');
1482
- const detail = record.kind === 'wikilink'
1483
- ? `unlinked in ${record.sourceFile} · ${when}`
1484
- : record.kind === 'index-edit'
1485
- ? `${INDEX_EDIT_LABELS[record.op] || 'MEMORY.md edited'} · ${when}`
1486
- : record.kind === 'merge'
1487
- ? `merged into ${record.into} · ${when} · ${record.backups?.length || 0} file(s) rewritten`
1488
- : `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
1489
- container.append(node('div', { class: ui.issue(!record.present) }, [
1490
- node('div', { class: ui.issueBody }, [
1491
- node('div', { class: ui.issueTitle, text: record.label || record.id }),
1492
- node('div', { class: ui.issueDetail, text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
1493
- ]),
1494
- record.present
1495
- ? node('button', { class: ui.buttonPrimarySmall, text: 'Restore', onclick: () => restoreFromTrash(record.id) })
1496
- : null,
1497
- ]));
1498
- }
1499
- }
1500
-
1501
- function closeModal() {
1502
- el('modal-root').textContent = '';
1503
- }
1504
-
1505
- async function openDeleteDialog(file) {
1506
- let preview;
1507
- try {
1508
- preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete-preview`, {
1509
- method: 'POST',
1510
- body: JSON.stringify({ file }),
1511
- });
1512
- } catch (err) {
1513
- return toast(err.message, { error: true });
1514
- }
1515
-
1516
- const body = node('div', { class: ui.modalBody });
1517
-
1518
- const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
1519
- removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.file} → memory/.trash/` }));
1520
- for (const line of [...preview.indexLines, ...preview.continuations]) {
1521
- removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
1522
- }
1523
- if (!preview.indexLines.length && preview.hasIndex) {
1524
- removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for this file - nothing to unlink there.' }));
1525
- }
1526
- body.append(removals);
1527
-
1528
- if (preview.inlineRefs.length) {
1529
- 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' })]);
1530
- for (const ref of preview.inlineRefs) {
1531
- kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
1532
- }
1533
- body.append(kept);
1534
- }
1535
-
1536
- const cascade = new Set();
1537
- if (preview.inboundWikilinks.length) {
1538
- const block = node('div', { class: ui.willBlock });
1539
- const selectAll = node('button', { class: ui.linkButton, text: 'select all' });
1540
- block.append(node('div', { class: ui.cascadeHead }, [
1541
- node('div', { class: ui.willTitle, text: `${preview.inboundWikilinks.length} memory(s) link here and will break` }),
1542
- selectAll,
1543
- ]));
1544
-
1545
- const list = node('div', { class: ui.cascadeList });
1546
- const rows = [];
1547
- for (const link of preview.inboundWikilinks) {
1548
- const box = node('input', { type: 'checkbox', class: ui.checkbox });
1549
- const row = node('label', { class: ui.cascadeRow(false) }, [
1550
- box,
1551
- node('span', { class: ui.cascadeText }, [
1552
- node('span', { class: ui.cascadeName, text: link.fromName || link.from }),
1553
- node('span', { class: ui.cascadeDetail, text: link.indexLine
1554
- ? `${link.from} · MEMORY.md line ${link.indexLine.index + 1}`
1555
- : `${link.from} · not in the index` }),
1556
- ]),
1557
- ]);
1558
- box.addEventListener('change', () => {
1559
- row.className = ui.cascadeRow(box.checked);
1560
- if (box.checked) cascade.add(link.from);
1561
- else cascade.delete(link.from);
1562
- updateButton();
1563
- });
1564
- rows.push({ box, row });
1565
- list.append(row);
1566
- }
1567
- block.append(list);
1568
- 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.' }));
1569
- body.append(block);
1570
-
1571
- selectAll.onclick = (event) => {
1572
- event.preventDefault();
1573
- const turnOn = rows.some((r) => !r.box.checked);
1574
- for (const { box } of rows) {
1575
- if (box.checked !== turnOn) { box.checked = turnOn; box.dispatchEvent(new Event('change')); }
1576
- }
1577
- selectAll.textContent = turnOn ? 'select none' : 'select all';
1578
- };
1579
- }
1580
-
1581
- body.append(node('p', { class: ui.noteTight, text: 'The file moves to memory/.trash/ with a restore record, so this can be undone.' }));
1582
-
1583
- const confirmButton = node('button', {
1584
- class: ui.button({ tone: 'danger' }),
1585
- text: 'Delete',
1586
- onclick: async () => {
1587
- closeModal();
1588
- try {
1589
- const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete`, {
1590
- method: 'POST',
1591
- body: JSON.stringify({ file, alsoDelete: [...cascade] }),
1592
- });
1593
- if (state.selected === file || cascade.has(state.selected)) state.selected = null;
1594
- await openStore(state.storeId, { keepTab: true });
1595
- const count = result.record.files.length;
1596
- toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
1597
- action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
1598
- });
1599
- } catch (err) {
1600
- toast(err.message, { error: true });
1601
- }
1602
- },
1603
- });
1604
-
1605
- const modal = node('div', { class: ui.modal }, [
1606
- node('header', { class: ui.modalHead }, [
1607
- node('h3', { class: ui.modalTitle, text: `Delete "${preview.name || preview.file}"?` }),
1608
- node('p', { class: ui.note, text: preview.description || '' }),
1609
- ]),
1610
- body,
1611
- node('footer', { class: ui.modalFoot }, [
1612
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1613
- confirmButton,
1614
- ]),
1615
- ]);
1616
-
1617
- const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1618
- el('modal-root').append(backdrop);
1619
- updateButton();
1620
-
1621
- function updateButton() {
1622
- confirmButton.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
1623
- }
1624
- }
1625
-
1626
- function openRememberPathDialog() {
1627
- const store = state.store;
1628
- const input = node('input', {
1629
- type: 'text',
1630
- class: ui.pathInput,
1631
- value: store.guess || '',
1632
- placeholder: '/Users/you/repos/the-project',
1633
- spellcheck: 'false',
1634
- });
1635
-
1636
- const body = node('div', { class: ui.modalBody }, [
1637
- 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.` }),
1638
- input,
1639
- 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.' }),
1640
- ]);
1641
-
1642
- const modal = node('div', { class: ui.modal }, [
1643
- node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Remember this project\u2019s path' })]),
1644
- body,
1645
- node('footer', { class: ui.modalFoot }, [
1646
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1647
- node('button', {
1648
- class: ui.button({ tone: 'primary' }),
1649
- text: 'Remember',
1650
- onclick: async () => {
1651
- const value = input.value.trim();
1652
- if (!value) return;
1653
- try {
1654
- await api(`/api/stores/${encodeURIComponent(state.storeId)}/path/remember`, {
1655
- method: 'POST',
1656
- body: JSON.stringify({ path: value }),
1657
- });
1658
- } catch (err) {
1659
- return toast(err.message, { error: true });
1660
- }
1661
- closeModal();
1662
- await reloadStores();
1663
- await openStore(state.storeId, { keepTab: true });
1664
- toast(`Remembered ${value}`);
1665
- },
1666
- }),
1667
- ]),
1668
- ]);
1669
-
1670
- const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1671
- el('modal-root').append(backdrop);
1672
- input.focus();
1673
- input.select();
1674
- }
1675
-
1676
- async function forgetProjectPath() {
1677
- try {
1678
- await api(`/api/stores/${encodeURIComponent(state.storeId)}/path/forget`, { method: 'POST' });
1679
- } catch (err) {
1680
- return toast(err.message, { error: true });
1681
- }
1682
- await reloadStores();
1683
- await openStore(state.storeId, { keepTab: true });
1684
- toast('Forgot the remembered path');
1685
- }
1686
-
1687
- async function openStoreDeleteDialog() {
1688
- let preview;
1689
- try {
1690
- preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/project/delete-preview`, { method: 'POST' });
1691
- } catch (err) {
1692
- return toast(err.message, { error: true });
1693
- }
1694
-
1695
- if (!preview.files.length && !preview.hasIndex) {
1696
- return toast('This store has no memory to delete');
1697
- }
1698
-
1699
- const body = node('div', { class: ui.modalBody });
1700
- const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
1701
- if (preview.hasIndex) {
1702
- removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md (${preview.indexLines} lines) → memory/.trash/` }));
1703
- }
1704
- for (const entry of preview.files) {
1705
- removals.append(node('div', { class: ui.willItem('remove'), text: `${entry.file} → memory/.trash/` }));
1706
- }
1707
- body.append(removals);
1708
-
1709
- body.append(node('div', { class: ui.willBlock }, [
1710
- node('div', { class: ui.willTitle, text: 'Will be kept' }),
1711
- node('div', { class: ui.willItem('keep'), text: state.store.kind === 'auto'
1712
- ? 'Session transcripts (*.jsonl) and the project folder itself'
1713
- : 'The agent-memory folder itself, and every other agent\u2019s memory' }),
1714
- ]));
1715
-
1716
- body.append(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.' }));
1717
-
1718
- if (state.store.kind === 'agent-project') {
1719
- body.append(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.' }));
1720
- }
1721
-
1722
- const modal = node('div', { class: ui.modal }, [
1723
- node('header', { class: ui.modalHead }, [
1724
- node('h3', { class: ui.modalTitle, text: `Delete all memory for ${state.store.label}?` }),
1725
- node('p', { class: ui.note, text: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}` }),
1726
- ]),
1727
- body,
1728
- node('footer', { class: ui.modalFoot }, [
1729
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1730
- node('button', {
1731
- class: ui.button({ tone: 'danger' }),
1732
- text: `Delete ${preview.files.length + (preview.hasIndex ? 1 : 0)} files`,
1733
- onclick: async () => {
1734
- closeModal();
1735
- try {
1736
- const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/project/delete`, { method: 'POST' });
1737
- state.selected = null;
1738
- await openStore(state.storeId, { keepTab: true });
1739
- toast(`Cleared ${state.store.label}`, {
1740
- action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
1741
- });
1742
- } catch (err) {
1743
- toast(err.message, { error: true });
1744
- }
1745
- },
1746
- }),
1747
- ]),
1748
- ]);
1749
-
1750
- const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1751
- el('modal-root').append(backdrop);
1752
- }
1753
-
1754
- function highlight(text, terms) {
1755
- const wrapper = node('span');
1756
- if (!terms.length) { wrapper.textContent = text; return wrapper; }
1757
- const pattern = new RegExp(`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'ig');
1758
- let last = 0;
1759
- for (const match of text.matchAll(pattern)) {
1760
- if (match.index > last) wrapper.append(document.createTextNode(text.slice(last, match.index)));
1761
- wrapper.append(node('mark', { class: ui.resultMark, text: match[0] }));
1762
- last = match.index + match[0].length;
1763
- }
1764
- if (last < text.length) wrapper.append(document.createTextNode(text.slice(last)));
1765
- return wrapper;
1766
- }
1767
-
1768
- function formatAge(days) {
1769
- if (days === null || days === undefined) return 'undated';
1770
- if (days < 1) return 'today';
1771
- if (days < 31) return `${days}d`;
1772
- if (days < 365) return `${Math.round(days / 30)}mo`;
1773
- return `${(days / 365).toFixed(1)}y`;
1774
- }
1775
-
1776
- const PRUNE_SORTS = {
1777
- oldest: { label: 'oldest first', compare: (a, b) => (b.ageDays ?? -1) - (a.ageDays ?? -1) },
1778
- largest: { label: 'largest first', compare: (a, b) => b.bytes - a.bytes },
1779
- unlinked: { label: 'least linked', compare: (a, b) => a.inbound.length - b.inbound.length || (b.ageDays ?? 0) - (a.ageDays ?? 0) },
1780
- name: { label: 'by name', compare: (a, b) => a.name.localeCompare(b.name) },
1781
- };
1782
-
1783
- function renderBudget(container) {
1784
- const { index } = state.store.stats;
1785
- const percent = Math.min(100, index.worstPercent);
1786
- const level = index.level;
1787
-
1788
- const note = index.level === 'over'
1789
- ? 'Over the limit. Everything past the cutoff is dropped when the session loads it, so those entries are effectively invisible to Claude right now.'
1790
- : index.level === 'near'
1791
- ? 'Approaching the limit. Once MEMORY.md passes it, everything after the cutoff stops being loaded at all.'
1792
- : 'Comfortably inside the limit. MEMORY.md is loaded at the start of every session.';
1793
-
1794
- if (index.cutoff) {
1795
- const dropped = index.cutoff.droppedEntries;
1796
- const box = node('div', { class: ui.cutSummary });
1797
- box.append(node('div', { class: ui.cutSummaryHead, text: dropped.length
1798
- ? `${dropped.length} ${dropped.length === 1 ? 'memory is' : 'memories are'} past the cutoff and not loaded`
1799
- : `${index.cutoff.droppedLines} lines are past the cutoff and not loaded` }));
1800
- box.append(node('p', { class: ui.note, text: `MEMORY.md stops loading at line ${index.cutoff.rawLine + 1}, bounded by ${index.cutoff.by}. Claude cannot see anything below it.` }));
1801
- for (const entry of dropped.slice(0, 20)) {
1802
- box.append(node('div', { class: ui.issue(true) }, [
1803
- node('div', { class: ui.issueBody }, [
1804
- node('div', { class: ui.issueTitle, text: entry.title || entry.file }),
1805
- node('div', { class: ui.issueDetail, text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
1806
- ]),
1807
- node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(entry.file) }),
1808
- node('button', { class: ui.buttonPrimarySmall, text: 'Move up', onclick: () => openMoveDialog(entry) }),
1809
- ]));
1810
- }
1811
- if (dropped.length > 20) {
1812
- box.append(node('p', { class: ui.note, text: `${dropped.length - 20} more not listed.` }));
1813
- }
1814
- container.append(box);
1815
- }
1816
-
1817
- container.append(node('div', { class: ui.meter }, [
1818
- node('div', { class: ui.meterTop }, [
1819
- node('span', { class: ui.meterValue, text: `${Math.round(index.worstPercent)}%` }),
1820
- node('span', { class: ui.meterUnit, text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
1821
- ]),
1822
- node('div', { class: ui.meterBar }, [
1823
- node('div', { class: ui.meterFill(level), style: `width:${percent.toFixed(1)}%` }),
1824
- ]),
1825
- node('p', { class: ui.meterNote, text: note }),
1826
- node('div', { class: ui.meterFacts }, [
1827
- node('span', {}, [node('b', { class: ui.meterFactValue, text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
1828
- node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
1829
- node('span', {}, [node('b', { class: ui.meterFactValue, text: String(index.entryCount) }), document.createTextNode(' index entries')]),
1830
- node('span', {}, [node('b', { class: ui.meterFactValue, text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
1831
- ]),
1832
- node('p', { class: ui.meterNote, text: 'Claude Code loads the first 200 lines or 25KB of MEMORY.md, whichever comes first, and drops the rest. Topic files are only read when Claude follows a link, so detail belongs in them and MEMORY.md should stay one line per entry.' }),
1833
- ]));
1834
- }
1835
-
1836
- function renderPrune(container) {
1837
- const project = state.store;
1838
- renderBudget(container);
1839
-
1840
- if (!project.memories.length) {
1841
- container.append(node('p', { class: ui.note, text: 'Nothing to prune - this project has no memories.' }));
1842
- return;
1843
- }
1844
-
1845
- const selected = state.pruneSelection;
1846
- const bar = node('div', { class: ui.pruneBar });
1847
- const sort = node('select', { class: ui.select });
1848
- for (const [key, config] of Object.entries(PRUNE_SORTS)) {
1849
- sort.append(node('option', { value: key, text: config.label, selected: key === state.pruneSort ? '' : null }));
1850
- }
1851
- sort.value = state.pruneSort;
1852
- sort.onchange = () => { state.pruneSort = sort.value; renderTab(); };
1853
-
1854
- const count = node('span', { class: ui.note });
1855
- const deleteButton = node('button', { class: ui.buttonDangerSmall });
1856
- const clearButton = node('button', { class: ui.buttonSmall, text: 'Clear selection' });
1857
-
1858
- bar.append(node('span', { class: ui.sectionLabel, text: 'Prune' }), sort, node('span', { class: ui.pruneSpacer }), count, clearButton, deleteButton);
1859
- container.append(bar);
1860
-
1861
- const list = node('div');
1862
- const ordered = [...project.memories].sort(PRUNE_SORTS[state.pruneSort].compare);
1863
- const rows = [];
1864
- for (const memory of ordered) {
1865
- const box = node('input', { type: 'checkbox', class: ui.checkbox });
1866
- box.checked = selected.has(memory.file);
1867
- const row = node('label', { class: ui.pruneRow(box.checked) }, [
1868
- box,
1869
- node('span', { class: ui.pruneMain }, [
1870
- node('span', { class: ui.pruneName, text: memory.name }),
1871
- node('span', { class: ui.pruneDesc, text: memory.description || memory.file }),
1872
- ]),
1873
- node('span', { class: ui.pruneFacts }, [
1874
- node('span', { class: ui.pruneFact((memory.ageDays ?? 0) > 180), text: formatAge(memory.ageDays) }),
1875
- node('span', { text: `${(memory.bytes / 1024).toFixed(1)}K` }),
1876
- node('span', { class: ui.pruneFact(!memory.inbound.length), text: `${memory.inbound.length} in` }),
1877
- node('span', { class: ui.pruneFact(memory.status !== 'indexed'), text: memory.status }),
1878
- ]),
1879
- ]);
1880
- box.addEventListener('change', () => {
1881
- if (box.checked) selected.add(memory.file); else selected.delete(memory.file);
1882
- row.className = ui.pruneRow(box.checked);
1883
- updateBar();
1884
- });
1885
- rows.push({ box, memory });
1886
- list.append(row);
1887
- }
1888
- container.append(list);
1889
-
1890
- function updateBar() {
1891
- count.textContent = selected.size ? `${selected.size} selected` : '';
1892
- deleteButton.textContent = `Delete ${selected.size || ''}`.trim();
1893
- deleteButton.disabled = selected.size === 0;
1894
- }
1895
- clearButton.onclick = () => {
1896
- selected.clear();
1897
- renderTab();
1898
- };
1899
- deleteButton.onclick = () => openBulkDeleteDialog([...selected]);
1900
- updateBar();
1901
-
1902
- const { longHooks } = project.stats.index;
1903
- if (longHooks.length) {
1904
- container.append(node('div', { class: ui.sectionLabel, text: `Long index hooks · ${longHooks.length}` }));
1905
- 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.' }));
1906
- for (const hook of longHooks.slice(0, 12)) {
1907
- const row = node('div', { class: ui.issue(false) });
1908
- const editButton = node('button', { class: ui.buttonPrimarySmall, text: 'Edit hook' });
1909
- row.append(
1910
- node('div', { class: ui.issueBody }, [
1911
- node('div', { class: ui.issueTitle, text: `${hook.hookLength} chars: ${hook.title}` }),
1912
- node('div', { class: ui.issueDetail, text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
1913
- ]),
1914
- node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(hook.file) }),
1915
- editButton,
1916
- );
1917
- editButton.onclick = () => openHookEditor(row, hook.index);
1918
- container.append(row);
1919
- }
1920
- }
1921
-
1922
- if (project.duplicates.length) {
1923
- container.append(node('div', { class: ui.sectionLabel, text: `Possible overlap · ${project.duplicates.length}` }));
1924
- container.append(node('p', { class: ui.noteTight, text: 'Ranked by how much rare vocabulary these pairs share. This is a hint, not a verdict - open both and decide whether one covers the other.' }));
1925
- for (const pair of project.duplicates) {
1926
- container.append(node('div', { class: ui.dupe }, [
1927
- node('div', { class: ui.dupeHead }, [
1928
- node('span', { class: ui.dupeScore, text: `${pair.score}%` }),
1929
- node('span', { text: `shared: ${pair.shared.join(', ')}` }),
1930
- ]),
1931
- node('div', { class: ui.dupePair }, [
1932
- ...[pair.a, pair.b].map((side) => node('div', {
1933
- class: ui.dupeSide,
1934
- onclick: () => selectMemory(side.file),
1935
- }, [
1936
- node('div', { class: ui.dupeSideName, text: side.name }),
1937
- node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
1938
- ])),
1939
- ]),
1940
- node('div', { class: ui.dupeActions }, [
1941
- node('button', {
1942
- class: ui.buttonSmall,
1943
- text: `Merge into ${pair.a.name}`,
1944
- onclick: () => openMergeDialog(pair.a.file, pair.b.file),
1945
- }),
1946
- node('button', {
1947
- class: ui.buttonSmall,
1948
- text: `Merge into ${pair.b.name}`,
1949
- onclick: () => openMergeDialog(pair.b.file, pair.a.file),
1950
- }),
1951
- ]),
1952
- ]));
1953
- }
1954
- }
1955
- }
1956
-
1957
- async function openBulkDeleteDialog(files) {
1958
- if (!files.length) return;
1959
- const memories = files.map((file) => state.store.memories.find((m) => m.file === file)).filter(Boolean);
1960
-
1961
- const body = node('div', { class: ui.modalBody });
1962
- const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${memories.length} memories will be trashed` })]);
1963
- for (const memory of memories) {
1964
- removals.append(node('div', { class: ui.willItem('remove'), text: `${memory.file}${memory.entry ? ` · MEMORY.md line ${memory.entry.index + 1}` : ' · not in the index'}` }));
1965
- }
1966
- body.append(removals);
1967
-
1968
- const doomed = new Set(files);
1969
- const breaking = [];
1970
- for (const memory of memories) {
1971
- for (const link of memory.inbound) {
1972
- if (!doomed.has(link.from)) breaking.push({ from: link.from, target: link.target });
1973
- }
1974
- }
1975
- if (breaking.length) {
1976
- const block = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${breaking.length} link(s) from memories you are keeping will break` })]);
1977
- for (const link of breaking) block.append(node('div', { class: ui.willItem('keep'), text: `${link.from} → [[${link.target}]]` }));
1978
- body.append(block);
1979
- }
1980
-
1981
- body.append(node('p', { class: ui.noteTight, text: 'All of them go to memory/.trash/ as one restore point.' }));
1982
-
1983
- const modal = node('div', { class: ui.modal }, [
1984
- node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Delete ${memories.length} memories?` })]),
1985
- body,
1986
- node('footer', { class: ui.modalFoot }, [
1987
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1988
- node('button', {
1989
- class: ui.button({ tone: 'danger' }),
1990
- text: `Delete ${memories.length}`,
1991
- onclick: async () => {
1992
- closeModal();
1993
- try {
1994
- const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete-many`, {
1995
- method: 'POST',
1996
- body: JSON.stringify({ files, label: `${memories.length} pruned memories` }),
1997
- });
1998
- state.pruneSelection.clear();
1999
- if (doomed.has(state.selected)) state.selected = null;
2000
- await openStore(state.storeId, { keepTab: true });
2001
- toast(`Deleted ${memories.length} memories`, {
2002
- action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
2003
- });
2004
- } catch (err) {
2005
- toast(err.message, { error: true });
2006
- }
2007
- },
2008
- }),
2009
- ]),
2010
- ]);
2011
- const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
2012
- el('modal-root').append(backdrop);
2013
- }
2014
-
2015
- let searchTimer = null;
2016
-
2017
- function scheduleSearch(value) {
2018
- state.query = value;
2019
- clearTimeout(searchTimer);
2020
- if (!value.trim()) {
2021
- state.search = null;
2022
- renderView();
2023
- return;
2024
- }
2025
- searchTimer = setTimeout(runSearch, 180);
2026
- }
2027
-
2028
- async function runSearch() {
2029
- const query = state.query.trim();
2030
- if (!query) return;
2031
- try {
2032
- state.search = await api(`/api/search?q=${encodeURIComponent(query)}`);
2033
- } catch (err) {
2034
- return toast(err.message, { error: true });
2035
- }
2036
- renderView();
2037
- }
2038
-
2039
- function renderSearch() {
2040
- const data = state.search;
2041
- const host = el('search-results');
2042
- host.textContent = '';
2043
- el('search-title').textContent = data.total
2044
- ? `${data.total} result${data.total === 1 ? '' : 's'} for "${state.query.trim()}"`
2045
- : `No results for "${state.query.trim()}"`;
2046
-
2047
- if (!data.total && !data.stores.length) {
2048
- host.append(node('p', { class: ui.note, text: 'Nothing matched. Every word has to appear somewhere in a memory, so try fewer words.' }));
2049
- return;
2050
- }
2051
-
2052
- for (const group of data.stores) {
2053
- const block = node('div', { class: ui.resultGroup });
2054
- block.append(node('div', { class: ui.sectionLabel, text: group.sublabel
2055
- ? `${group.label} · ${group.sublabel} · ${group.results.length}`
2056
- : `${group.label} · ${group.results.length}` }));
2057
-
2058
- for (const result of group.results) {
2059
- const button = node('button', {
2060
- class: ui.result,
2061
- onclick: () => openFromSearch(group.id, result.file),
2062
- });
2063
- const top = node('div', { class: ui.resultTop });
2064
- const name = node('span', { class: ui.resultName });
2065
- name.append(highlight(result.name, data.terms));
2066
- top.append(name, node('span', { class: ui.typeBadge(result.type), text: result.type }));
2067
- if (result.status !== 'indexed') top.append(node('span', { class: ui.badge('warn'), text: result.status }));
2068
- button.append(top);
2069
-
2070
- if (result.snippet) {
2071
- const snippet = node('div', { class: ui.resultSnippet });
2072
- snippet.append(highlight(result.snippet.text, data.terms));
2073
- button.append(snippet);
2074
- }
2075
- button.append(node('div', { class: ui.resultWhere, text: `${result.file} · matched in ${result.fields.join(', ')}` }));
2076
- block.append(button);
2077
- }
2078
-
2079
- for (const hit of group.indexHits) {
2080
- const button = node('button', {
2081
- class: ui.result,
2082
- onclick: () => openFromSearch(group.id, hit.file, 'index'),
2083
- });
2084
- button.append(node('div', { class: ui.resultWhere, text: `MEMORY.md line ${hit.index + 1}` }));
2085
- const line = node('div', { class: ui.resultSnippet });
2086
- line.append(highlight(hit.text.trim(), data.terms));
2087
- button.append(line);
2088
- block.append(button);
2089
- }
2090
- host.append(block);
2091
- }
2092
- }
2093
-
2094
- async function openFromSearch(id, file, tab = 'memories') {
2095
- clearSearch();
2096
- if (id !== state.storeId) await openStore(id);
2097
- state.tab = tab;
2098
- if (file) state.selected = file;
2099
- renderTabs();
2100
- renderTab();
2101
- }
2102
-
2103
- function clearSearch() {
2104
- state.query = '';
2105
- state.search = null;
2106
- el('search').value = '';
2107
- renderView();
2108
- }
2109
-
2110
33
  function renderView() {
2111
34
  const searching = state.search !== null;
2112
35
  el('search-view').hidden = !searching;
@@ -2118,219 +41,21 @@ function renderView() {
2118
41
  function renderTab() {
2119
42
  const container = el('tab-content');
2120
43
  container.textContent = '';
2121
- if (state.tab === 'memories') renderMemories(container);
2122
- else if (state.tab === 'prune') renderPrune(container);
2123
- else if (state.tab === 'context') renderContext(container);
2124
- else if (state.tab === 'sessions') renderSessions(container);
2125
- else if (state.tab === 'index') renderIndex(container);
2126
- else if (state.tab === 'health') renderHealth(container);
2127
- else if (state.tab === 'settings') renderSettings(container);
2128
- else if (state.tab === 'trash') renderTrash(container);
2129
- else if (state.tab === 'graph') {
2130
- const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
2131
- container.append(wrap);
2132
- renderGraph(wrap, state.store.graph, {
2133
- selected: state.selected,
2134
- spread: state.spread,
2135
- onSelect: (file) => selectMemory(file),
2136
- onSpreadChange: (value) => {
2137
- state.spread = value;
2138
- localStorage.setItem('graphSpread', String(value));
2139
- renderTab();
2140
- },
2141
- });
2142
- }
2143
- }
2144
-
2145
- function selectMemory(file) {
2146
- state.selected = file;
2147
- if (state.tab !== 'memories') {
2148
- state.tab = 'memories';
2149
- renderTabs();
2150
- renderTab();
2151
- } else {
2152
- renderTab();
2153
- }
2154
- document.querySelector('[data-memory][data-active]')?.scrollIntoView({ block: 'nearest' });
2155
- }
2156
-
2157
- async function reloadStores() {
2158
- const data = await api('/api/stores');
2159
- state.stores = data.stores;
2160
- renderStores();
2161
- sweepIssues();
2162
- return data;
2163
- }
2164
-
2165
- async function sweepIssues() {
2166
- let data;
2167
- try {
2168
- data = await api('/api/stores/issues');
2169
- } catch {
2170
- return;
2171
- }
2172
- const bySummary = new Map(data.stores.map((summary) => [summary.id, summary]));
2173
- for (const store of state.stores) Object.assign(store, bySummary.get(store.id) || {});
2174
- renderStores();
2175
- }
2176
-
2177
- function provenanceLine(memory) {
2178
- const origin = memory.origin;
2179
- if (!origin) return null;
2180
-
2181
- const known = state.aux.sessions?.sessions.find((session) => session.id === origin.sessionId);
2182
- const label = origin.present ? (known?.title || origin.sessionId) : origin.sessionId;
2183
-
2184
- const row = node('div', { class: ui.provenanceRow }, [node('span', { text: 'written in' })]);
2185
-
2186
- if (origin.present) {
2187
- row.append(node('button', {
2188
- class: ui.provenanceLink(true),
2189
- text: label,
2190
- title: 'Open the Sessions tab',
2191
- onclick: () => goToTab('sessions'),
2192
- }));
2193
- if (known?.gitBranch) row.append(node('span', { text: `on ${known.gitBranch}` }));
2194
- if (origin.modified) row.append(node('span', { text: sessionDate(origin.modified).slice(0, 10) }));
2195
- if (!known) loadSessionsForProvenance();
2196
- } else {
2197
- row.append(node('span', {
2198
- class: ui.provenanceLink(false),
2199
- text: label,
2200
- title: 'This transcript is no longer on disk',
2201
- }));
2202
- row.append(node('span', { text: '- transcript swept, so why this memory exists can no longer be traced' }));
2203
- }
2204
- return row;
2205
- }
2206
-
2207
- async function loadSessionsForProvenance() {
2208
- if (state.aux.sessions || !state.store?.sessions?.count) return;
2209
- const id = state.storeId;
2210
- try {
2211
- const data = await api(`/api/stores/${encodeURIComponent(id)}/sessions`);
2212
- if (state.storeId !== id) return;
2213
- state.aux.sessions = data;
2214
- renderTab();
2215
- } catch {
2216
- return;
2217
- }
2218
- }
2219
-
2220
- function renderStoreHeader() {
2221
- const store = state.store;
2222
- el('project-title').textContent = store.label;
2223
- el('delete-project').hidden = store.kind === 'global';
2224
-
2225
- const sub = el('project-sub');
2226
- sub.textContent = '';
2227
-
2228
- if (store.kind === 'global') {
2229
- sub.append(node('span', { text: store.dir }));
2230
- sub.append(node('span', { class: ui.subNote, text: 'instructions every session loads, before any project is chosen' }));
2231
- return;
2232
- }
2233
-
2234
- if (store.kind !== 'auto') {
2235
- const scope = store.kind === 'agent-user' ? 'user'
2236
- : store.kind === 'agent-project' ? 'project' : 'local';
2237
- sub.append(node('span', { text: store.dir }));
2238
- sub.append(node('span', { class: ui.subNote, text: `subagent memory, ${scope} scope` }));
2239
- if (store.kind === 'agent-project') {
2240
- sub.append(node('span', { class: ui.subWarn, text: 'checked into the repository - changes here show up in git status' }));
2241
- }
2242
- return;
2243
- }
2244
-
2245
- sub.append(node('span', { text: store.resolvedBy === 'unresolved'
2246
- ? `${store.path} (real path unresolved)`
2247
- : store.path }));
2248
-
2249
- const others = (store.workingDirs || []).filter((d) => d !== store.path);
2250
- if (others.length) {
2251
- sub.append(node('span', { class: ui.subNote, text: `also used from ${others.join(', ')}` }));
2252
- }
2253
- if (store.resolvedBy === 'unresolved') {
2254
- sub.append(node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }));
2255
- } else if (store.resolvedBy === 'remembered') {
2256
- sub.append(node('span', { class: ui.subNote }, [
2257
- document.createTextNode('path remembered, not recovered from a transcript '),
2258
- node('button', { class: ui.linkButton, text: 'Forget', onclick: forgetProjectPath }),
2259
- ]));
2260
- }
2261
-
2262
- const evidence = store.sessions?.evidenceExpiresInDays;
2263
- if (!store.sessions?.remembered
2264
- && (store.resolvedBy === 'transcript' || store.resolvedBy === 'repo-root')
2265
- && typeof evidence === 'number' && evidence <= 14) {
2266
- sub.append(node('span', { class: ui.subWarn }, [
2267
- document.createTextNode(evidence === 0
2268
- ? 'the last transcript proving this path is due to be swept - the store keeps its memories and loses its name '
2269
- : `the last transcript proving this path is swept in ${evidence} days, and nothing else records it `),
2270
- node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }),
2271
- ]));
2272
- }
2273
-
2274
- const auto = store.autoMemory;
2275
- if (auto && auto.known && !auto.enabled) {
2276
- sub.append(node('span', { class: ui.subWarn, text: auto.scope === 'env'
2277
- ? 'auto memory is off (CLAUDE_CODE_DISABLE_AUTO_MEMORY) - this store will not grow'
2278
- : `auto memory is off (${auto.setBy}) - this store will not grow` }));
2279
- }
2280
- }
2281
-
2282
- async function openStore(id, { keepTab = false } = {}) {
2283
- if (id !== state.storeId) state.pruneSelection.clear();
2284
- state.storeId = id;
2285
- state.aux = { instructions: null, settings: null, sessions: null };
2286
- state.pathCheck = null;
2287
- if (!keepTab) {
2288
- const opening = state.stores.find((store) => store.id === id);
2289
- state.tab = opening && opening.kind === 'global' ? 'context' : 'memories';
2290
- state.selected = null;
2291
- }
2292
- renderStores();
2293
-
2294
- try {
2295
- state.store = await api(`/api/stores/${encodeURIComponent(id)}`);
2296
- } catch (err) {
2297
- return toast(err.message, { error: true });
2298
- }
2299
-
2300
- if (state.selected && !state.store.memories.some((m) => m.file === state.selected)) {
2301
- state.selected = null;
2302
- }
2303
-
2304
- renderStoreHeader();
2305
-
2306
- const listed = state.stores.find((store) => store.id === id);
2307
- if (listed) {
2308
- listed.memoryCount = state.store.memories.length;
2309
- listed.issueCount = state.store.health.issueCount;
2310
- listed.severity = worst(state.store.health.severity, listed.context ? 'warn' : 'ok', listed.settings ? 'warn' : 'ok');
2311
- }
2312
- renderStores();
2313
-
2314
- renderView();
2315
- renderTabs();
2316
- renderTab();
2317
-
2318
- Promise.all([
2319
- api(`/api/stores/${encodeURIComponent(id)}/instructions`).catch(() => null),
2320
- api(`/api/stores/${encodeURIComponent(id)}/settings`).catch(() => null),
2321
- ]).then(([instructions, settings]) => {
2322
- if (state.storeId !== id) return;
2323
- state.aux = { instructions, settings };
2324
- renderTabs();
2325
- if (!listed) return;
2326
- listed.context = instructions ? instructions.problems.length : 0;
2327
- listed.settings = settings ? settings.problems.length : 0;
2328
- listed.severity = worst(
2329
- state.store.health.severity,
2330
- instructions ? worstSeverity(instructions.problems) : 'ok',
2331
- settings ? worstSeverity(settings.problems) : 'ok',
2332
- );
2333
- renderStores();
44
+ const view = TAB_VIEWS[state.tab];
45
+ if (view) return view(container);
46
+ if (state.tab !== 'graph') return;
47
+
48
+ const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
49
+ container.append(wrap);
50
+ renderGraph(wrap, state.store.graph, {
51
+ selected: state.selected,
52
+ spread: state.spread,
53
+ onSelect: (file) => selectMemory(file),
54
+ onSpreadChange: (value) => {
55
+ state.spread = value;
56
+ localStorage.setItem('graphSpread', String(value));
57
+ paint('tab');
58
+ },
2334
59
  });
2335
60
  }
2336
61
 
@@ -2345,7 +70,7 @@ function paintTheme() {
2345
70
  document.documentElement.dataset.theme = state.theme;
2346
71
  const dark = state.theme === 'dark';
2347
72
  const button = el('theme-toggle');
2348
- button.textContent = dark ? '\u2600' : '\u263E';
73
+ button.textContent = dark ? '☀' : '☾';
2349
74
  button.title = dark ? 'Switch to the light theme ( t )' : 'Switch to the dark theme ( t )';
2350
75
  button.setAttribute('aria-label', dark ? 'Switch to the light theme' : 'Switch to the dark theme');
2351
76
  button.setAttribute('aria-pressed', String(dark));
@@ -2364,7 +89,7 @@ function toggleTheme() {
2364
89
  function setCollapsed(value) {
2365
90
  state.collapsed = value;
2366
91
  applyCollapsed();
2367
- if (state.tab === 'graph' && state.store) requestAnimationFrame(renderTab);
92
+ if (state.tab === 'graph' && state.store) requestAnimationFrame(() => paint('tab'));
2368
93
  }
2369
94
 
2370
95
  function applyStyles() {
@@ -2373,11 +98,20 @@ function applyStyles() {
2373
98
  }
2374
99
  }
2375
100
 
101
+ function registerRegions() {
102
+ register('stores', renderStores);
103
+ register('header', renderStoreHeader);
104
+ register('tabs', renderTabs);
105
+ register('tab', renderTab);
106
+ register('view', renderView);
107
+ }
108
+
2376
109
  async function init() {
110
+ registerRegions();
2377
111
  applyStyles();
2378
112
  el('show-all').addEventListener('change', (event) => {
2379
113
  state.showAll = event.target.checked;
2380
- renderStores();
114
+ paint('stores');
2381
115
  });
2382
116
 
2383
117
  el('search').addEventListener('input', (event) => scheduleSearch(event.target.value));
@@ -2393,6 +127,7 @@ async function init() {
2393
127
  paintTheme();
2394
128
  });
2395
129
  document.addEventListener('keydown', (event) => {
130
+ if (isDialogOpen()) return;
2396
131
  const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName);
2397
132
  if (event.key === '/' && !typing) {
2398
133
  event.preventDefault();
@@ -2407,8 +142,7 @@ async function init() {
2407
142
  return;
2408
143
  }
2409
144
  if (event.key === 'Escape') {
2410
- if (el('modal-root').firstChild) closeModal();
2411
- else if (state.search !== null) clearSearch();
145
+ if (state.search !== null) clearSearch();
2412
146
  else if (!state.collapsed) setCollapsed(true);
2413
147
  }
2414
148
  });