claude-memory-admin 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/package.json +8 -2
- package/public/app.mjs +369 -395
- package/public/graph.mjs +32 -76
- package/public/index.html +34 -29
- package/public/markdown.mjs +74 -0
- package/public/styles.css +2 -439
- package/public/ui.mjs +291 -0
package/public/app.mjs
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { marked } from '/vendor/marked.js';
|
|
2
|
-
import DOMPurify from '/vendor/purify.js';
|
|
3
1
|
import { renderGraph } from '/graph.mjs';
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import { renderMarkdown } from '/markdown.mjs';
|
|
3
|
+
import * as ui from '/ui.mjs';
|
|
6
4
|
|
|
7
5
|
const state = {
|
|
8
6
|
stores: [],
|
|
@@ -17,12 +15,11 @@ const state = {
|
|
|
17
15
|
search: null,
|
|
18
16
|
pruneSort: 'oldest',
|
|
19
17
|
pruneSelection: new Set(),
|
|
18
|
+
indexView: localStorage.getItem('memoryIndexView') === 'source' ? 'source' : 'rendered',
|
|
20
19
|
};
|
|
21
20
|
|
|
22
21
|
const el = (id) => document.getElementById(id);
|
|
23
22
|
|
|
24
|
-
/* ---------------------------------------------------------------- helpers */
|
|
25
|
-
|
|
26
23
|
async function api(path, options) {
|
|
27
24
|
const response = await fetch(path, {
|
|
28
25
|
headers: { 'content-type': 'application/json' },
|
|
@@ -35,10 +32,11 @@ async function api(path, options) {
|
|
|
35
32
|
|
|
36
33
|
function toast(message, { error = false, action } = {}) {
|
|
37
34
|
const node = document.createElement('div');
|
|
38
|
-
node.className =
|
|
35
|
+
node.className = ui.toast(error);
|
|
39
36
|
node.append(document.createTextNode(message));
|
|
40
37
|
if (action) {
|
|
41
38
|
const button = document.createElement('button');
|
|
39
|
+
button.className = ui.toastAction;
|
|
42
40
|
button.textContent = action.label;
|
|
43
41
|
button.onclick = () => { node.remove(); action.run(); };
|
|
44
42
|
node.append(button);
|
|
@@ -61,60 +59,26 @@ function node(tag, props = {}, children = []) {
|
|
|
61
59
|
return element;
|
|
62
60
|
}
|
|
63
61
|
|
|
64
|
-
|
|
65
|
-
* Render a memory body. Wikilinks are swapped for placeholders before markdown
|
|
66
|
-
* runs, then rehydrated as buttons afterwards, so they survive sanitising and
|
|
67
|
-
* can carry click handlers.
|
|
68
|
-
*/
|
|
69
|
-
function renderBody(container, memory, project) {
|
|
62
|
+
function memoryLookup(project) {
|
|
70
63
|
const byName = new Map();
|
|
71
|
-
for (const
|
|
72
|
-
byName.set(
|
|
73
|
-
byName.set(
|
|
64
|
+
for (const memory of project.memories) {
|
|
65
|
+
byName.set(memory.name, memory.file);
|
|
66
|
+
byName.set(memory.stem, memory.file);
|
|
74
67
|
}
|
|
68
|
+
return byName;
|
|
69
|
+
}
|
|
75
70
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const key = `%%WIKI${tokens.length}%%`;
|
|
79
|
-
tokens.push({ target: target.trim(), alias: (alias || '').trim() });
|
|
80
|
-
return key;
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const html = DOMPurify.sanitize(marked.parse(withPlaceholders));
|
|
84
|
-
container.innerHTML = html;
|
|
85
|
-
|
|
86
|
-
if (!tokens.length) return;
|
|
87
|
-
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
88
|
-
const texts = [];
|
|
89
|
-
while (walker.nextNode()) texts.push(walker.currentNode);
|
|
90
|
-
|
|
91
|
-
for (const textNode of texts) {
|
|
92
|
-
if (!/%%WIKI\d+%%/.test(textNode.nodeValue)) continue;
|
|
93
|
-
const fragment = document.createDocumentFragment();
|
|
94
|
-
const parts = textNode.nodeValue.split(/(%%WIKI\d+%%)/);
|
|
95
|
-
for (const part of parts) {
|
|
96
|
-
const match = part.match(/^%%WIKI(\d+)%%$/);
|
|
97
|
-
if (!match) {
|
|
98
|
-
if (part) fragment.append(document.createTextNode(part));
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
const token = tokens[Number(match[1])];
|
|
102
|
-
const targetFile = byName.get(token.target);
|
|
103
|
-
const button = node('button', {
|
|
104
|
-
class: `wikilink${targetFile ? '' : ' dangling'}`,
|
|
105
|
-
text: token.alias || token.target,
|
|
106
|
-
title: targetFile ? `Go to ${token.target}` : `No memory named "${token.target}"`,
|
|
107
|
-
});
|
|
108
|
-
if (targetFile) button.onclick = () => selectMemory(targetFile);
|
|
109
|
-
fragment.append(button);
|
|
110
|
-
}
|
|
111
|
-
textNode.replaceWith(fragment);
|
|
112
|
-
}
|
|
71
|
+
function openTarget(file) {
|
|
72
|
+
return file ? { open: () => selectMemory(file) } : null;
|
|
113
73
|
}
|
|
114
74
|
|
|
115
|
-
|
|
75
|
+
function renderBody(container, memory, project) {
|
|
76
|
+
const byName = memoryLookup(project);
|
|
77
|
+
renderMarkdown(container, memory.body, {
|
|
78
|
+
resolveWikilink: (target) => openTarget(byName.get(target)),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
116
81
|
|
|
117
|
-
/** The second line under a store's name: where it lives, in its own terms. */
|
|
118
82
|
function storeSubtitle(store) {
|
|
119
83
|
if (store.kind === 'auto') return store.path;
|
|
120
84
|
const scope = store.kind === 'agent-user' ? 'user'
|
|
@@ -126,19 +90,19 @@ function storeButton(store) {
|
|
|
126
90
|
const health = store.memoryCount === 0 ? 'none' : 'ok';
|
|
127
91
|
const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
|
|
128
92
|
return node('button', {
|
|
129
|
-
class:
|
|
93
|
+
class: ui.storeItem({ active: store.id === state.storeId, empty: !store.hasMemoryDir }),
|
|
130
94
|
onclick: () => openStore(store.id),
|
|
131
95
|
title: store.kind === 'auto' && store.resolvedBy === 'unresolved'
|
|
132
96
|
? 'Real path could not be resolved - showing the raw folder name'
|
|
133
97
|
: store.dir,
|
|
134
98
|
}, [
|
|
135
|
-
node('span', { class:
|
|
136
|
-
node('span', { class:
|
|
137
|
-
node('span', { class:
|
|
138
|
-
off ? node('span', { class:
|
|
139
|
-
node('span', { class:
|
|
99
|
+
node('span', { class: ui.storeRow }, [
|
|
100
|
+
node('span', { class: ui.dot(health) }),
|
|
101
|
+
node('span', { class: ui.storeName, text: store.label }),
|
|
102
|
+
off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
|
|
103
|
+
node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
|
|
140
104
|
]),
|
|
141
|
-
node('span', { class:
|
|
105
|
+
node('span', { class: ui.storePath, text: storeSubtitle(store) }),
|
|
142
106
|
]);
|
|
143
107
|
}
|
|
144
108
|
|
|
@@ -148,13 +112,10 @@ function renderStores() {
|
|
|
148
112
|
|
|
149
113
|
const visible = state.stores.filter((s) => state.showAll || s.hasMemoryDir);
|
|
150
114
|
if (!visible.length) {
|
|
151
|
-
list.append(node('p', { class:
|
|
115
|
+
list.append(node('p', { class: ui.note, text: 'No memory stores found.' }));
|
|
152
116
|
return;
|
|
153
117
|
}
|
|
154
118
|
|
|
155
|
-
// Subagent memory is a different thing from a project's auto memory - a
|
|
156
|
-
// different scope, written by a different agent - so it gets its own group
|
|
157
|
-
// rather than being mixed into the project list.
|
|
158
119
|
const groups = [
|
|
159
120
|
['Projects', visible.filter((s) => s.kind === 'auto')],
|
|
160
121
|
['Subagents', visible.filter((s) => s.kind !== 'auto')],
|
|
@@ -162,16 +123,13 @@ function renderStores() {
|
|
|
162
123
|
|
|
163
124
|
for (const [title, stores] of groups) {
|
|
164
125
|
if (!stores.length) continue;
|
|
165
|
-
// With nothing to tell apart, a single heading is just noise.
|
|
166
126
|
if (groups.every(([, group]) => group.length) ) {
|
|
167
|
-
list.append(node('div', { class:
|
|
127
|
+
list.append(node('div', { class: ui.sidebarGroup, text: title }));
|
|
168
128
|
}
|
|
169
129
|
for (const store of stores) list.append(storeButton(store));
|
|
170
130
|
}
|
|
171
131
|
}
|
|
172
132
|
|
|
173
|
-
/* ------------------------------------------------------------------ tabs */
|
|
174
|
-
|
|
175
133
|
const TABS = [
|
|
176
134
|
{ id: 'memories', label: 'Memories' },
|
|
177
135
|
{ id: 'index', label: 'MEMORY.md' },
|
|
@@ -198,43 +156,42 @@ function renderTabs() {
|
|
|
198
156
|
if (tab.id === 'prune' && state.store.stats.index.overTarget) badge = '!';
|
|
199
157
|
|
|
200
158
|
container.append(node('button', {
|
|
201
|
-
class:
|
|
159
|
+
class: ui.tab(state.tab === tab.id),
|
|
202
160
|
onclick: () => { state.tab = tab.id; renderTabs(); renderTab(); },
|
|
203
161
|
}, [
|
|
204
162
|
document.createTextNode(tab.label),
|
|
205
|
-
badge ? node('span', { class:
|
|
163
|
+
badge ? node('span', { class: ui.tabBadge(tab.id === 'health' || tab.id === 'prune'), text: badge }) : null,
|
|
206
164
|
]));
|
|
207
165
|
}
|
|
208
166
|
}
|
|
209
167
|
|
|
210
|
-
/* -------------------------------------------------------------- memories */
|
|
211
|
-
|
|
212
168
|
function memoryButton(memory) {
|
|
169
|
+
const active = state.selected === memory.file;
|
|
213
170
|
return node('button', {
|
|
214
|
-
class:
|
|
171
|
+
class: ui.memoryItem(active),
|
|
172
|
+
'data-memory': memory.file,
|
|
173
|
+
'data-active': active ? '' : null,
|
|
215
174
|
onclick: () => selectMemory(memory.file),
|
|
216
175
|
}, [
|
|
217
|
-
node('span', { class:
|
|
218
|
-
node('span', { class:
|
|
219
|
-
node('span', { class:
|
|
176
|
+
node('span', { class: ui.memoryTop }, [
|
|
177
|
+
node('span', { class: ui.memoryName, text: memory.name }),
|
|
178
|
+
node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
|
|
220
179
|
memory.status !== 'indexed'
|
|
221
|
-
? node('span', { class:
|
|
180
|
+
? node('span', { class: ui.badge('warn'), text: memory.status })
|
|
222
181
|
: null,
|
|
223
182
|
]),
|
|
224
|
-
memory.description ? node('span', { class:
|
|
183
|
+
memory.description ? node('span', { class: ui.memoryDesc, text: memory.description }) : null,
|
|
225
184
|
]);
|
|
226
185
|
}
|
|
227
186
|
|
|
228
187
|
function renderMemories(container) {
|
|
229
188
|
const project = state.store;
|
|
230
189
|
if (!project.memories.length) {
|
|
231
|
-
container.append(node('p', { class:
|
|
190
|
+
container.append(node('p', { class: ui.note, text: 'This project has no memory files yet.' }));
|
|
232
191
|
return;
|
|
233
192
|
}
|
|
234
193
|
|
|
235
194
|
const left = node('div');
|
|
236
|
-
// Group by the MEMORY.md heading each entry sits under, so the list mirrors
|
|
237
|
-
// how the index is actually organised.
|
|
238
195
|
const groups = new Map();
|
|
239
196
|
for (const memory of project.memories) {
|
|
240
197
|
const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
|
|
@@ -242,12 +199,12 @@ function renderMemories(container) {
|
|
|
242
199
|
groups.get(key).push(memory);
|
|
243
200
|
}
|
|
244
201
|
for (const [section, memories] of groups) {
|
|
245
|
-
left.append(node('div', { class:
|
|
202
|
+
left.append(node('div', { class: ui.sectionLabel, text: `${section} · ${memories.length}` }));
|
|
246
203
|
for (const memory of memories) left.append(memoryButton(memory));
|
|
247
204
|
}
|
|
248
205
|
|
|
249
|
-
const right = node('div', { id: 'detail' });
|
|
250
|
-
container.append(node('div', { class:
|
|
206
|
+
const right = node('div', { id: 'detail', class: ui.detailPane });
|
|
207
|
+
container.append(node('div', { class: ui.split }, [left, right]));
|
|
251
208
|
renderDetail();
|
|
252
209
|
}
|
|
253
210
|
|
|
@@ -258,30 +215,28 @@ function renderDetail() {
|
|
|
258
215
|
|
|
259
216
|
const memory = state.store.memories.find((m) => m.file === state.selected);
|
|
260
217
|
if (!memory) {
|
|
261
|
-
host.append(node('p', { class:
|
|
218
|
+
host.append(node('p', { class: ui.note, text: 'Select a memory to read it.' }));
|
|
262
219
|
return;
|
|
263
220
|
}
|
|
264
221
|
|
|
265
|
-
const card = node('div', { class:
|
|
266
|
-
card.append(node('div', { class:
|
|
267
|
-
node('h3', { text: memory.name }),
|
|
268
|
-
node('span', { class:
|
|
222
|
+
const card = node('div', { class: ui.card });
|
|
223
|
+
card.append(node('div', { class: ui.detailHead }, [
|
|
224
|
+
node('h3', { class: ui.detailTitle, text: memory.name }),
|
|
225
|
+
node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
|
|
269
226
|
node('button', {
|
|
270
|
-
class:
|
|
227
|
+
class: ui.buttonDangerSmall,
|
|
271
228
|
text: 'Delete',
|
|
272
229
|
onclick: () => openDeleteDialog(memory.file),
|
|
273
230
|
}),
|
|
274
231
|
]));
|
|
275
232
|
|
|
276
|
-
if (memory.description) card.append(node('p', { class:
|
|
233
|
+
if (memory.description) card.append(node('p', { class: ui.detailDesc, text: memory.description }));
|
|
277
234
|
|
|
278
|
-
const meta = node('dl', { class:
|
|
235
|
+
const meta = node('dl', { class: ui.metaList });
|
|
279
236
|
const rows = [
|
|
280
237
|
['file', memory.file],
|
|
281
238
|
['status', memory.status],
|
|
282
239
|
['modified', memory.modified
|
|
283
|
-
// mtime is reset by any copy or restore, so a date that is only a file
|
|
284
|
-
// timestamp is weaker evidence than one Claude Code actually stamped.
|
|
285
240
|
? `${memory.modified.replace('T', ' ').replace(/\..*$/, '')}${memory.modifiedFrom === 'mtime' ? ' (file mtime)' : ''}`
|
|
286
241
|
: '-'],
|
|
287
242
|
['size', `${(memory.bytes / 1024).toFixed(1)} KB`],
|
|
@@ -291,23 +246,23 @@ function renderDetail() {
|
|
|
291
246
|
if (key !== 'type' && key !== 'modified') rows.push([key, value]);
|
|
292
247
|
}
|
|
293
248
|
for (const [key, value] of rows) {
|
|
294
|
-
meta.append(node('dt', { text: key }), node('dd', { text: String(value) }));
|
|
249
|
+
meta.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: String(value) }));
|
|
295
250
|
}
|
|
296
251
|
card.append(meta);
|
|
297
252
|
|
|
298
|
-
const body = node('div', { class:
|
|
253
|
+
const body = node('div', { class: ui.prose });
|
|
299
254
|
card.append(body);
|
|
300
255
|
renderBody(body, memory, state.store);
|
|
301
256
|
|
|
302
257
|
const outbound = memory.outboundResolved || [];
|
|
303
258
|
if (outbound.length || memory.inbound.length) {
|
|
304
|
-
const links = node('div', {
|
|
259
|
+
const links = node('div', { class: ui.linkSection });
|
|
305
260
|
if (outbound.length) {
|
|
306
|
-
links.append(node('div', { class:
|
|
307
|
-
const chips = node('div', { class:
|
|
261
|
+
links.append(node('div', { class: ui.sectionLabel, text: 'Links out' }));
|
|
262
|
+
const chips = node('div', { class: ui.linkRow });
|
|
308
263
|
for (const link of outbound) {
|
|
309
264
|
chips.append(node('button', {
|
|
310
|
-
class:
|
|
265
|
+
class: ui.chip(Boolean(link.file)),
|
|
311
266
|
text: link.file ? link.target : `${link.target} (missing)`,
|
|
312
267
|
onclick: link.file ? () => selectMemory(link.file) : null,
|
|
313
268
|
}));
|
|
@@ -315,12 +270,12 @@ function renderDetail() {
|
|
|
315
270
|
links.append(chips);
|
|
316
271
|
}
|
|
317
272
|
if (memory.inbound.length) {
|
|
318
|
-
links.append(node('div', { class:
|
|
319
|
-
const chips = node('div', { class:
|
|
273
|
+
links.append(node('div', { class: ui.sectionLabel, text: 'Linked from' }));
|
|
274
|
+
const chips = node('div', { class: ui.linkRow });
|
|
320
275
|
for (const link of memory.inbound) {
|
|
321
276
|
const source = state.store.memories.find((m) => m.file === link.from);
|
|
322
277
|
chips.append(node('button', {
|
|
323
|
-
class:
|
|
278
|
+
class: ui.chip(true),
|
|
324
279
|
text: source ? source.name : link.from,
|
|
325
280
|
onclick: () => selectMemory(link.from),
|
|
326
281
|
}));
|
|
@@ -333,45 +288,96 @@ function renderDetail() {
|
|
|
333
288
|
host.append(card);
|
|
334
289
|
}
|
|
335
290
|
|
|
336
|
-
|
|
291
|
+
const INDEX_VIEWS = [
|
|
292
|
+
{ id: 'rendered', label: 'Rendered' },
|
|
293
|
+
{ id: 'source', label: 'Source' },
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
function setIndexView(view) {
|
|
297
|
+
state.indexView = view;
|
|
298
|
+
localStorage.setItem('memoryIndexView', view);
|
|
299
|
+
renderTab();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function indexViewPicker() {
|
|
303
|
+
return node('div', { class: ui.segmentGroup, role: 'tablist' }, INDEX_VIEWS.map((view) => node('button', {
|
|
304
|
+
class: ui.segment(state.indexView === view.id),
|
|
305
|
+
text: view.label,
|
|
306
|
+
role: 'tab',
|
|
307
|
+
'aria-selected': String(state.indexView === view.id),
|
|
308
|
+
onclick: () => setIndexView(view.id),
|
|
309
|
+
})));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function cutMarker(cutoff) {
|
|
313
|
+
return node('div', { class: ui.cutLine }, [
|
|
314
|
+
node('span', { class: ui.cutLabel, text: `not loaded past here — ${cutoff.droppedLines} lines dropped on ${cutoff.by}` }),
|
|
315
|
+
]);
|
|
316
|
+
}
|
|
337
317
|
|
|
338
318
|
function renderIndex(container) {
|
|
339
319
|
const project = state.store;
|
|
340
320
|
if (!project.hasIndex) {
|
|
341
|
-
container.append(node('p', { class:
|
|
321
|
+
container.append(node('p', { class: ui.note, text: 'This project has no MEMORY.md.' }));
|
|
342
322
|
return;
|
|
343
323
|
}
|
|
344
324
|
|
|
325
|
+
const card = node('div', { class: ui.card });
|
|
326
|
+
card.append(node('div', { class: ui.cardHeadRow }, [
|
|
327
|
+
node('p', { class: ui.note, text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }),
|
|
328
|
+
indexViewPicker(),
|
|
329
|
+
]));
|
|
330
|
+
|
|
331
|
+
if (state.indexView === 'source') renderIndexSource(card, project);
|
|
332
|
+
else renderIndexRendered(card, project);
|
|
333
|
+
|
|
334
|
+
container.append(card);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function renderIndexRendered(card, project) {
|
|
338
|
+
const { cutoff } = project.stats.index;
|
|
339
|
+
const files = new Set(project.memories.map((memory) => memory.file));
|
|
340
|
+
const byName = memoryLookup(project);
|
|
341
|
+
const options = {
|
|
342
|
+
resolveWikilink: (target) => openTarget(byName.get(target)),
|
|
343
|
+
resolveHref: (href) => openTarget(files.has(href) ? href : null),
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const lines = project.index.raw.split('\n');
|
|
347
|
+
const loaded = node('div', { class: ui.prose });
|
|
348
|
+
renderMarkdown(loaded, cutoff ? lines.slice(0, cutoff.rawLine).join('\n') : project.index.raw, options);
|
|
349
|
+
card.append(loaded);
|
|
350
|
+
|
|
351
|
+
if (!cutoff) return;
|
|
352
|
+
card.append(cutMarker(cutoff));
|
|
353
|
+
const dropped = node('div', { class: ui.proseDim });
|
|
354
|
+
renderMarkdown(dropped, lines.slice(cutoff.rawLine).join('\n'), options);
|
|
355
|
+
card.append(dropped);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function renderIndexSource(card, project) {
|
|
345
359
|
const missing = new Set(project.health.danglingIndex.map((d) => d.index));
|
|
346
360
|
const { cutoff } = project.stats.index;
|
|
347
|
-
const card = node('div', { class: 'card' });
|
|
348
|
-
card.append(node('p', { class: 'muted', style: 'margin-top:0', text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }));
|
|
349
361
|
|
|
350
362
|
for (const line of project.index.lines) {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
card.append(row);
|
|
363
|
+
if (cutoff && line.index === cutoff.rawLine) card.append(cutMarker(cutoff));
|
|
364
|
+
|
|
365
|
+
const dangling = missing.has(line.index);
|
|
366
|
+
const dropped = Boolean(cutoff) && line.index >= cutoff.rawLine;
|
|
367
|
+
const clickable = line.kind === 'index' && !dangling;
|
|
368
|
+
const content = node('span', {
|
|
369
|
+
class: ui.indexLineText({ kind: line.kind, clickable, dropped }),
|
|
370
|
+
text: line.text || ' ',
|
|
371
|
+
});
|
|
372
|
+
if (clickable) content.onclick = () => selectMemory(line.file);
|
|
373
|
+
|
|
374
|
+
card.append(node('div', { class: ui.indexLine({ dangling, dropped }) }, [
|
|
375
|
+
node('span', { class: ui.indexLineNumber, text: String(line.index + 1) }),
|
|
376
|
+
content,
|
|
377
|
+
]));
|
|
368
378
|
}
|
|
369
|
-
container.append(card);
|
|
370
379
|
}
|
|
371
380
|
|
|
372
|
-
|
|
373
|
-
/* ------------------------------------------------------------- context */
|
|
374
|
-
|
|
375
381
|
const SCOPE_ORDER = ['managed', 'user', 'project', 'local'];
|
|
376
382
|
|
|
377
383
|
const CONTEXT_PROBLEMS = {
|
|
@@ -386,107 +392,103 @@ const CONTEXT_PROBLEMS = {
|
|
|
386
392
|
};
|
|
387
393
|
|
|
388
394
|
async function renderContext(container) {
|
|
389
|
-
container.append(node('p', { class:
|
|
395
|
+
container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
|
|
390
396
|
let data;
|
|
391
397
|
try {
|
|
392
398
|
data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
|
|
393
399
|
} catch (err) {
|
|
394
400
|
container.textContent = '';
|
|
395
|
-
return container.append(node('p', { class:
|
|
401
|
+
return container.append(node('p', { class: ui.note, text: err.message }));
|
|
396
402
|
}
|
|
397
|
-
// The tab may have been switched while the request was in flight.
|
|
398
403
|
if (state.tab !== 'context') return;
|
|
399
404
|
container.textContent = '';
|
|
400
405
|
|
|
401
406
|
if (!data.projectDir) {
|
|
402
|
-
return container.append(node('p', { class:
|
|
407
|
+
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.' }));
|
|
403
408
|
}
|
|
404
409
|
|
|
405
410
|
const { totals } = data;
|
|
406
|
-
container.append(node('div', { class:
|
|
407
|
-
node('div', { class:
|
|
408
|
-
node('span', { class:
|
|
409
|
-
node('span', { class:
|
|
411
|
+
container.append(node('div', { class: ui.meter }, [
|
|
412
|
+
node('div', { class: ui.meterTop }, [
|
|
413
|
+
node('span', { class: ui.meterValue, text: `~${totals.alwaysTokens.toLocaleString()}` }),
|
|
414
|
+
node('span', { class: ui.meterUnit, text: 'estimated tokens of instructions, every session' }),
|
|
410
415
|
]),
|
|
411
|
-
node('div', { class:
|
|
412
|
-
node('span', {}, [node('b', { text: String(totals.files) }), document.createTextNode(' files resolved')]),
|
|
413
|
-
node('span', {}, [node('b', { text: String(totals.alwaysLines) }), document.createTextNode(' lines always loaded')]),
|
|
414
|
-
node('span', {}, [node('b', { text: `${(totals.alwaysBytes / 1024).toFixed(1)} KB` }), document.createTextNode(' always loaded')]),
|
|
415
|
-
node('span', {}, [node('b', { text: String(totals.conditionalFiles) }), document.createTextNode(' path-scoped rules, loaded only on a match')]),
|
|
416
|
+
node('div', { class: ui.meterFacts }, [
|
|
417
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.files) }), document.createTextNode(' files resolved')]),
|
|
418
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.alwaysLines) }), document.createTextNode(' lines always loaded')]),
|
|
419
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(totals.alwaysBytes / 1024).toFixed(1)} KB` }), document.createTextNode(' always loaded')]),
|
|
420
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.conditionalFiles) }), document.createTextNode(' path-scoped rules, loaded only on a match')]),
|
|
416
421
|
]),
|
|
417
|
-
node('p', { class:
|
|
422
|
+
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.' }),
|
|
418
423
|
]));
|
|
419
424
|
|
|
420
425
|
if (data.problems.length) {
|
|
421
|
-
container.append(node('div', { class:
|
|
426
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
|
|
422
427
|
for (const problem of data.problems) {
|
|
423
428
|
const describe = CONTEXT_PROBLEMS[problem.kind];
|
|
424
429
|
const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
|
|
425
430
|
const bad = problem.kind === 'missing' || problem.kind === 'invalid-glob' || problem.kind === 'glob-budget';
|
|
426
|
-
container.append(node('div', { class:
|
|
427
|
-
node('div', { class:
|
|
428
|
-
node('div', { class:
|
|
429
|
-
node('div', { class:
|
|
431
|
+
container.append(node('div', { class: ui.issue(bad) }, [
|
|
432
|
+
node('div', { class: ui.issueBody }, [
|
|
433
|
+
node('div', { class: ui.issueTitle, text: title }),
|
|
434
|
+
node('div', { class: ui.issueDetail, text: detail }),
|
|
430
435
|
]),
|
|
431
436
|
]));
|
|
432
437
|
}
|
|
433
438
|
}
|
|
434
439
|
|
|
435
|
-
container.append(node('div', { class:
|
|
436
|
-
const card = node('div', { class:
|
|
440
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Loaded in this order · ${data.files.length}` }));
|
|
441
|
+
const card = node('div', { class: ui.card });
|
|
437
442
|
for (const file of data.files) {
|
|
438
|
-
const tags = [node('span', { class:
|
|
439
|
-
if (file.kind === 'import') tags.push(node('span', { class:
|
|
440
|
-
if (file.kind === 'rule') tags.push(node('span', { class:
|
|
441
|
-
if (file.kind === 'managed-settings') tags.push(node('span', { class:
|
|
442
|
-
if (file.conditional) tags.push(node('span', { class:
|
|
443
|
-
|
|
444
|
-
card.append(node('div', { class:
|
|
445
|
-
node('div', { class:
|
|
446
|
-
node('div', { class:
|
|
447
|
-
node('div', { class:
|
|
443
|
+
const tags = [node('span', { class: ui.scopeBadge(file.scope), text: file.scope })];
|
|
444
|
+
if (file.kind === 'import') tags.push(node('span', { class: ui.badge(), text: `import · depth ${file.depth}` }));
|
|
445
|
+
if (file.kind === 'rule') tags.push(node('span', { class: ui.badge(), text: 'rule' }));
|
|
446
|
+
if (file.kind === 'managed-settings') tags.push(node('span', { class: ui.badge(), text: 'claudeMd setting' }));
|
|
447
|
+
if (file.conditional) tags.push(node('span', { class: ui.badge('warn'), text: 'only on a path match' }));
|
|
448
|
+
|
|
449
|
+
card.append(node('div', { class: ui.contextRow }, [
|
|
450
|
+
node('div', { class: ui.contextMain }, [
|
|
451
|
+
node('div', { class: ui.contextTags }, tags),
|
|
452
|
+
node('div', { class: ui.contextFile, text: file.file }),
|
|
448
453
|
]),
|
|
449
|
-
node('div', { class:
|
|
454
|
+
node('div', { class: ui.contextSize, text: `${file.lines} L · ~${file.tokens.toLocaleString()} tok` }),
|
|
450
455
|
]));
|
|
451
456
|
}
|
|
452
457
|
container.append(card);
|
|
453
458
|
|
|
454
459
|
if (data.excluded.length) {
|
|
455
|
-
container.append(node('div', { class:
|
|
460
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Excluded by claudeMdExcludes · ${data.excluded.length}` }));
|
|
456
461
|
for (const file of data.excluded) {
|
|
457
|
-
container.append(node('div', { class:
|
|
458
|
-
node('div', { class:
|
|
462
|
+
container.append(node('div', { class: ui.issue(false) }, [
|
|
463
|
+
node('div', { class: ui.issueBody }, [node('div', { class: ui.issueDetail, text: file })]),
|
|
459
464
|
]));
|
|
460
465
|
}
|
|
461
466
|
}
|
|
462
467
|
|
|
463
|
-
container.append(node('p', { class:
|
|
468
|
+
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.' }));
|
|
464
469
|
}
|
|
465
470
|
|
|
466
|
-
/* -------------------------------------------------------------- health */
|
|
467
|
-
|
|
468
471
|
function issue(title, detail, { bad = false, action, secondary } = {}) {
|
|
469
|
-
return node('div', { class:
|
|
470
|
-
node('div', { class:
|
|
471
|
-
node('div', { class:
|
|
472
|
-
node('div', { class:
|
|
472
|
+
return node('div', { class: ui.issue(bad) }, [
|
|
473
|
+
node('div', { class: ui.issueBody }, [
|
|
474
|
+
node('div', { class: ui.issueTitle, text: title }),
|
|
475
|
+
node('div', { class: ui.issueDetail, text: detail }),
|
|
473
476
|
]),
|
|
474
|
-
secondary ? node('button', { class:
|
|
475
|
-
action ? node('button', { class:
|
|
477
|
+
secondary ? node('button', { class: ui.buttonSmall, text: secondary.label, onclick: secondary.run }) : null,
|
|
478
|
+
action ? node('button', { class: ui.buttonSmall, text: action.label, onclick: action.run }) : null,
|
|
476
479
|
]);
|
|
477
480
|
}
|
|
478
481
|
|
|
479
482
|
function renderHealth(container) {
|
|
480
483
|
const { health, memories } = state.store;
|
|
481
484
|
if (!health.issues.length) {
|
|
482
|
-
container.append(node('div', { class:
|
|
483
|
-
node('p', {
|
|
484
|
-
node('p', { class:
|
|
485
|
+
container.append(node('div', { class: ui.card }, [
|
|
486
|
+
node('p', { class: ui.okLine, text: '\u2713 No consistency problems found.' }),
|
|
487
|
+
node('p', { class: ui.noteTight, text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
|
|
485
488
|
]));
|
|
486
489
|
return;
|
|
487
490
|
}
|
|
488
491
|
|
|
489
|
-
// Rendered straight from health.issues so the tab and the badge always agree.
|
|
490
492
|
for (const item of health.issues) {
|
|
491
493
|
container.append(renderIssue(item, memories));
|
|
492
494
|
}
|
|
@@ -618,12 +620,10 @@ async function restoreFromTrash(id) {
|
|
|
618
620
|
}
|
|
619
621
|
}
|
|
620
622
|
|
|
621
|
-
/* --------------------------------------------------------------- trash */
|
|
622
|
-
|
|
623
623
|
function renderTrash(container) {
|
|
624
624
|
const trash = state.store.trash;
|
|
625
625
|
if (!trash.length) {
|
|
626
|
-
container.append(node('p', { class:
|
|
626
|
+
container.append(node('p', { class: ui.note, text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
|
|
627
627
|
return;
|
|
628
628
|
}
|
|
629
629
|
for (const record of trash) {
|
|
@@ -631,20 +631,18 @@ function renderTrash(container) {
|
|
|
631
631
|
const detail = record.kind === 'wikilink'
|
|
632
632
|
? `unlinked in ${record.sourceFile} · ${when}`
|
|
633
633
|
: `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
|
|
634
|
-
container.append(node('div', { class:
|
|
635
|
-
node('div', { class:
|
|
636
|
-
node('div', { class:
|
|
637
|
-
node('div', { class:
|
|
634
|
+
container.append(node('div', { class: ui.issue(!record.present) }, [
|
|
635
|
+
node('div', { class: ui.issueBody }, [
|
|
636
|
+
node('div', { class: ui.issueTitle, text: record.label || record.id }),
|
|
637
|
+
node('div', { class: ui.issueDetail, text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
|
|
638
638
|
]),
|
|
639
639
|
record.present
|
|
640
|
-
? node('button', { class:
|
|
640
|
+
? node('button', { class: ui.buttonPrimarySmall, text: 'Restore', onclick: () => restoreFromTrash(record.id) })
|
|
641
641
|
: null,
|
|
642
642
|
]));
|
|
643
643
|
}
|
|
644
644
|
}
|
|
645
645
|
|
|
646
|
-
/* -------------------------------------------------------- delete dialog */
|
|
647
|
-
|
|
648
646
|
function closeModal() {
|
|
649
647
|
el('modal-root').textContent = '';
|
|
650
648
|
}
|
|
@@ -660,53 +658,50 @@ async function openDeleteDialog(file) {
|
|
|
660
658
|
return toast(err.message, { error: true });
|
|
661
659
|
}
|
|
662
660
|
|
|
663
|
-
const body = node('div', { class:
|
|
661
|
+
const body = node('div', { class: ui.modalBody });
|
|
664
662
|
|
|
665
|
-
const removals = node('div', { class:
|
|
666
|
-
removals.append(node('div', { class: '
|
|
663
|
+
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
|
|
664
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.file} → memory/.trash/` }));
|
|
667
665
|
for (const line of [...preview.indexLines, ...preview.continuations]) {
|
|
668
|
-
removals.append(node('div', { class: '
|
|
666
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
|
|
669
667
|
}
|
|
670
668
|
if (!preview.indexLines.length && preview.hasIndex) {
|
|
671
|
-
removals.append(node('div', { class:
|
|
669
|
+
removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for this file - nothing to unlink there.' }));
|
|
672
670
|
}
|
|
673
671
|
body.append(removals);
|
|
674
672
|
|
|
675
673
|
if (preview.inlineRefs.length) {
|
|
676
|
-
const kept = node('div', { class:
|
|
674
|
+
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' })]);
|
|
677
675
|
for (const ref of preview.inlineRefs) {
|
|
678
|
-
kept.append(node('div', { class: '
|
|
676
|
+
kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
|
|
679
677
|
}
|
|
680
678
|
body.append(kept);
|
|
681
679
|
}
|
|
682
680
|
|
|
683
|
-
// Memories that link here can be deleted in the same operation. Ticking one
|
|
684
|
-
// is the common follow-up, so it lives in the dialog rather than forcing a
|
|
685
|
-
// second pass through the list.
|
|
686
681
|
const cascade = new Set();
|
|
687
682
|
if (preview.inboundWikilinks.length) {
|
|
688
|
-
const block = node('div', { class:
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
node('
|
|
692
|
-
|
|
693
|
-
|
|
683
|
+
const block = node('div', { class: ui.willBlock });
|
|
684
|
+
const selectAll = node('button', { class: ui.linkButton, text: 'select all' });
|
|
685
|
+
block.append(node('div', { class: ui.cascadeHead }, [
|
|
686
|
+
node('div', { class: ui.willTitle, text: `${preview.inboundWikilinks.length} memory(s) link here and will break` }),
|
|
687
|
+
selectAll,
|
|
688
|
+
]));
|
|
694
689
|
|
|
695
|
-
const list = node('div', { class:
|
|
690
|
+
const list = node('div', { class: ui.cascadeList });
|
|
696
691
|
const rows = [];
|
|
697
692
|
for (const link of preview.inboundWikilinks) {
|
|
698
|
-
const box = node('input', { type: 'checkbox' });
|
|
699
|
-
const row = node('label', { class:
|
|
693
|
+
const box = node('input', { type: 'checkbox', class: ui.checkbox });
|
|
694
|
+
const row = node('label', { class: ui.cascadeRow(false) }, [
|
|
700
695
|
box,
|
|
701
|
-
node('span', { class:
|
|
702
|
-
node('span', { class:
|
|
703
|
-
node('span', { class:
|
|
696
|
+
node('span', { class: ui.cascadeText }, [
|
|
697
|
+
node('span', { class: ui.cascadeName, text: link.fromName || link.from }),
|
|
698
|
+
node('span', { class: ui.cascadeDetail, text: link.indexLine
|
|
704
699
|
? `${link.from} · MEMORY.md line ${link.indexLine.index + 1}`
|
|
705
700
|
: `${link.from} · not in the index` }),
|
|
706
701
|
]),
|
|
707
702
|
]);
|
|
708
703
|
box.addEventListener('change', () => {
|
|
709
|
-
row.
|
|
704
|
+
row.className = ui.cascadeRow(box.checked);
|
|
710
705
|
if (box.checked) cascade.add(link.from);
|
|
711
706
|
else cascade.delete(link.from);
|
|
712
707
|
updateButton();
|
|
@@ -715,94 +710,87 @@ async function openDeleteDialog(file) {
|
|
|
715
710
|
list.append(row);
|
|
716
711
|
}
|
|
717
712
|
block.append(list);
|
|
718
|
-
block.append(node('p', { class:
|
|
713
|
+
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.' }));
|
|
719
714
|
body.append(block);
|
|
720
715
|
|
|
721
|
-
|
|
716
|
+
selectAll.onclick = (event) => {
|
|
722
717
|
event.preventDefault();
|
|
723
718
|
const turnOn = rows.some((r) => !r.box.checked);
|
|
724
719
|
for (const { box } of rows) {
|
|
725
720
|
if (box.checked !== turnOn) { box.checked = turnOn; box.dispatchEvent(new Event('change')); }
|
|
726
721
|
}
|
|
727
|
-
|
|
722
|
+
selectAll.textContent = turnOn ? 'select none' : 'select all';
|
|
728
723
|
};
|
|
729
724
|
}
|
|
730
725
|
|
|
731
|
-
body.append(node('p', { class:
|
|
726
|
+
body.append(node('p', { class: ui.noteTight, text: 'The file moves to memory/.trash/ with a restore record, so this can be undone.' }));
|
|
727
|
+
|
|
728
|
+
const confirmButton = node('button', {
|
|
729
|
+
class: ui.button({ tone: 'danger' }),
|
|
730
|
+
text: 'Delete',
|
|
731
|
+
onclick: async () => {
|
|
732
|
+
closeModal();
|
|
733
|
+
try {
|
|
734
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete`, {
|
|
735
|
+
method: 'POST',
|
|
736
|
+
body: JSON.stringify({ file, alsoDelete: [...cascade] }),
|
|
737
|
+
});
|
|
738
|
+
if (state.selected === file || cascade.has(state.selected)) state.selected = null;
|
|
739
|
+
await openStore(state.storeId, { keepTab: true });
|
|
740
|
+
const count = result.record.files.length;
|
|
741
|
+
toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
|
|
742
|
+
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
743
|
+
});
|
|
744
|
+
} catch (err) {
|
|
745
|
+
toast(err.message, { error: true });
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
});
|
|
732
749
|
|
|
733
|
-
const modal = node('div', { class:
|
|
734
|
-
node('header', {}, [
|
|
735
|
-
node('h3', { text: `Delete "${preview.name || preview.file}"?` }),
|
|
736
|
-
node('p', { class:
|
|
750
|
+
const modal = node('div', { class: ui.modal }, [
|
|
751
|
+
node('header', { class: ui.modalHead }, [
|
|
752
|
+
node('h3', { class: ui.modalTitle, text: `Delete "${preview.name || preview.file}"?` }),
|
|
753
|
+
node('p', { class: ui.note, text: preview.description || '' }),
|
|
737
754
|
]),
|
|
738
755
|
body,
|
|
739
|
-
node('footer', {}, [
|
|
740
|
-
node('button', { class:
|
|
741
|
-
|
|
742
|
-
class: 'btn danger',
|
|
743
|
-
text: 'Delete',
|
|
744
|
-
onclick: async () => {
|
|
745
|
-
closeModal();
|
|
746
|
-
try {
|
|
747
|
-
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete`, {
|
|
748
|
-
method: 'POST',
|
|
749
|
-
body: JSON.stringify({ file, alsoDelete: [...cascade] }),
|
|
750
|
-
});
|
|
751
|
-
if (state.selected === file || cascade.has(state.selected)) state.selected = null;
|
|
752
|
-
await openStore(state.storeId, { keepTab: true });
|
|
753
|
-
const count = result.record.files.length;
|
|
754
|
-
toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
|
|
755
|
-
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
756
|
-
});
|
|
757
|
-
} catch (err) {
|
|
758
|
-
toast(err.message, { error: true });
|
|
759
|
-
}
|
|
760
|
-
},
|
|
761
|
-
}),
|
|
756
|
+
node('footer', { class: ui.modalFoot }, [
|
|
757
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
758
|
+
confirmButton,
|
|
762
759
|
]),
|
|
763
760
|
]);
|
|
764
761
|
|
|
765
|
-
const backdrop = node('div', { class:
|
|
762
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
766
763
|
el('modal-root').append(backdrop);
|
|
767
764
|
updateButton();
|
|
768
765
|
|
|
769
766
|
function updateButton() {
|
|
770
|
-
|
|
771
|
-
if (button) button.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
|
|
767
|
+
confirmButton.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
|
|
772
768
|
}
|
|
773
769
|
}
|
|
774
770
|
|
|
775
|
-
/**
|
|
776
|
-
* Name a store whose transcripts have aged out.
|
|
777
|
-
*
|
|
778
|
-
* Claude Code sweeps session transcripts on a retention period but keeps memory
|
|
779
|
-
* forever, so the evidence that names a store expires while the store does not.
|
|
780
|
-
* This is the only thing in the app that writes outside a memory directory, so
|
|
781
|
-
* it happens only when asked for, and says so.
|
|
782
|
-
*/
|
|
783
771
|
function openRememberPathDialog() {
|
|
784
772
|
const store = state.store;
|
|
785
773
|
const input = node('input', {
|
|
786
774
|
type: 'text',
|
|
787
|
-
class:
|
|
775
|
+
class: ui.pathInput,
|
|
788
776
|
value: store.guess || '',
|
|
789
777
|
placeholder: '/Users/you/repos/the-project',
|
|
790
778
|
spellcheck: 'false',
|
|
791
779
|
});
|
|
792
780
|
|
|
793
|
-
const body = node('div', { class:
|
|
794
|
-
node('p', { class:
|
|
781
|
+
const body = node('div', { class: ui.modalBody }, [
|
|
782
|
+
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.` }),
|
|
795
783
|
input,
|
|
796
|
-
node('p', { class:
|
|
784
|
+
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.' }),
|
|
797
785
|
]);
|
|
798
786
|
|
|
799
|
-
const modal = node('div', { class:
|
|
800
|
-
node('header', {}, [node('h3', { text: 'Remember this project\u2019s path' })]),
|
|
787
|
+
const modal = node('div', { class: ui.modal }, [
|
|
788
|
+
node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Remember this project\u2019s path' })]),
|
|
801
789
|
body,
|
|
802
|
-
node('footer', {}, [
|
|
803
|
-
node('button', { class:
|
|
790
|
+
node('footer', { class: ui.modalFoot }, [
|
|
791
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
804
792
|
node('button', {
|
|
805
|
-
class: '
|
|
793
|
+
class: ui.button({ tone: 'primary' }),
|
|
806
794
|
text: 'Remember',
|
|
807
795
|
onclick: async () => {
|
|
808
796
|
const value = input.value.trim();
|
|
@@ -824,7 +812,7 @@ function openRememberPathDialog() {
|
|
|
824
812
|
]),
|
|
825
813
|
]);
|
|
826
814
|
|
|
827
|
-
const backdrop = node('div', { class:
|
|
815
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
828
816
|
el('modal-root').append(backdrop);
|
|
829
817
|
input.focus();
|
|
830
818
|
input.select();
|
|
@@ -853,40 +841,39 @@ async function openStoreDeleteDialog() {
|
|
|
853
841
|
return toast('This store has no memory to delete');
|
|
854
842
|
}
|
|
855
843
|
|
|
856
|
-
const body = node('div', { class:
|
|
857
|
-
const removals = node('div', { class:
|
|
844
|
+
const body = node('div', { class: ui.modalBody });
|
|
845
|
+
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
|
|
858
846
|
if (preview.hasIndex) {
|
|
859
|
-
removals.append(node('div', { class: '
|
|
847
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md (${preview.indexLines} lines) → memory/.trash/` }));
|
|
860
848
|
}
|
|
861
849
|
for (const entry of preview.files) {
|
|
862
|
-
removals.append(node('div', { class: '
|
|
850
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `${entry.file} → memory/.trash/` }));
|
|
863
851
|
}
|
|
864
852
|
body.append(removals);
|
|
865
853
|
|
|
866
|
-
body.append(node('div', { class:
|
|
867
|
-
node('div', { class:
|
|
868
|
-
node('div', { class: '
|
|
854
|
+
body.append(node('div', { class: ui.willBlock }, [
|
|
855
|
+
node('div', { class: ui.willTitle, text: 'Will be kept' }),
|
|
856
|
+
node('div', { class: ui.willItem('keep'), text: state.store.kind === 'auto'
|
|
869
857
|
? 'Session transcripts (*.jsonl) and the project folder itself'
|
|
870
858
|
: 'The agent-memory folder itself, and every other agent\u2019s memory' }),
|
|
871
859
|
]));
|
|
872
860
|
|
|
873
|
-
body.append(node('p', { class:
|
|
861
|
+
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.' }));
|
|
874
862
|
|
|
875
|
-
// A project-scoped agent store is committed, so this is a working-tree change.
|
|
876
863
|
if (state.store.kind === 'agent-project') {
|
|
877
|
-
body.append(node('p', { class:
|
|
864
|
+
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.' }));
|
|
878
865
|
}
|
|
879
866
|
|
|
880
|
-
const modal = node('div', { class:
|
|
881
|
-
node('header', {}, [
|
|
882
|
-
node('h3', { text: `Delete all memory for ${state.store.label}?` }),
|
|
883
|
-
node('p', { class:
|
|
867
|
+
const modal = node('div', { class: ui.modal }, [
|
|
868
|
+
node('header', { class: ui.modalHead }, [
|
|
869
|
+
node('h3', { class: ui.modalTitle, text: `Delete all memory for ${state.store.label}?` }),
|
|
870
|
+
node('p', { class: ui.note, text: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}` }),
|
|
884
871
|
]),
|
|
885
872
|
body,
|
|
886
|
-
node('footer', {}, [
|
|
887
|
-
node('button', { class:
|
|
873
|
+
node('footer', { class: ui.modalFoot }, [
|
|
874
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
888
875
|
node('button', {
|
|
889
|
-
class: '
|
|
876
|
+
class: ui.button({ tone: 'danger' }),
|
|
890
877
|
text: `Delete ${preview.files.length + (preview.hasIndex ? 1 : 0)} files`,
|
|
891
878
|
onclick: async () => {
|
|
892
879
|
closeModal();
|
|
@@ -905,13 +892,10 @@ async function openStoreDeleteDialog() {
|
|
|
905
892
|
]),
|
|
906
893
|
]);
|
|
907
894
|
|
|
908
|
-
const backdrop = node('div', { class:
|
|
895
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
909
896
|
el('modal-root').append(backdrop);
|
|
910
897
|
}
|
|
911
898
|
|
|
912
|
-
|
|
913
|
-
/* --------------------------------------------------------------- prune */
|
|
914
|
-
|
|
915
899
|
function highlight(text, terms) {
|
|
916
900
|
const wrapper = node('span');
|
|
917
901
|
if (!terms.length) { wrapper.textContent = text; return wrapper; }
|
|
@@ -919,7 +903,7 @@ function highlight(text, terms) {
|
|
|
919
903
|
let last = 0;
|
|
920
904
|
for (const match of text.matchAll(pattern)) {
|
|
921
905
|
if (match.index > last) wrapper.append(document.createTextNode(text.slice(last, match.index)));
|
|
922
|
-
wrapper.append(node('mark', { text: match[0] }));
|
|
906
|
+
wrapper.append(node('mark', { class: ui.resultMark, text: match[0] }));
|
|
923
907
|
last = match.index + match[0].length;
|
|
924
908
|
}
|
|
925
909
|
if (last < text.length) wrapper.append(document.createTextNode(text.slice(last)));
|
|
@@ -944,7 +928,7 @@ const PRUNE_SORTS = {
|
|
|
944
928
|
function renderBudget(container) {
|
|
945
929
|
const { index } = state.store.stats;
|
|
946
930
|
const percent = Math.min(100, index.worstPercent);
|
|
947
|
-
const level = index.level
|
|
931
|
+
const level = index.level;
|
|
948
932
|
|
|
949
933
|
const note = index.level === 'over'
|
|
950
934
|
? 'Over the limit. Everything past the cutoff is dropped when the session loads it, so those entries are effectively invisible to Claude right now.'
|
|
@@ -954,42 +938,42 @@ function renderBudget(container) {
|
|
|
954
938
|
|
|
955
939
|
if (index.cutoff) {
|
|
956
940
|
const dropped = index.cutoff.droppedEntries;
|
|
957
|
-
const box = node('div', { class:
|
|
958
|
-
box.append(node('div', { class:
|
|
941
|
+
const box = node('div', { class: ui.cutSummary });
|
|
942
|
+
box.append(node('div', { class: ui.cutSummaryHead, text: dropped.length
|
|
959
943
|
? `${dropped.length} ${dropped.length === 1 ? 'memory is' : 'memories are'} past the cutoff and not loaded`
|
|
960
944
|
: `${index.cutoff.droppedLines} lines are past the cutoff and not loaded` }));
|
|
961
|
-
box.append(node('p', { class:
|
|
945
|
+
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.` }));
|
|
962
946
|
for (const entry of dropped.slice(0, 20)) {
|
|
963
|
-
box.append(node('div', { class:
|
|
964
|
-
node('div', { class:
|
|
965
|
-
node('div', { class:
|
|
966
|
-
node('div', { class:
|
|
947
|
+
box.append(node('div', { class: ui.issue(true) }, [
|
|
948
|
+
node('div', { class: ui.issueBody }, [
|
|
949
|
+
node('div', { class: ui.issueTitle, text: entry.title || entry.file }),
|
|
950
|
+
node('div', { class: ui.issueDetail, text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
|
|
967
951
|
]),
|
|
968
|
-
node('button', { class:
|
|
952
|
+
node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(entry.file) }),
|
|
969
953
|
]));
|
|
970
954
|
}
|
|
971
955
|
if (dropped.length > 20) {
|
|
972
|
-
box.append(node('p', { class:
|
|
956
|
+
box.append(node('p', { class: ui.note, text: `${dropped.length - 20} more not listed.` }));
|
|
973
957
|
}
|
|
974
958
|
container.append(box);
|
|
975
959
|
}
|
|
976
960
|
|
|
977
|
-
container.append(node('div', { class:
|
|
978
|
-
node('div', { class:
|
|
979
|
-
node('span', { class:
|
|
980
|
-
node('span', { class:
|
|
961
|
+
container.append(node('div', { class: ui.meter }, [
|
|
962
|
+
node('div', { class: ui.meterTop }, [
|
|
963
|
+
node('span', { class: ui.meterValue, text: `${Math.round(index.worstPercent)}%` }),
|
|
964
|
+
node('span', { class: ui.meterUnit, text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
|
|
981
965
|
]),
|
|
982
|
-
node('div', { class:
|
|
983
|
-
node('div', { class:
|
|
966
|
+
node('div', { class: ui.meterBar }, [
|
|
967
|
+
node('div', { class: ui.meterFill(level), style: `width:${percent.toFixed(1)}%` }),
|
|
984
968
|
]),
|
|
985
|
-
node('p', { class:
|
|
986
|
-
node('div', { class:
|
|
987
|
-
node('span', {}, [node('b', { text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
|
|
988
|
-
node('span', {}, [node('b', { text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
|
|
989
|
-
node('span', {}, [node('b', { text: String(index.entryCount) }), document.createTextNode(' index entries')]),
|
|
990
|
-
node('span', {}, [node('b', { text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
|
|
969
|
+
node('p', { class: ui.meterNote, text: note }),
|
|
970
|
+
node('div', { class: ui.meterFacts }, [
|
|
971
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
|
|
972
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
|
|
973
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(index.entryCount) }), document.createTextNode(' index entries')]),
|
|
974
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
|
|
991
975
|
]),
|
|
992
|
-
node('p', { class:
|
|
976
|
+
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.' }),
|
|
993
977
|
]));
|
|
994
978
|
}
|
|
995
979
|
|
|
@@ -998,49 +982,48 @@ function renderPrune(container) {
|
|
|
998
982
|
renderBudget(container);
|
|
999
983
|
|
|
1000
984
|
if (!project.memories.length) {
|
|
1001
|
-
container.append(node('p', { class:
|
|
985
|
+
container.append(node('p', { class: ui.note, text: 'Nothing to prune - this project has no memories.' }));
|
|
1002
986
|
return;
|
|
1003
987
|
}
|
|
1004
988
|
|
|
1005
|
-
// --- bulk selection list ---
|
|
1006
989
|
const selected = state.pruneSelection;
|
|
1007
|
-
const bar = node('div', { class:
|
|
1008
|
-
const sort = node('select');
|
|
990
|
+
const bar = node('div', { class: ui.pruneBar });
|
|
991
|
+
const sort = node('select', { class: ui.select });
|
|
1009
992
|
for (const [key, config] of Object.entries(PRUNE_SORTS)) {
|
|
1010
993
|
sort.append(node('option', { value: key, text: config.label, selected: key === state.pruneSort ? '' : null }));
|
|
1011
994
|
}
|
|
1012
995
|
sort.value = state.pruneSort;
|
|
1013
996
|
sort.onchange = () => { state.pruneSort = sort.value; renderTab(); };
|
|
1014
997
|
|
|
1015
|
-
const count = node('span', { class:
|
|
1016
|
-
const deleteButton = node('button', { class:
|
|
1017
|
-
const clearButton = node('button', { class:
|
|
998
|
+
const count = node('span', { class: ui.note });
|
|
999
|
+
const deleteButton = node('button', { class: ui.buttonDangerSmall });
|
|
1000
|
+
const clearButton = node('button', { class: ui.buttonSmall, text: 'Clear selection' });
|
|
1018
1001
|
|
|
1019
|
-
bar.append(node('span', { class:
|
|
1002
|
+
bar.append(node('span', { class: ui.sectionLabel, text: 'Prune' }), sort, node('span', { class: ui.pruneSpacer }), count, clearButton, deleteButton);
|
|
1020
1003
|
container.append(bar);
|
|
1021
1004
|
|
|
1022
1005
|
const list = node('div');
|
|
1023
1006
|
const ordered = [...project.memories].sort(PRUNE_SORTS[state.pruneSort].compare);
|
|
1024
1007
|
const rows = [];
|
|
1025
1008
|
for (const memory of ordered) {
|
|
1026
|
-
const box = node('input', { type: 'checkbox' });
|
|
1009
|
+
const box = node('input', { type: 'checkbox', class: ui.checkbox });
|
|
1027
1010
|
box.checked = selected.has(memory.file);
|
|
1028
|
-
const row = node('label', { class:
|
|
1011
|
+
const row = node('label', { class: ui.pruneRow(box.checked) }, [
|
|
1029
1012
|
box,
|
|
1030
|
-
node('span', { class:
|
|
1031
|
-
node('span', { class:
|
|
1032
|
-
node('span', { class:
|
|
1013
|
+
node('span', { class: ui.pruneMain }, [
|
|
1014
|
+
node('span', { class: ui.pruneName, text: memory.name }),
|
|
1015
|
+
node('span', { class: ui.pruneDesc, text: memory.description || memory.file }),
|
|
1033
1016
|
]),
|
|
1034
|
-
node('span', { class:
|
|
1035
|
-
node('span', { class: (memory.ageDays ?? 0) > 180
|
|
1017
|
+
node('span', { class: ui.pruneFacts }, [
|
|
1018
|
+
node('span', { class: ui.pruneFact((memory.ageDays ?? 0) > 180), text: formatAge(memory.ageDays) }),
|
|
1036
1019
|
node('span', { text: `${(memory.bytes / 1024).toFixed(1)}K` }),
|
|
1037
|
-
node('span', { class: memory.inbound.length
|
|
1038
|
-
node('span', { class: memory.status
|
|
1020
|
+
node('span', { class: ui.pruneFact(!memory.inbound.length), text: `${memory.inbound.length} in` }),
|
|
1021
|
+
node('span', { class: ui.pruneFact(memory.status !== 'indexed'), text: memory.status }),
|
|
1039
1022
|
]),
|
|
1040
1023
|
]);
|
|
1041
1024
|
box.addEventListener('change', () => {
|
|
1042
1025
|
if (box.checked) selected.add(memory.file); else selected.delete(memory.file);
|
|
1043
|
-
row.
|
|
1026
|
+
row.className = ui.pruneRow(box.checked);
|
|
1044
1027
|
updateBar();
|
|
1045
1028
|
});
|
|
1046
1029
|
rows.push({ box, memory });
|
|
@@ -1052,7 +1035,6 @@ function renderPrune(container) {
|
|
|
1052
1035
|
count.textContent = selected.size ? `${selected.size} selected` : '';
|
|
1053
1036
|
deleteButton.textContent = `Delete ${selected.size || ''}`.trim();
|
|
1054
1037
|
deleteButton.disabled = selected.size === 0;
|
|
1055
|
-
deleteButton.style.opacity = selected.size ? '1' : '.45';
|
|
1056
1038
|
}
|
|
1057
1039
|
clearButton.onclick = () => {
|
|
1058
1040
|
selected.clear();
|
|
@@ -1061,39 +1043,37 @@ function renderPrune(container) {
|
|
|
1061
1043
|
deleteButton.onclick = () => openBulkDeleteDialog([...selected]);
|
|
1062
1044
|
updateBar();
|
|
1063
1045
|
|
|
1064
|
-
// --- long hooks ---
|
|
1065
1046
|
const { longHooks } = project.stats.index;
|
|
1066
1047
|
if (longHooks.length) {
|
|
1067
|
-
container.append(node('div', { class:
|
|
1068
|
-
container.append(node('p', { class:
|
|
1048
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Long index hooks · ${longHooks.length}` }));
|
|
1049
|
+
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.' }));
|
|
1069
1050
|
for (const hook of longHooks.slice(0, 12)) {
|
|
1070
|
-
container.append(node('div', { class:
|
|
1071
|
-
node('div', { class:
|
|
1072
|
-
node('div', { class:
|
|
1073
|
-
node('div', { class:
|
|
1051
|
+
container.append(node('div', { class: ui.issue(false) }, [
|
|
1052
|
+
node('div', { class: ui.issueBody }, [
|
|
1053
|
+
node('div', { class: ui.issueTitle, text: `${hook.hookLength} chars: ${hook.title}` }),
|
|
1054
|
+
node('div', { class: ui.issueDetail, text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
|
|
1074
1055
|
]),
|
|
1075
|
-
node('button', { class:
|
|
1056
|
+
node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(hook.file) }),
|
|
1076
1057
|
]));
|
|
1077
1058
|
}
|
|
1078
1059
|
}
|
|
1079
1060
|
|
|
1080
|
-
// --- possible overlap ---
|
|
1081
1061
|
if (project.duplicates.length) {
|
|
1082
|
-
container.append(node('div', { class:
|
|
1083
|
-
container.append(node('p', { class:
|
|
1062
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Possible overlap · ${project.duplicates.length}` }));
|
|
1063
|
+
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.' }));
|
|
1084
1064
|
for (const pair of project.duplicates) {
|
|
1085
|
-
container.append(node('div', { class:
|
|
1086
|
-
node('div', { class:
|
|
1087
|
-
node('span', { class:
|
|
1065
|
+
container.append(node('div', { class: ui.dupe }, [
|
|
1066
|
+
node('div', { class: ui.dupeHead }, [
|
|
1067
|
+
node('span', { class: ui.dupeScore, text: `${pair.score}%` }),
|
|
1088
1068
|
node('span', { text: `shared: ${pair.shared.join(', ')}` }),
|
|
1089
1069
|
]),
|
|
1090
|
-
node('div', { class:
|
|
1070
|
+
node('div', { class: ui.dupePair }, [
|
|
1091
1071
|
...[pair.a, pair.b].map((side) => node('div', {
|
|
1092
|
-
class:
|
|
1072
|
+
class: ui.dupeSide,
|
|
1093
1073
|
onclick: () => selectMemory(side.file),
|
|
1094
1074
|
}, [
|
|
1095
|
-
node('div', { class:
|
|
1096
|
-
node('div', { class:
|
|
1075
|
+
node('div', { class: ui.dupeSideName, text: side.name }),
|
|
1076
|
+
node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
|
|
1097
1077
|
])),
|
|
1098
1078
|
]),
|
|
1099
1079
|
]));
|
|
@@ -1105,14 +1085,13 @@ async function openBulkDeleteDialog(files) {
|
|
|
1105
1085
|
if (!files.length) return;
|
|
1106
1086
|
const memories = files.map((file) => state.store.memories.find((m) => m.file === file)).filter(Boolean);
|
|
1107
1087
|
|
|
1108
|
-
const body = node('div', { class:
|
|
1109
|
-
const removals = node('div', { class:
|
|
1088
|
+
const body = node('div', { class: ui.modalBody });
|
|
1089
|
+
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${memories.length} memories will be trashed` })]);
|
|
1110
1090
|
for (const memory of memories) {
|
|
1111
|
-
removals.append(node('div', { class: '
|
|
1091
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `${memory.file}${memory.entry ? ` · MEMORY.md line ${memory.entry.index + 1}` : ' · not in the index'}` }));
|
|
1112
1092
|
}
|
|
1113
1093
|
body.append(removals);
|
|
1114
1094
|
|
|
1115
|
-
// Links from memories that are NOT being deleted are the ones that break.
|
|
1116
1095
|
const doomed = new Set(files);
|
|
1117
1096
|
const breaking = [];
|
|
1118
1097
|
for (const memory of memories) {
|
|
@@ -1121,20 +1100,20 @@ async function openBulkDeleteDialog(files) {
|
|
|
1121
1100
|
}
|
|
1122
1101
|
}
|
|
1123
1102
|
if (breaking.length) {
|
|
1124
|
-
const block = node('div', { class:
|
|
1125
|
-
for (const link of breaking) block.append(node('div', { class: '
|
|
1103
|
+
const block = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${breaking.length} link(s) from memories you are keeping will break` })]);
|
|
1104
|
+
for (const link of breaking) block.append(node('div', { class: ui.willItem('keep'), text: `${link.from} → [[${link.target}]]` }));
|
|
1126
1105
|
body.append(block);
|
|
1127
1106
|
}
|
|
1128
1107
|
|
|
1129
|
-
body.append(node('p', { class:
|
|
1108
|
+
body.append(node('p', { class: ui.noteTight, text: 'All of them go to memory/.trash/ as one restore point.' }));
|
|
1130
1109
|
|
|
1131
|
-
const modal = node('div', { class:
|
|
1132
|
-
node('header', {}, [node('h3', { text: `Delete ${memories.length} memories?` })]),
|
|
1110
|
+
const modal = node('div', { class: ui.modal }, [
|
|
1111
|
+
node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Delete ${memories.length} memories?` })]),
|
|
1133
1112
|
body,
|
|
1134
|
-
node('footer', {}, [
|
|
1135
|
-
node('button', { class:
|
|
1113
|
+
node('footer', { class: ui.modalFoot }, [
|
|
1114
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
1136
1115
|
node('button', {
|
|
1137
|
-
class: '
|
|
1116
|
+
class: ui.button({ tone: 'danger' }),
|
|
1138
1117
|
text: `Delete ${memories.length}`,
|
|
1139
1118
|
onclick: async () => {
|
|
1140
1119
|
closeModal();
|
|
@@ -1156,12 +1135,10 @@ async function openBulkDeleteDialog(files) {
|
|
|
1156
1135
|
}),
|
|
1157
1136
|
]),
|
|
1158
1137
|
]);
|
|
1159
|
-
const backdrop = node('div', { class:
|
|
1138
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
1160
1139
|
el('modal-root').append(backdrop);
|
|
1161
1140
|
}
|
|
1162
1141
|
|
|
1163
|
-
/* -------------------------------------------------------------- search */
|
|
1164
|
-
|
|
1165
1142
|
let searchTimer = null;
|
|
1166
1143
|
|
|
1167
1144
|
function scheduleSearch(value) {
|
|
@@ -1195,44 +1172,44 @@ function renderSearch() {
|
|
|
1195
1172
|
: `No results for "${state.query.trim()}"`;
|
|
1196
1173
|
|
|
1197
1174
|
if (!data.total && !data.stores.length) {
|
|
1198
|
-
host.append(node('p', { class:
|
|
1175
|
+
host.append(node('p', { class: ui.note, text: 'Nothing matched. Every word has to appear somewhere in a memory, so try fewer words.' }));
|
|
1199
1176
|
return;
|
|
1200
1177
|
}
|
|
1201
1178
|
|
|
1202
1179
|
for (const group of data.stores) {
|
|
1203
|
-
const block = node('div', { class:
|
|
1204
|
-
block.append(node('div', { class:
|
|
1180
|
+
const block = node('div', { class: ui.resultGroup });
|
|
1181
|
+
block.append(node('div', { class: ui.sectionLabel, text: group.sublabel
|
|
1205
1182
|
? `${group.label} · ${group.sublabel} · ${group.results.length}`
|
|
1206
1183
|
: `${group.label} · ${group.results.length}` }));
|
|
1207
1184
|
|
|
1208
1185
|
for (const result of group.results) {
|
|
1209
1186
|
const button = node('button', {
|
|
1210
|
-
class:
|
|
1187
|
+
class: ui.result,
|
|
1211
1188
|
onclick: () => openFromSearch(group.id, result.file),
|
|
1212
1189
|
});
|
|
1213
|
-
const top = node('div', { class:
|
|
1214
|
-
const name = node('span', { class:
|
|
1190
|
+
const top = node('div', { class: ui.resultTop });
|
|
1191
|
+
const name = node('span', { class: ui.resultName });
|
|
1215
1192
|
name.append(highlight(result.name, data.terms));
|
|
1216
|
-
top.append(name, node('span', { class:
|
|
1217
|
-
if (result.status !== 'indexed') top.append(node('span', { class:
|
|
1193
|
+
top.append(name, node('span', { class: ui.typeBadge(result.type), text: result.type }));
|
|
1194
|
+
if (result.status !== 'indexed') top.append(node('span', { class: ui.badge('warn'), text: result.status }));
|
|
1218
1195
|
button.append(top);
|
|
1219
1196
|
|
|
1220
1197
|
if (result.snippet) {
|
|
1221
|
-
const snippet = node('div', { class:
|
|
1198
|
+
const snippet = node('div', { class: ui.resultSnippet });
|
|
1222
1199
|
snippet.append(highlight(result.snippet.text, data.terms));
|
|
1223
1200
|
button.append(snippet);
|
|
1224
1201
|
}
|
|
1225
|
-
button.append(node('div', { class:
|
|
1202
|
+
button.append(node('div', { class: ui.resultWhere, text: `${result.file} · matched in ${result.fields.join(', ')}` }));
|
|
1226
1203
|
block.append(button);
|
|
1227
1204
|
}
|
|
1228
1205
|
|
|
1229
1206
|
for (const hit of group.indexHits) {
|
|
1230
1207
|
const button = node('button', {
|
|
1231
|
-
class:
|
|
1208
|
+
class: ui.result,
|
|
1232
1209
|
onclick: () => openFromSearch(group.id, hit.file, 'index'),
|
|
1233
1210
|
});
|
|
1234
|
-
button.append(node('div', { class:
|
|
1235
|
-
const line = node('div', { class:
|
|
1211
|
+
button.append(node('div', { class: ui.resultWhere, text: `MEMORY.md line ${hit.index + 1}` }));
|
|
1212
|
+
const line = node('div', { class: ui.resultSnippet });
|
|
1236
1213
|
line.append(highlight(hit.text.trim(), data.terms));
|
|
1237
1214
|
button.append(line);
|
|
1238
1215
|
block.append(button);
|
|
@@ -1265,8 +1242,6 @@ function renderView() {
|
|
|
1265
1242
|
if (searching) renderSearch();
|
|
1266
1243
|
}
|
|
1267
1244
|
|
|
1268
|
-
/* ------------------------------------------------------------ rendering */
|
|
1269
|
-
|
|
1270
1245
|
function renderTab() {
|
|
1271
1246
|
const container = el('tab-content');
|
|
1272
1247
|
container.textContent = '';
|
|
@@ -1277,7 +1252,7 @@ function renderTab() {
|
|
|
1277
1252
|
else if (state.tab === 'health') renderHealth(container);
|
|
1278
1253
|
else if (state.tab === 'trash') renderTrash(container);
|
|
1279
1254
|
else if (state.tab === 'graph') {
|
|
1280
|
-
const wrap = node('div', { id: 'graph-wrap' });
|
|
1255
|
+
const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
|
|
1281
1256
|
container.append(wrap);
|
|
1282
1257
|
renderGraph(wrap, state.store.graph, {
|
|
1283
1258
|
selected: state.selected,
|
|
@@ -1301,10 +1276,9 @@ function selectMemory(file) {
|
|
|
1301
1276
|
} else {
|
|
1302
1277
|
renderTab();
|
|
1303
1278
|
}
|
|
1304
|
-
document.querySelector('
|
|
1279
|
+
document.querySelector('[data-memory][data-active]')?.scrollIntoView({ block: 'nearest' });
|
|
1305
1280
|
}
|
|
1306
1281
|
|
|
1307
|
-
/** Re-read the store list, e.g. after a path is remembered or forgotten. */
|
|
1308
1282
|
async function reloadStores() {
|
|
1309
1283
|
const data = await api('/api/stores');
|
|
1310
1284
|
state.stores = data.stores;
|
|
@@ -1312,7 +1286,6 @@ async function reloadStores() {
|
|
|
1312
1286
|
return data;
|
|
1313
1287
|
}
|
|
1314
1288
|
|
|
1315
|
-
/** The name and provenance line above the tabs, which differs by store kind. */
|
|
1316
1289
|
function renderStoreHeader() {
|
|
1317
1290
|
const store = state.store;
|
|
1318
1291
|
el('project-title').textContent = store.label;
|
|
@@ -1324,11 +1297,9 @@ function renderStoreHeader() {
|
|
|
1324
1297
|
const scope = store.kind === 'agent-user' ? 'user'
|
|
1325
1298
|
: store.kind === 'agent-project' ? 'project' : 'local';
|
|
1326
1299
|
sub.append(node('span', { text: store.dir }));
|
|
1327
|
-
sub.append(node('span', { class:
|
|
1328
|
-
// A project-scoped store is committed, so deleting from it edits the repo
|
|
1329
|
-
// rather than a private cache. Worth saying before, not after.
|
|
1300
|
+
sub.append(node('span', { class: ui.subNote, text: `subagent memory, ${scope} scope` }));
|
|
1330
1301
|
if (store.kind === 'agent-project') {
|
|
1331
|
-
sub.append(node('span', { class:
|
|
1302
|
+
sub.append(node('span', { class: ui.subWarn, text: 'checked into the repository - changes here show up in git status' }));
|
|
1332
1303
|
}
|
|
1333
1304
|
return;
|
|
1334
1305
|
}
|
|
@@ -1337,24 +1308,22 @@ function renderStoreHeader() {
|
|
|
1337
1308
|
? `${store.path} (real path unresolved)`
|
|
1338
1309
|
: store.path }));
|
|
1339
1310
|
|
|
1340
|
-
// One store serves every worktree and subdirectory of a repository, which is
|
|
1341
|
-
// not obvious from a single path and changes what a memory here applies to.
|
|
1342
1311
|
const others = (store.workingDirs || []).filter((d) => d !== store.path);
|
|
1343
1312
|
if (others.length) {
|
|
1344
|
-
sub.append(node('span', { class:
|
|
1313
|
+
sub.append(node('span', { class: ui.subNote, text: `also used from ${others.join(', ')}` }));
|
|
1345
1314
|
}
|
|
1346
1315
|
if (store.resolvedBy === 'unresolved') {
|
|
1347
|
-
sub.append(node('button', { class:
|
|
1316
|
+
sub.append(node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }));
|
|
1348
1317
|
} else if (store.resolvedBy === 'remembered') {
|
|
1349
|
-
sub.append(node('span', { class:
|
|
1318
|
+
sub.append(node('span', { class: ui.subNote }, [
|
|
1350
1319
|
document.createTextNode('path remembered, not recovered from a transcript '),
|
|
1351
|
-
node('button', { class:
|
|
1320
|
+
node('button', { class: ui.linkButton, text: 'Forget', onclick: forgetProjectPath }),
|
|
1352
1321
|
]));
|
|
1353
1322
|
}
|
|
1354
1323
|
|
|
1355
1324
|
const auto = store.autoMemory;
|
|
1356
1325
|
if (auto && auto.known && !auto.enabled) {
|
|
1357
|
-
sub.append(node('span', { class:
|
|
1326
|
+
sub.append(node('span', { class: ui.subWarn, text: auto.scope === 'env'
|
|
1358
1327
|
? 'auto memory is off (CLAUDE_CODE_DISABLE_AUTO_MEMORY) - this store will not grow'
|
|
1359
1328
|
: `auto memory is off (${auto.setBy}) - this store will not grow` }));
|
|
1360
1329
|
}
|
|
@@ -1381,7 +1350,6 @@ async function openStore(id, { keepTab = false } = {}) {
|
|
|
1381
1350
|
|
|
1382
1351
|
renderStoreHeader();
|
|
1383
1352
|
|
|
1384
|
-
// Refresh the sidebar count in case a delete changed it.
|
|
1385
1353
|
const listed = state.stores.find((store) => store.id === id);
|
|
1386
1354
|
if (listed) listed.memoryCount = state.store.memories.length;
|
|
1387
1355
|
renderStores();
|
|
@@ -1392,7 +1360,8 @@ async function openStore(id, { keepTab = false } = {}) {
|
|
|
1392
1360
|
}
|
|
1393
1361
|
|
|
1394
1362
|
function applyCollapsed() {
|
|
1395
|
-
el('app').
|
|
1363
|
+
el('app').className = state.collapsed ? ui.shellCollapsed : ui.shell;
|
|
1364
|
+
el('sidebar').className = state.collapsed ? ui.sidebarHidden : ui.sidebar;
|
|
1396
1365
|
el('expand').hidden = !state.collapsed;
|
|
1397
1366
|
localStorage.setItem('sidebarCollapsed', state.collapsed ? '1' : '0');
|
|
1398
1367
|
}
|
|
@@ -1400,12 +1369,17 @@ function applyCollapsed() {
|
|
|
1400
1369
|
function setCollapsed(value) {
|
|
1401
1370
|
state.collapsed = value;
|
|
1402
1371
|
applyCollapsed();
|
|
1403
|
-
// The graph sizes itself to the container, so it has to be redrawn once the
|
|
1404
|
-
// pane has actually changed width.
|
|
1405
1372
|
if (state.tab === 'graph' && state.store) requestAnimationFrame(renderTab);
|
|
1406
1373
|
}
|
|
1407
1374
|
|
|
1375
|
+
function applyStyles() {
|
|
1376
|
+
for (const element of document.querySelectorAll('[data-ui]')) {
|
|
1377
|
+
element.className = ui[element.dataset.ui] ?? '';
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1408
1381
|
async function init() {
|
|
1382
|
+
applyStyles();
|
|
1409
1383
|
el('show-all').addEventListener('change', (event) => {
|
|
1410
1384
|
state.showAll = event.target.checked;
|
|
1411
1385
|
renderStores();
|
|
@@ -1440,7 +1414,7 @@ async function init() {
|
|
|
1440
1414
|
el('root-path').title = data.rootFile
|
|
1441
1415
|
? `autoMemoryDirectory set in ${data.rootFile}`
|
|
1442
1416
|
: 'store chosen with --root';
|
|
1443
|
-
el('root-path').append(node('span', { class:
|
|
1417
|
+
el('root-path').append(node('span', { class: ui.rootSource, text: data.rootSource }));
|
|
1444
1418
|
}
|
|
1445
1419
|
if (data.rootWarning) toast(data.rootWarning, { error: true });
|
|
1446
1420
|
const first = state.stores.find((store) => store.memoryCount > 0);
|