claude-memory-admin 1.0.1 → 1.2.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 +94 -15
- package/package.json +8 -2
- package/public/app.mjs +849 -408
- 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 +309 -0
- package/server.mjs +82 -5
- package/src/instructions.mjs +123 -21
- package/src/model.mjs +4 -1
- package/src/mutate.mjs +355 -1
- package/src/parse.mjs +176 -0
- package/src/settings.mjs +120 -19
- package/src/stores.mjs +34 -4
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,61 +59,28 @@ 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) {
|
|
83
|
+
if (store.kind === 'global') return store.dir;
|
|
119
84
|
if (store.kind === 'auto') return store.path;
|
|
120
85
|
const scope = store.kind === 'agent-user' ? 'user'
|
|
121
86
|
: store.kind === 'agent-project' ? 'project' : 'local';
|
|
@@ -123,22 +88,23 @@ function storeSubtitle(store) {
|
|
|
123
88
|
}
|
|
124
89
|
|
|
125
90
|
function storeButton(store) {
|
|
126
|
-
const
|
|
91
|
+
const global = store.kind === 'global';
|
|
92
|
+
const health = global ? 'ok' : store.memoryCount === 0 ? 'none' : 'ok';
|
|
127
93
|
const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
|
|
128
94
|
return node('button', {
|
|
129
|
-
class:
|
|
95
|
+
class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
|
|
130
96
|
onclick: () => openStore(store.id),
|
|
131
97
|
title: store.kind === 'auto' && store.resolvedBy === 'unresolved'
|
|
132
98
|
? 'Real path could not be resolved - showing the raw folder name'
|
|
133
99
|
: store.dir,
|
|
134
100
|
}, [
|
|
135
|
-
node('span', { class:
|
|
136
|
-
node('span', { class:
|
|
137
|
-
node('span', { class:
|
|
138
|
-
off ? node('span', { class:
|
|
139
|
-
node('span', { class:
|
|
101
|
+
node('span', { class: ui.storeRow }, [
|
|
102
|
+
node('span', { class: ui.dot(health) }),
|
|
103
|
+
node('span', { class: ui.storeName, text: store.label }),
|
|
104
|
+
off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
|
|
105
|
+
global ? null : node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
|
|
140
106
|
]),
|
|
141
|
-
node('span', { class:
|
|
107
|
+
node('span', { class: ui.storePath, text: storeSubtitle(store) }),
|
|
142
108
|
]);
|
|
143
109
|
}
|
|
144
110
|
|
|
@@ -146,32 +112,25 @@ function renderStores() {
|
|
|
146
112
|
const list = el('project-list');
|
|
147
113
|
list.textContent = '';
|
|
148
114
|
|
|
149
|
-
const visible = state.stores.filter((s) => state.showAll || s.hasMemoryDir);
|
|
115
|
+
const visible = state.stores.filter((s) => s.kind === 'global' || state.showAll || s.hasMemoryDir);
|
|
150
116
|
if (!visible.length) {
|
|
151
|
-
list.append(node('p', { class:
|
|
117
|
+
list.append(node('p', { class: ui.note, text: 'No memory stores found.' }));
|
|
152
118
|
return;
|
|
153
119
|
}
|
|
154
120
|
|
|
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
121
|
const groups = [
|
|
122
|
+
['Global', visible.filter((s) => s.kind === 'global')],
|
|
159
123
|
['Projects', visible.filter((s) => s.kind === 'auto')],
|
|
160
|
-
['Subagents', visible.filter((s) => s.kind !== 'auto')],
|
|
124
|
+
['Subagents', visible.filter((s) => s.kind !== 'auto' && s.kind !== 'global')],
|
|
161
125
|
];
|
|
162
126
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (groups.every(([, group]) => group.length) ) {
|
|
167
|
-
list.append(node('div', { class: 'sidebar-group', text: title }));
|
|
168
|
-
}
|
|
127
|
+
const filled = groups.filter(([, stores]) => stores.length);
|
|
128
|
+
for (const [title, stores] of filled) {
|
|
129
|
+
if (filled.length > 1) list.append(node('div', { class: ui.sidebarGroup, text: title }));
|
|
169
130
|
for (const store of stores) list.append(storeButton(store));
|
|
170
131
|
}
|
|
171
132
|
}
|
|
172
133
|
|
|
173
|
-
/* ------------------------------------------------------------------ tabs */
|
|
174
|
-
|
|
175
134
|
const TABS = [
|
|
176
135
|
{ id: 'memories', label: 'Memories' },
|
|
177
136
|
{ id: 'index', label: 'MEMORY.md' },
|
|
@@ -179,18 +138,21 @@ const TABS = [
|
|
|
179
138
|
{ id: 'prune', label: 'Prune' },
|
|
180
139
|
{ id: 'context', label: 'Context' },
|
|
181
140
|
{ id: 'health', label: 'Health' },
|
|
141
|
+
{ id: 'settings', label: 'Settings' },
|
|
182
142
|
{ id: 'trash', label: 'Trash' },
|
|
183
143
|
];
|
|
184
144
|
|
|
185
145
|
function renderTabs() {
|
|
186
146
|
const container = el('tabs');
|
|
187
147
|
container.textContent = '';
|
|
148
|
+
const global = state.store.kind === 'global';
|
|
188
149
|
const hasProjectDir = state.store.kind === 'auto'
|
|
189
150
|
? state.store.resolvedBy !== 'unresolved'
|
|
190
151
|
: Boolean(state.store.projectPath);
|
|
191
152
|
|
|
192
153
|
for (const tab of TABS) {
|
|
193
|
-
if (tab.id
|
|
154
|
+
if (global && tab.id !== 'context') continue;
|
|
155
|
+
if (!global && tab.id === 'context' && !hasProjectDir) continue;
|
|
194
156
|
let badge = null;
|
|
195
157
|
if (tab.id === 'memories') badge = String(state.store.memories.length);
|
|
196
158
|
if (tab.id === 'health' && state.store.health.issueCount) badge = String(state.store.health.issueCount);
|
|
@@ -198,43 +160,42 @@ function renderTabs() {
|
|
|
198
160
|
if (tab.id === 'prune' && state.store.stats.index.overTarget) badge = '!';
|
|
199
161
|
|
|
200
162
|
container.append(node('button', {
|
|
201
|
-
class:
|
|
163
|
+
class: ui.tab(state.tab === tab.id),
|
|
202
164
|
onclick: () => { state.tab = tab.id; renderTabs(); renderTab(); },
|
|
203
165
|
}, [
|
|
204
166
|
document.createTextNode(tab.label),
|
|
205
|
-
badge ? node('span', { class:
|
|
167
|
+
badge ? node('span', { class: ui.tabBadge(tab.id === 'health' || tab.id === 'prune'), text: badge }) : null,
|
|
206
168
|
]));
|
|
207
169
|
}
|
|
208
170
|
}
|
|
209
171
|
|
|
210
|
-
/* -------------------------------------------------------------- memories */
|
|
211
|
-
|
|
212
172
|
function memoryButton(memory) {
|
|
173
|
+
const active = state.selected === memory.file;
|
|
213
174
|
return node('button', {
|
|
214
|
-
class:
|
|
175
|
+
class: ui.memoryItem(active),
|
|
176
|
+
'data-memory': memory.file,
|
|
177
|
+
'data-active': active ? '' : null,
|
|
215
178
|
onclick: () => selectMemory(memory.file),
|
|
216
179
|
}, [
|
|
217
|
-
node('span', { class:
|
|
218
|
-
node('span', { class:
|
|
219
|
-
node('span', { class:
|
|
180
|
+
node('span', { class: ui.memoryTop }, [
|
|
181
|
+
node('span', { class: ui.memoryName, text: memory.name }),
|
|
182
|
+
node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
|
|
220
183
|
memory.status !== 'indexed'
|
|
221
|
-
? node('span', { class:
|
|
184
|
+
? node('span', { class: ui.badge('warn'), text: memory.status })
|
|
222
185
|
: null,
|
|
223
186
|
]),
|
|
224
|
-
memory.description ? node('span', { class:
|
|
187
|
+
memory.description ? node('span', { class: ui.memoryDesc, text: memory.description }) : null,
|
|
225
188
|
]);
|
|
226
189
|
}
|
|
227
190
|
|
|
228
191
|
function renderMemories(container) {
|
|
229
192
|
const project = state.store;
|
|
230
193
|
if (!project.memories.length) {
|
|
231
|
-
container.append(node('p', { class:
|
|
194
|
+
container.append(node('p', { class: ui.note, text: 'This project has no memory files yet.' }));
|
|
232
195
|
return;
|
|
233
196
|
}
|
|
234
197
|
|
|
235
198
|
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
199
|
const groups = new Map();
|
|
239
200
|
for (const memory of project.memories) {
|
|
240
201
|
const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
|
|
@@ -242,12 +203,12 @@ function renderMemories(container) {
|
|
|
242
203
|
groups.get(key).push(memory);
|
|
243
204
|
}
|
|
244
205
|
for (const [section, memories] of groups) {
|
|
245
|
-
left.append(node('div', { class:
|
|
206
|
+
left.append(node('div', { class: ui.sectionLabel, text: `${section} · ${memories.length}` }));
|
|
246
207
|
for (const memory of memories) left.append(memoryButton(memory));
|
|
247
208
|
}
|
|
248
209
|
|
|
249
|
-
const right = node('div', { id: 'detail' });
|
|
250
|
-
container.append(node('div', { class:
|
|
210
|
+
const right = node('div', { id: 'detail', class: ui.detailPane });
|
|
211
|
+
container.append(node('div', { class: ui.split }, [left, right]));
|
|
251
212
|
renderDetail();
|
|
252
213
|
}
|
|
253
214
|
|
|
@@ -258,30 +219,28 @@ function renderDetail() {
|
|
|
258
219
|
|
|
259
220
|
const memory = state.store.memories.find((m) => m.file === state.selected);
|
|
260
221
|
if (!memory) {
|
|
261
|
-
host.append(node('p', { class:
|
|
222
|
+
host.append(node('p', { class: ui.note, text: 'Select a memory to read it.' }));
|
|
262
223
|
return;
|
|
263
224
|
}
|
|
264
225
|
|
|
265
|
-
const card = node('div', { class:
|
|
266
|
-
card.append(node('div', { class:
|
|
267
|
-
node('h3', { text: memory.name }),
|
|
268
|
-
node('span', { class:
|
|
226
|
+
const card = node('div', { class: ui.card });
|
|
227
|
+
card.append(node('div', { class: ui.detailHead }, [
|
|
228
|
+
node('h3', { class: ui.detailTitle, text: memory.name }),
|
|
229
|
+
node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
|
|
269
230
|
node('button', {
|
|
270
|
-
class:
|
|
231
|
+
class: ui.buttonDangerSmall,
|
|
271
232
|
text: 'Delete',
|
|
272
233
|
onclick: () => openDeleteDialog(memory.file),
|
|
273
234
|
}),
|
|
274
235
|
]));
|
|
275
236
|
|
|
276
|
-
if (memory.description) card.append(node('p', { class:
|
|
237
|
+
if (memory.description) card.append(node('p', { class: ui.detailDesc, text: memory.description }));
|
|
277
238
|
|
|
278
|
-
const meta = node('dl', { class:
|
|
239
|
+
const meta = node('dl', { class: ui.metaList });
|
|
279
240
|
const rows = [
|
|
280
241
|
['file', memory.file],
|
|
281
242
|
['status', memory.status],
|
|
282
243
|
['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
244
|
? `${memory.modified.replace('T', ' ').replace(/\..*$/, '')}${memory.modifiedFrom === 'mtime' ? ' (file mtime)' : ''}`
|
|
286
245
|
: '-'],
|
|
287
246
|
['size', `${(memory.bytes / 1024).toFixed(1)} KB`],
|
|
@@ -291,23 +250,23 @@ function renderDetail() {
|
|
|
291
250
|
if (key !== 'type' && key !== 'modified') rows.push([key, value]);
|
|
292
251
|
}
|
|
293
252
|
for (const [key, value] of rows) {
|
|
294
|
-
meta.append(node('dt', { text: key }), node('dd', { text: String(value) }));
|
|
253
|
+
meta.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: String(value) }));
|
|
295
254
|
}
|
|
296
255
|
card.append(meta);
|
|
297
256
|
|
|
298
|
-
const body = node('div', { class:
|
|
257
|
+
const body = node('div', { class: ui.prose });
|
|
299
258
|
card.append(body);
|
|
300
259
|
renderBody(body, memory, state.store);
|
|
301
260
|
|
|
302
261
|
const outbound = memory.outboundResolved || [];
|
|
303
262
|
if (outbound.length || memory.inbound.length) {
|
|
304
|
-
const links = node('div', {
|
|
263
|
+
const links = node('div', { class: ui.linkSection });
|
|
305
264
|
if (outbound.length) {
|
|
306
|
-
links.append(node('div', { class:
|
|
307
|
-
const chips = node('div', { class:
|
|
265
|
+
links.append(node('div', { class: ui.sectionLabel, text: 'Links out' }));
|
|
266
|
+
const chips = node('div', { class: ui.linkRow });
|
|
308
267
|
for (const link of outbound) {
|
|
309
268
|
chips.append(node('button', {
|
|
310
|
-
class:
|
|
269
|
+
class: ui.chip(Boolean(link.file)),
|
|
311
270
|
text: link.file ? link.target : `${link.target} (missing)`,
|
|
312
271
|
onclick: link.file ? () => selectMemory(link.file) : null,
|
|
313
272
|
}));
|
|
@@ -315,12 +274,12 @@ function renderDetail() {
|
|
|
315
274
|
links.append(chips);
|
|
316
275
|
}
|
|
317
276
|
if (memory.inbound.length) {
|
|
318
|
-
links.append(node('div', { class:
|
|
319
|
-
const chips = node('div', { class:
|
|
277
|
+
links.append(node('div', { class: ui.sectionLabel, text: 'Linked from' }));
|
|
278
|
+
const chips = node('div', { class: ui.linkRow });
|
|
320
279
|
for (const link of memory.inbound) {
|
|
321
280
|
const source = state.store.memories.find((m) => m.file === link.from);
|
|
322
281
|
chips.append(node('button', {
|
|
323
|
-
class:
|
|
282
|
+
class: ui.chip(true),
|
|
324
283
|
text: source ? source.name : link.from,
|
|
325
284
|
onclick: () => selectMemory(link.from),
|
|
326
285
|
}));
|
|
@@ -333,45 +292,96 @@ function renderDetail() {
|
|
|
333
292
|
host.append(card);
|
|
334
293
|
}
|
|
335
294
|
|
|
336
|
-
|
|
295
|
+
const INDEX_VIEWS = [
|
|
296
|
+
{ id: 'rendered', label: 'Rendered' },
|
|
297
|
+
{ id: 'source', label: 'Source' },
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
function setIndexView(view) {
|
|
301
|
+
state.indexView = view;
|
|
302
|
+
localStorage.setItem('memoryIndexView', view);
|
|
303
|
+
renderTab();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function indexViewPicker() {
|
|
307
|
+
return node('div', { class: ui.segmentGroup, role: 'tablist' }, INDEX_VIEWS.map((view) => node('button', {
|
|
308
|
+
class: ui.segment(state.indexView === view.id),
|
|
309
|
+
text: view.label,
|
|
310
|
+
role: 'tab',
|
|
311
|
+
'aria-selected': String(state.indexView === view.id),
|
|
312
|
+
onclick: () => setIndexView(view.id),
|
|
313
|
+
})));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function cutMarker(cutoff) {
|
|
317
|
+
return node('div', { class: ui.cutLine }, [
|
|
318
|
+
node('span', { class: ui.cutLabel, text: `not loaded past here — ${cutoff.droppedLines} lines dropped on ${cutoff.by}` }),
|
|
319
|
+
]);
|
|
320
|
+
}
|
|
337
321
|
|
|
338
322
|
function renderIndex(container) {
|
|
339
323
|
const project = state.store;
|
|
340
324
|
if (!project.hasIndex) {
|
|
341
|
-
container.append(node('p', { class:
|
|
325
|
+
container.append(node('p', { class: ui.note, text: 'This project has no MEMORY.md.' }));
|
|
342
326
|
return;
|
|
343
327
|
}
|
|
344
328
|
|
|
329
|
+
const card = node('div', { class: ui.card });
|
|
330
|
+
card.append(node('div', { class: ui.cardHeadRow }, [
|
|
331
|
+
node('p', { class: ui.note, text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }),
|
|
332
|
+
indexViewPicker(),
|
|
333
|
+
]));
|
|
334
|
+
|
|
335
|
+
if (state.indexView === 'source') renderIndexSource(card, project);
|
|
336
|
+
else renderIndexRendered(card, project);
|
|
337
|
+
|
|
338
|
+
container.append(card);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function renderIndexRendered(card, project) {
|
|
342
|
+
const { cutoff } = project.stats.index;
|
|
343
|
+
const files = new Set(project.memories.map((memory) => memory.file));
|
|
344
|
+
const byName = memoryLookup(project);
|
|
345
|
+
const options = {
|
|
346
|
+
resolveWikilink: (target) => openTarget(byName.get(target)),
|
|
347
|
+
resolveHref: (href) => openTarget(files.has(href) ? href : null),
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const lines = project.index.raw.split('\n');
|
|
351
|
+
const loaded = node('div', { class: ui.prose });
|
|
352
|
+
renderMarkdown(loaded, cutoff ? lines.slice(0, cutoff.rawLine).join('\n') : project.index.raw, options);
|
|
353
|
+
card.append(loaded);
|
|
354
|
+
|
|
355
|
+
if (!cutoff) return;
|
|
356
|
+
card.append(cutMarker(cutoff));
|
|
357
|
+
const dropped = node('div', { class: ui.proseDim });
|
|
358
|
+
renderMarkdown(dropped, lines.slice(cutoff.rawLine).join('\n'), options);
|
|
359
|
+
card.append(dropped);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function renderIndexSource(card, project) {
|
|
345
363
|
const missing = new Set(project.health.danglingIndex.map((d) => d.index));
|
|
346
364
|
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
365
|
|
|
350
366
|
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);
|
|
367
|
+
if (cutoff && line.index === cutoff.rawLine) card.append(cutMarker(cutoff));
|
|
368
|
+
|
|
369
|
+
const dangling = missing.has(line.index);
|
|
370
|
+
const dropped = Boolean(cutoff) && line.index >= cutoff.rawLine;
|
|
371
|
+
const clickable = line.kind === 'index' && !dangling;
|
|
372
|
+
const content = node('span', {
|
|
373
|
+
class: ui.indexLineText({ kind: line.kind, clickable, dropped }),
|
|
374
|
+
text: line.text || ' ',
|
|
375
|
+
});
|
|
376
|
+
if (clickable) content.onclick = () => selectMemory(line.file);
|
|
377
|
+
|
|
378
|
+
card.append(node('div', { class: ui.indexLine({ dangling, dropped }) }, [
|
|
379
|
+
node('span', { class: ui.indexLineNumber, text: String(line.index + 1) }),
|
|
380
|
+
content,
|
|
381
|
+
]));
|
|
368
382
|
}
|
|
369
|
-
container.append(card);
|
|
370
383
|
}
|
|
371
384
|
|
|
372
|
-
|
|
373
|
-
/* ------------------------------------------------------------- context */
|
|
374
|
-
|
|
375
385
|
const SCOPE_ORDER = ['managed', 'user', 'project', 'local'];
|
|
376
386
|
|
|
377
387
|
const CONTEXT_PROBLEMS = {
|
|
@@ -383,110 +393,128 @@ const CONTEXT_PROBLEMS = {
|
|
|
383
393
|
'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`],
|
|
384
394
|
'long-claude-md': (p) => [`${p.lines} lines, over the 200-line guidance`, `${p.file} - long files cost context every session and reduce adherence`],
|
|
385
395
|
'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.`],
|
|
396
|
+
'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.`],
|
|
386
397
|
};
|
|
387
398
|
|
|
388
399
|
async function renderContext(container) {
|
|
389
|
-
container.append(node('p', { class:
|
|
400
|
+
container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
|
|
390
401
|
let data;
|
|
391
402
|
try {
|
|
392
403
|
data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
|
|
393
404
|
} catch (err) {
|
|
394
405
|
container.textContent = '';
|
|
395
|
-
return container.append(node('p', { class:
|
|
406
|
+
return container.append(node('p', { class: ui.note, text: err.message }));
|
|
396
407
|
}
|
|
397
|
-
// The tab may have been switched while the request was in flight.
|
|
398
408
|
if (state.tab !== 'context') return;
|
|
399
409
|
container.textContent = '';
|
|
400
410
|
|
|
401
|
-
|
|
402
|
-
|
|
411
|
+
const global = state.store.kind === 'global';
|
|
412
|
+
if (!data.projectDir && !global) {
|
|
413
|
+
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
414
|
}
|
|
404
415
|
|
|
405
416
|
const { totals } = data;
|
|
406
|
-
container.append(node('div', { class:
|
|
407
|
-
node('div', { class:
|
|
408
|
-
node('span', { class:
|
|
409
|
-
node('span', { class:
|
|
417
|
+
container.append(node('div', { class: ui.meter }, [
|
|
418
|
+
node('div', { class: ui.meterTop }, [
|
|
419
|
+
node('span', { class: ui.meterValue, text: `~${totals.alwaysTokens.toLocaleString()}` }),
|
|
420
|
+
node('span', { class: ui.meterUnit, text: 'estimated tokens of instructions, every session' }),
|
|
410
421
|
]),
|
|
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')]),
|
|
422
|
+
node('div', { class: ui.meterFacts }, [
|
|
423
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.files) }), document.createTextNode(' files resolved')]),
|
|
424
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.alwaysLines) }), document.createTextNode(' lines always loaded')]),
|
|
425
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(totals.alwaysBytes / 1024).toFixed(1)} KB` }), document.createTextNode(' always loaded')]),
|
|
426
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.conditionalFiles) }), document.createTextNode(' path-scoped rules, loaded only on a match')]),
|
|
416
427
|
]),
|
|
417
|
-
node('p', { class:
|
|
428
|
+
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
429
|
]));
|
|
419
430
|
|
|
420
431
|
if (data.problems.length) {
|
|
421
|
-
container.append(node('div', { class:
|
|
432
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
|
|
422
433
|
for (const problem of data.problems) {
|
|
423
434
|
const describe = CONTEXT_PROBLEMS[problem.kind];
|
|
424
435
|
const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
|
|
425
436
|
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:
|
|
437
|
+
container.append(node('div', { class: ui.issue(bad) }, [
|
|
438
|
+
node('div', { class: ui.issueBody }, [
|
|
439
|
+
node('div', { class: ui.issueTitle, text: title }),
|
|
440
|
+
node('div', { class: ui.issueDetail, text: detail }),
|
|
430
441
|
]),
|
|
431
442
|
]));
|
|
432
443
|
}
|
|
433
444
|
}
|
|
434
445
|
|
|
435
|
-
container.append(node('div', { class:
|
|
436
|
-
const card = node('div', { class:
|
|
446
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Loaded in this order · ${data.files.length}` }));
|
|
447
|
+
const card = node('div', { class: ui.card });
|
|
437
448
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
449
|
+
const tags = [node('span', { class: ui.scopeBadge(file.scope), text: file.scope })];
|
|
450
|
+
if (file.kind === 'import') tags.push(node('span', { class: ui.badge(), text: `import · depth ${file.depth}` }));
|
|
451
|
+
if (file.kind === 'rule') tags.push(node('span', { class: ui.badge(), text: 'rule' }));
|
|
452
|
+
if (file.kind === 'managed-settings') tags.push(node('span', { class: ui.badge(), text: 'claudeMd setting' }));
|
|
453
|
+
if (file.conditional) tags.push(node('span', { class: ui.badge('warn'), text: 'only on a path match' }));
|
|
454
|
+
|
|
455
|
+
const body = node('div', { class: ui.contextBody, hidden: true });
|
|
456
|
+
const bodyProse = node('div', { class: ui.prose });
|
|
457
|
+
body.append(bodyProse);
|
|
458
|
+
const caret = node('span', { class: ui.contextCaret, text: '\u25b8' });
|
|
459
|
+
let rendered = false;
|
|
460
|
+
|
|
461
|
+
card.append(node('button', {
|
|
462
|
+
class: ui.contextRowButton,
|
|
463
|
+
'aria-expanded': 'false',
|
|
464
|
+
onclick: (event) => {
|
|
465
|
+
const open = body.hidden;
|
|
466
|
+
body.hidden = !open;
|
|
467
|
+
caret.textContent = open ? '\u25be' : '\u25b8';
|
|
468
|
+
event.currentTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
469
|
+
if (open && !rendered) {
|
|
470
|
+
rendered = true;
|
|
471
|
+
renderMarkdown(bodyProse, file.text);
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
}, [
|
|
475
|
+
node('div', { class: ui.contextMain }, [
|
|
476
|
+
node('div', { class: ui.contextTags }, tags),
|
|
477
|
+
node('div', { class: ui.contextFile }, [caret, document.createTextNode(file.file)]),
|
|
448
478
|
]),
|
|
449
|
-
node('div', { class:
|
|
479
|
+
node('div', { class: ui.contextSize, text: `${file.lines} L · ~${file.tokens.toLocaleString()} tok` }),
|
|
450
480
|
]));
|
|
481
|
+
card.append(body);
|
|
451
482
|
}
|
|
452
483
|
container.append(card);
|
|
453
484
|
|
|
454
485
|
if (data.excluded.length) {
|
|
455
|
-
container.append(node('div', { class:
|
|
486
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Excluded by claudeMdExcludes · ${data.excluded.length}` }));
|
|
456
487
|
for (const file of data.excluded) {
|
|
457
|
-
container.append(node('div', { class:
|
|
458
|
-
node('div', { class:
|
|
488
|
+
container.append(node('div', { class: ui.issue(false) }, [
|
|
489
|
+
node('div', { class: ui.issueBody }, [node('div', { class: ui.issueDetail, text: file })]),
|
|
459
490
|
]));
|
|
460
491
|
}
|
|
461
492
|
}
|
|
462
493
|
|
|
463
|
-
container.append(node('p', { class:
|
|
494
|
+
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
495
|
}
|
|
465
496
|
|
|
466
|
-
/* -------------------------------------------------------------- health */
|
|
467
|
-
|
|
468
497
|
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:
|
|
498
|
+
return node('div', { class: ui.issue(bad) }, [
|
|
499
|
+
node('div', { class: ui.issueBody }, [
|
|
500
|
+
node('div', { class: ui.issueTitle, text: title }),
|
|
501
|
+
node('div', { class: ui.issueDetail, text: detail }),
|
|
473
502
|
]),
|
|
474
|
-
secondary ? node('button', { class:
|
|
475
|
-
action ? node('button', { class:
|
|
503
|
+
secondary ? node('button', { class: ui.buttonSmall, text: secondary.label, onclick: secondary.run }) : null,
|
|
504
|
+
action ? node('button', { class: ui.buttonSmall, text: action.label, onclick: action.run }) : null,
|
|
476
505
|
]);
|
|
477
506
|
}
|
|
478
507
|
|
|
479
508
|
function renderHealth(container) {
|
|
480
509
|
const { health, memories } = state.store;
|
|
481
510
|
if (!health.issues.length) {
|
|
482
|
-
container.append(node('div', { class:
|
|
483
|
-
node('p', {
|
|
484
|
-
node('p', { class:
|
|
511
|
+
container.append(node('div', { class: ui.card }, [
|
|
512
|
+
node('p', { class: ui.okLine, text: '\u2713 No consistency problems found.' }),
|
|
513
|
+
node('p', { class: ui.noteTight, text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
|
|
485
514
|
]));
|
|
486
515
|
return;
|
|
487
516
|
}
|
|
488
517
|
|
|
489
|
-
// Rendered straight from health.issues so the tab and the badge always agree.
|
|
490
518
|
for (const item of health.issues) {
|
|
491
519
|
container.append(renderIssue(item, memories));
|
|
492
520
|
}
|
|
@@ -557,7 +585,11 @@ function renderIssue(item, memories) {
|
|
|
557
585
|
return issue(
|
|
558
586
|
`Not referenced anywhere in MEMORY.md: ${item.file}`,
|
|
559
587
|
`"${memory?.name || item.file}" exists on disk but nothing points to it, so Claude will not load it.`,
|
|
560
|
-
{
|
|
588
|
+
{
|
|
589
|
+
bad,
|
|
590
|
+
secondary: { label: 'Open', run: () => selectMemory(item.file) },
|
|
591
|
+
action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
|
|
592
|
+
},
|
|
561
593
|
);
|
|
562
594
|
}
|
|
563
595
|
|
|
@@ -566,7 +598,11 @@ function renderIssue(item, memories) {
|
|
|
566
598
|
return issue(
|
|
567
599
|
`Linked mid-sentence, not indexed: ${item.file}`,
|
|
568
600
|
`"${memory?.name || item.file}" is mentioned inside prose in MEMORY.md but has no index bullet of its own.`,
|
|
569
|
-
{
|
|
601
|
+
{
|
|
602
|
+
bad,
|
|
603
|
+
secondary: { label: 'Open', run: () => selectMemory(item.file) },
|
|
604
|
+
action: { label: 'Add to MEMORY.md', run: () => openAddEntryDialog(item.file) },
|
|
605
|
+
},
|
|
570
606
|
);
|
|
571
607
|
}
|
|
572
608
|
|
|
@@ -603,6 +639,402 @@ function renderIssue(item, memories) {
|
|
|
603
639
|
return issue(item.kind, JSON.stringify(item));
|
|
604
640
|
}
|
|
605
641
|
|
|
642
|
+
const SETTINGS_PROBLEMS = {
|
|
643
|
+
unparseable: (p) => [
|
|
644
|
+
`Not valid JSON: ${p.scope} settings`,
|
|
645
|
+
`${p.file} — Claude Code cannot read this file either, so every value in it is being ignored. ${p.detail}`,
|
|
646
|
+
],
|
|
647
|
+
unreadable: (p) => [
|
|
648
|
+
`Cannot be read: ${p.scope} settings`,
|
|
649
|
+
`${p.file} — ${p.detail}`,
|
|
650
|
+
],
|
|
651
|
+
'not-object': (p) => [
|
|
652
|
+
`Not a settings object: ${p.scope} settings`,
|
|
653
|
+
`${p.file} — ${p.detail}`,
|
|
654
|
+
],
|
|
655
|
+
'invalid-auto-memory-directory': (p) => [
|
|
656
|
+
'autoMemoryDirectory cannot be used',
|
|
657
|
+
`${p.file} — ${p.detail}. Claude Code accepts only an absolute or ~/-prefixed path.`,
|
|
658
|
+
],
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const show = (value) => (value === undefined ? 'unset' : JSON.stringify(value));
|
|
662
|
+
|
|
663
|
+
async function renderSettings(container) {
|
|
664
|
+
container.append(node('p', { class: ui.note, text: 'Reading settings files…' }));
|
|
665
|
+
let data;
|
|
666
|
+
try {
|
|
667
|
+
data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/settings`);
|
|
668
|
+
} catch (err) {
|
|
669
|
+
container.textContent = '';
|
|
670
|
+
return container.append(node('p', { class: ui.note, text: err.message }));
|
|
671
|
+
}
|
|
672
|
+
if (state.tab !== 'settings') return;
|
|
673
|
+
container.textContent = '';
|
|
674
|
+
|
|
675
|
+
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.' }));
|
|
676
|
+
|
|
677
|
+
if (!data.projectDir) {
|
|
678
|
+
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.' }));
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (data.problems.length) {
|
|
682
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
|
|
683
|
+
for (const problem of data.problems) {
|
|
684
|
+
const describe = SETTINGS_PROBLEMS[problem.kind];
|
|
685
|
+
const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
|
|
686
|
+
container.append(node('div', { class: ui.issue(true) }, [
|
|
687
|
+
node('div', { class: ui.issueBody }, [
|
|
688
|
+
node('div', { class: ui.issueTitle, text: title }),
|
|
689
|
+
node('div', { class: ui.issueDetail, text: detail }),
|
|
690
|
+
]),
|
|
691
|
+
]));
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (data.env.overrides) {
|
|
696
|
+
container.append(node('div', { class: ui.sectionLabel, text: 'Environment override' }));
|
|
697
|
+
container.append(node('div', { class: ui.issue(false) }, [
|
|
698
|
+
node('div', { class: ui.issueBody }, [
|
|
699
|
+
node('div', { class: ui.issueTitle, text: `${data.env.name}=${data.env.value}` }),
|
|
700
|
+
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.` }),
|
|
701
|
+
]),
|
|
702
|
+
]));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
for (const entry of data.keys) {
|
|
706
|
+
const card = node('div', { class: ui.card });
|
|
707
|
+
const head = node('div', { class: ui.settingsKeyHead }, [
|
|
708
|
+
node('span', { class: ui.settingsKeyName, text: entry.key }),
|
|
709
|
+
node('span', {
|
|
710
|
+
class: ui.settingsEffective,
|
|
711
|
+
text: entry.effective ? show(entry.effective.value) : `${show(entry.fallback)} (default)`,
|
|
712
|
+
}),
|
|
713
|
+
]);
|
|
714
|
+
if (entry.effective) {
|
|
715
|
+
head.append(node('span', { class: ui.scopeBadge(entry.effective.scope), text: entry.effective.scope }));
|
|
716
|
+
}
|
|
717
|
+
card.append(head);
|
|
718
|
+
card.append(node('p', { class: ui.noteTight, text: entry.detail }));
|
|
719
|
+
|
|
720
|
+
if (entry.normalized !== undefined && entry.effective && entry.normalized !== entry.effective.value) {
|
|
721
|
+
card.append(node('div', { class: ui.issue(false) }, [
|
|
722
|
+
node('div', { class: ui.issueBody }, [
|
|
723
|
+
node('div', { class: ui.issueTitle, text: `Configured ${show(entry.effective.value)}, but ${show(entry.normalized)} applies` }),
|
|
724
|
+
node('div', { class: ui.issueDetail, text: 'Claude Code ignores a value it cannot use and falls back to the default.' }),
|
|
725
|
+
]),
|
|
726
|
+
]));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (!entry.values.length) {
|
|
730
|
+
card.append(node('p', { class: ui.note, text: 'No settings file sets this, so the built-in default applies.' }));
|
|
731
|
+
} else {
|
|
732
|
+
for (const value of entry.values) {
|
|
733
|
+
card.append(node('div', { class: ui.settingsLayerRow }, [
|
|
734
|
+
node('span', { class: ui.scopeBadge(value.scope), text: value.scope }),
|
|
735
|
+
node('span', { class: ui.settingsLayerValue(value.wins), text: show(value.value) }),
|
|
736
|
+
node('span', { class: ui.settingsLayerFile, text: value.file }),
|
|
737
|
+
value.wins ? node('span', { class: ui.badge('ok'), text: 'wins' }) : null,
|
|
738
|
+
]));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
container.append(card);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Files consulted · ${data.layers.length}` }));
|
|
745
|
+
const files = node('div', { class: ui.card });
|
|
746
|
+
for (const layer of data.layers) {
|
|
747
|
+
files.append(node('div', { class: ui.settingsLayerRow }, [
|
|
748
|
+
node('span', { class: ui.scopeBadge(layer.scope), text: layer.scope }),
|
|
749
|
+
node('span', { class: ui.settingsLayerValue(layer.status === 'ok'), text: layer.status }),
|
|
750
|
+
node('span', { class: ui.settingsLayerFile, text: layer.file }),
|
|
751
|
+
]));
|
|
752
|
+
}
|
|
753
|
+
container.append(files);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const byteLength = (text) => new TextEncoder().encode(text).length;
|
|
757
|
+
|
|
758
|
+
function indexEntryAt(lineIndex) {
|
|
759
|
+
return state.store.index?.entries.find((entry) => entry.index === lineIndex) || null;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function sectionsAbove(limit) {
|
|
763
|
+
const seen = [];
|
|
764
|
+
for (const line of state.store.index?.lines || []) {
|
|
765
|
+
if (line.kind !== 'heading') continue;
|
|
766
|
+
if (limit !== null && line.index >= limit) continue;
|
|
767
|
+
if (line.section && !seen.includes(line.section)) seen.push(line.section);
|
|
768
|
+
}
|
|
769
|
+
return seen;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function runIndexEdit(action, body, message) {
|
|
773
|
+
try {
|
|
774
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/${action}`, {
|
|
775
|
+
method: 'POST',
|
|
776
|
+
body: JSON.stringify(body),
|
|
777
|
+
});
|
|
778
|
+
await openStore(state.storeId, { keepTab: true });
|
|
779
|
+
toast(message(result), { action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) } });
|
|
780
|
+
return result;
|
|
781
|
+
} catch (err) {
|
|
782
|
+
toast(err.message, { error: true });
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function openHookEditor(row, lineIndex) {
|
|
788
|
+
const entry = indexEntryAt(lineIndex);
|
|
789
|
+
if (!entry) return toast('MEMORY.md has changed - reload and try again', { error: true });
|
|
790
|
+
|
|
791
|
+
const stats = state.store.stats.index;
|
|
792
|
+
const editor = node('div', { class: ui.hookEditor });
|
|
793
|
+
const field = node('textarea', { class: ui.textArea, spellcheck: 'false' });
|
|
794
|
+
field.value = entry.hook;
|
|
795
|
+
|
|
796
|
+
const counter = node('span');
|
|
797
|
+
const projection = node('span');
|
|
798
|
+
const readout = node('div', { class: ui.charCount }, [counter, projection]);
|
|
799
|
+
|
|
800
|
+
const save = node('button', { class: ui.buttonPrimarySmall, text: 'Save' });
|
|
801
|
+
const cancel = node('button', { class: ui.buttonSmall, text: 'Cancel', onclick: () => renderTab() });
|
|
802
|
+
|
|
803
|
+
function update() {
|
|
804
|
+
const next = field.value.trim();
|
|
805
|
+
const delta = byteLength(next) - byteLength(entry.hook);
|
|
806
|
+
const projected = Math.max(stats.linePercent, ((stats.bytes + delta) / stats.byteLimit) * 100);
|
|
807
|
+
|
|
808
|
+
counter.textContent = `${next.length} characters, was ${entry.hook.length}`;
|
|
809
|
+
projection.textContent = `index ${Math.round(stats.worstPercent)}% → ${Math.round(projected)}%`;
|
|
810
|
+
readout.className = next.length > 200 ? ui.charCountOver : ui.charCount;
|
|
811
|
+
save.disabled = next === entry.hook;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
field.addEventListener('input', update);
|
|
815
|
+
save.onclick = () => runIndexEdit(
|
|
816
|
+
'index/hook',
|
|
817
|
+
{ lineIndex, expectedText: entry.text, hook: field.value },
|
|
818
|
+
() => 'Hook shortened',
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
editor.append(
|
|
822
|
+
node('div', { class: ui.willTitle, text: 'Hook' }),
|
|
823
|
+
field,
|
|
824
|
+
readout,
|
|
825
|
+
node('div', { class: ui.hookEditorFoot }, [cancel, save]),
|
|
826
|
+
);
|
|
827
|
+
row.textContent = '';
|
|
828
|
+
row.append(editor);
|
|
829
|
+
update();
|
|
830
|
+
field.focus();
|
|
831
|
+
field.select();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function openMoveDialog(entry) {
|
|
835
|
+
const current = indexEntryAt(entry.index);
|
|
836
|
+
if (!current) return toast('MEMORY.md has changed - reload and try again', { error: true });
|
|
837
|
+
|
|
838
|
+
const { cutoff } = state.store.stats.index;
|
|
839
|
+
const sections = sectionsAbove(cutoff ? cutoff.rawLine : null);
|
|
840
|
+
const picker = node('select', { class: ui.select });
|
|
841
|
+
picker.append(node('option', { value: '', text: 'Top of MEMORY.md' }));
|
|
842
|
+
for (const section of sections) picker.append(node('option', { value: section, text: `Start of "${section}"` }));
|
|
843
|
+
|
|
844
|
+
const body = node('div', { class: ui.modalBody }, [
|
|
845
|
+
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.` }),
|
|
846
|
+
node('div', { class: ui.willBlock }, [
|
|
847
|
+
node('div', { class: ui.willTitle, text: 'Will move' }),
|
|
848
|
+
node('div', { class: ui.willItem('keep'), text: current.text }),
|
|
849
|
+
]),
|
|
850
|
+
node('label', { class: ui.willTitle, text: 'Move to' }),
|
|
851
|
+
picker,
|
|
852
|
+
]);
|
|
853
|
+
|
|
854
|
+
const modal = node('div', { class: ui.modal }, [
|
|
855
|
+
node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Move this entry above the cutoff' })]),
|
|
856
|
+
body,
|
|
857
|
+
node('footer', { class: ui.modalFoot }, [
|
|
858
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
859
|
+
node('button', {
|
|
860
|
+
class: ui.button({ tone: 'primary' }),
|
|
861
|
+
text: 'Move',
|
|
862
|
+
onclick: async () => {
|
|
863
|
+
closeModal();
|
|
864
|
+
await runIndexEdit(
|
|
865
|
+
'index/move',
|
|
866
|
+
picker.value
|
|
867
|
+
? { lineIndex: current.index, expectedText: current.text, section: picker.value }
|
|
868
|
+
: { lineIndex: current.index, expectedText: current.text, top: true },
|
|
869
|
+
() => 'Entry moved',
|
|
870
|
+
);
|
|
871
|
+
},
|
|
872
|
+
}),
|
|
873
|
+
]),
|
|
874
|
+
]);
|
|
875
|
+
|
|
876
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
877
|
+
el('modal-root').append(backdrop);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function openAddEntryDialog(file) {
|
|
881
|
+
let preview;
|
|
882
|
+
try {
|
|
883
|
+
preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/index/add-preview`, {
|
|
884
|
+
method: 'POST',
|
|
885
|
+
body: JSON.stringify({ file }),
|
|
886
|
+
});
|
|
887
|
+
} catch (err) {
|
|
888
|
+
return toast(err.message, { error: true });
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const title = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
|
|
892
|
+
title.value = preview.name;
|
|
893
|
+
const hook = node('textarea', { class: ui.textArea, spellcheck: 'false' });
|
|
894
|
+
hook.value = preview.description;
|
|
895
|
+
|
|
896
|
+
const picker = node('select', { class: ui.select });
|
|
897
|
+
for (const section of preview.sections) picker.append(node('option', { value: section, text: section }));
|
|
898
|
+
picker.append(node('option', { value: '', text: 'End of MEMORY.md' }));
|
|
899
|
+
if (preview.sections.length) picker.value = preview.sections[preview.sections.length - 1];
|
|
900
|
+
|
|
901
|
+
const line = node('div', { class: ui.willItem('keep') });
|
|
902
|
+
const update = () => {
|
|
903
|
+
const label = title.value.trim().replace(/[[\]]/g, '') || preview.name;
|
|
904
|
+
const text = hook.value.trim();
|
|
905
|
+
line.textContent = text ? `- [${label}](${file}) — ${text}` : `- [${label}](${file})`;
|
|
906
|
+
};
|
|
907
|
+
title.addEventListener('input', update);
|
|
908
|
+
hook.addEventListener('input', update);
|
|
909
|
+
update();
|
|
910
|
+
|
|
911
|
+
const body = node('div', { class: ui.modalBody }, [
|
|
912
|
+
node('p', { class: ui.noteTight, text: preview.hasIndex
|
|
913
|
+
? '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.'
|
|
914
|
+
: 'This project has no MEMORY.md yet. Adding this entry creates one.' }),
|
|
915
|
+
node('label', { class: ui.willTitle, text: 'Title' }),
|
|
916
|
+
title,
|
|
917
|
+
node('label', { class: ui.willTitle, text: 'Hook' }),
|
|
918
|
+
hook,
|
|
919
|
+
node('label', { class: ui.willTitle, text: 'Section' }),
|
|
920
|
+
picker,
|
|
921
|
+
node('div', { class: ui.willBlock }, [
|
|
922
|
+
node('div', { class: ui.willTitle, text: 'Will be added' }),
|
|
923
|
+
line,
|
|
924
|
+
]),
|
|
925
|
+
]);
|
|
926
|
+
|
|
927
|
+
const modal = node('div', { class: ui.modal }, [
|
|
928
|
+
node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Add ${file} to MEMORY.md` })]),
|
|
929
|
+
body,
|
|
930
|
+
node('footer', { class: ui.modalFoot }, [
|
|
931
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
932
|
+
node('button', {
|
|
933
|
+
class: ui.button({ tone: 'primary' }),
|
|
934
|
+
text: 'Add entry',
|
|
935
|
+
onclick: async () => {
|
|
936
|
+
closeModal();
|
|
937
|
+
await runIndexEdit(
|
|
938
|
+
'index/add',
|
|
939
|
+
{ file, section: picker.value || null, title: title.value, hook: hook.value },
|
|
940
|
+
() => 'Added to MEMORY.md',
|
|
941
|
+
);
|
|
942
|
+
},
|
|
943
|
+
}),
|
|
944
|
+
]),
|
|
945
|
+
]);
|
|
946
|
+
|
|
947
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
948
|
+
el('modal-root').append(backdrop);
|
|
949
|
+
title.focus();
|
|
950
|
+
title.select();
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function openMergeDialog(into, from) {
|
|
954
|
+
let preview;
|
|
955
|
+
try {
|
|
956
|
+
preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge-preview`, {
|
|
957
|
+
method: 'POST',
|
|
958
|
+
body: JSON.stringify({ into, from }),
|
|
959
|
+
});
|
|
960
|
+
} catch (err) {
|
|
961
|
+
return toast(err.message, { error: true });
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const heading = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
|
|
965
|
+
heading.value = preview.heading;
|
|
966
|
+
|
|
967
|
+
const body = node('div', { class: ui.modalBody });
|
|
968
|
+
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.` }));
|
|
969
|
+
|
|
970
|
+
const changes = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will change' })]);
|
|
971
|
+
changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} + ${preview.bodyLines} line(s) from ${preview.from}` }));
|
|
972
|
+
for (const entry of preview.inbound) {
|
|
973
|
+
changes.append(node('div', { class: ui.willItem('keep'), text: `${entry.file} ${entry.targets.map((t) => `[[${t}]]`).join(' ')} → [[${preview.intoName}]]` }));
|
|
974
|
+
}
|
|
975
|
+
for (const link of preview.selfLinks) {
|
|
976
|
+
changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} [[${link}]] → plain text, to avoid a self-link` }));
|
|
977
|
+
}
|
|
978
|
+
body.append(changes);
|
|
979
|
+
|
|
980
|
+
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
|
|
981
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.from} → memory/.trash/` }));
|
|
982
|
+
for (const line of preview.indexLines) {
|
|
983
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
|
|
984
|
+
}
|
|
985
|
+
if (!preview.indexLines.length) {
|
|
986
|
+
removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for the source - nothing to remove there.' }));
|
|
987
|
+
}
|
|
988
|
+
body.append(removals);
|
|
989
|
+
|
|
990
|
+
if (preview.inlineRefs.length) {
|
|
991
|
+
const kept = node('div', { class: ui.willBlock }, [
|
|
992
|
+
node('div', { class: ui.willTitle, text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' }),
|
|
993
|
+
]);
|
|
994
|
+
for (const ref of preview.inlineRefs) {
|
|
995
|
+
kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
|
|
996
|
+
}
|
|
997
|
+
body.append(kept);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
body.append(node('label', { class: ui.willTitle, text: 'Heading for the merged section' }), heading);
|
|
1001
|
+
|
|
1002
|
+
const modal = node('div', { class: ui.modal }, [
|
|
1003
|
+
node('header', { class: ui.modalHead }, [
|
|
1004
|
+
node('h3', { class: ui.modalTitle, text: `Merge "${preview.fromName}" into "${preview.intoName}"?` }),
|
|
1005
|
+
]),
|
|
1006
|
+
body,
|
|
1007
|
+
node('footer', { class: ui.modalFoot }, [
|
|
1008
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
1009
|
+
node('button', {
|
|
1010
|
+
class: ui.button({ tone: 'primary' }),
|
|
1011
|
+
text: 'Merge',
|
|
1012
|
+
onclick: async () => {
|
|
1013
|
+
closeModal();
|
|
1014
|
+
try {
|
|
1015
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge`, {
|
|
1016
|
+
method: 'POST',
|
|
1017
|
+
body: JSON.stringify({ into, from, heading: heading.value }),
|
|
1018
|
+
});
|
|
1019
|
+
if (state.selected === from) state.selected = into;
|
|
1020
|
+
await openStore(state.storeId, { keepTab: true });
|
|
1021
|
+
toast(`Merged into ${preview.intoName}`, {
|
|
1022
|
+
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
1023
|
+
});
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
toast(err.message, { error: true });
|
|
1026
|
+
}
|
|
1027
|
+
},
|
|
1028
|
+
}),
|
|
1029
|
+
]),
|
|
1030
|
+
]);
|
|
1031
|
+
|
|
1032
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
1033
|
+
el('modal-root').append(backdrop);
|
|
1034
|
+
heading.focus();
|
|
1035
|
+
heading.select();
|
|
1036
|
+
}
|
|
1037
|
+
|
|
606
1038
|
async function restoreFromTrash(id) {
|
|
607
1039
|
try {
|
|
608
1040
|
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/restore`, {
|
|
@@ -618,33 +1050,39 @@ async function restoreFromTrash(id) {
|
|
|
618
1050
|
}
|
|
619
1051
|
}
|
|
620
1052
|
|
|
621
|
-
|
|
1053
|
+
const INDEX_EDIT_LABELS = {
|
|
1054
|
+
hook: 'MEMORY.md hook shortened',
|
|
1055
|
+
move: 'MEMORY.md entry moved',
|
|
1056
|
+
add: 'MEMORY.md entry added',
|
|
1057
|
+
};
|
|
622
1058
|
|
|
623
1059
|
function renderTrash(container) {
|
|
624
1060
|
const trash = state.store.trash;
|
|
625
1061
|
if (!trash.length) {
|
|
626
|
-
container.append(node('p', { class:
|
|
1062
|
+
container.append(node('p', { class: ui.note, text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
|
|
627
1063
|
return;
|
|
628
1064
|
}
|
|
629
1065
|
for (const record of trash) {
|
|
630
1066
|
const when = String(record.deletedAt).replace('T', ' ').replace(/\..*$/, '');
|
|
631
1067
|
const detail = record.kind === 'wikilink'
|
|
632
1068
|
? `unlinked in ${record.sourceFile} · ${when}`
|
|
633
|
-
:
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
1069
|
+
: record.kind === 'index-edit'
|
|
1070
|
+
? `${INDEX_EDIT_LABELS[record.op] || 'MEMORY.md edited'} · ${when}`
|
|
1071
|
+
: record.kind === 'merge'
|
|
1072
|
+
? `merged into ${record.into} · ${when} · ${record.backups?.length || 0} file(s) rewritten`
|
|
1073
|
+
: `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
|
|
1074
|
+
container.append(node('div', { class: ui.issue(!record.present) }, [
|
|
1075
|
+
node('div', { class: ui.issueBody }, [
|
|
1076
|
+
node('div', { class: ui.issueTitle, text: record.label || record.id }),
|
|
1077
|
+
node('div', { class: ui.issueDetail, text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
|
|
638
1078
|
]),
|
|
639
1079
|
record.present
|
|
640
|
-
? node('button', { class:
|
|
1080
|
+
? node('button', { class: ui.buttonPrimarySmall, text: 'Restore', onclick: () => restoreFromTrash(record.id) })
|
|
641
1081
|
: null,
|
|
642
1082
|
]));
|
|
643
1083
|
}
|
|
644
1084
|
}
|
|
645
1085
|
|
|
646
|
-
/* -------------------------------------------------------- delete dialog */
|
|
647
|
-
|
|
648
1086
|
function closeModal() {
|
|
649
1087
|
el('modal-root').textContent = '';
|
|
650
1088
|
}
|
|
@@ -660,53 +1098,50 @@ async function openDeleteDialog(file) {
|
|
|
660
1098
|
return toast(err.message, { error: true });
|
|
661
1099
|
}
|
|
662
1100
|
|
|
663
|
-
const body = node('div', { class:
|
|
1101
|
+
const body = node('div', { class: ui.modalBody });
|
|
664
1102
|
|
|
665
|
-
const removals = node('div', { class:
|
|
666
|
-
removals.append(node('div', { class: '
|
|
1103
|
+
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
|
|
1104
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.file} → memory/.trash/` }));
|
|
667
1105
|
for (const line of [...preview.indexLines, ...preview.continuations]) {
|
|
668
|
-
removals.append(node('div', { class: '
|
|
1106
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
|
|
669
1107
|
}
|
|
670
1108
|
if (!preview.indexLines.length && preview.hasIndex) {
|
|
671
|
-
removals.append(node('div', { class:
|
|
1109
|
+
removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for this file - nothing to unlink there.' }));
|
|
672
1110
|
}
|
|
673
1111
|
body.append(removals);
|
|
674
1112
|
|
|
675
1113
|
if (preview.inlineRefs.length) {
|
|
676
|
-
const kept = node('div', { class:
|
|
1114
|
+
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
1115
|
for (const ref of preview.inlineRefs) {
|
|
678
|
-
kept.append(node('div', { class: '
|
|
1116
|
+
kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
|
|
679
1117
|
}
|
|
680
1118
|
body.append(kept);
|
|
681
1119
|
}
|
|
682
1120
|
|
|
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
1121
|
const cascade = new Set();
|
|
687
1122
|
if (preview.inboundWikilinks.length) {
|
|
688
|
-
const block = node('div', { class:
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
node('
|
|
692
|
-
|
|
693
|
-
|
|
1123
|
+
const block = node('div', { class: ui.willBlock });
|
|
1124
|
+
const selectAll = node('button', { class: ui.linkButton, text: 'select all' });
|
|
1125
|
+
block.append(node('div', { class: ui.cascadeHead }, [
|
|
1126
|
+
node('div', { class: ui.willTitle, text: `${preview.inboundWikilinks.length} memory(s) link here and will break` }),
|
|
1127
|
+
selectAll,
|
|
1128
|
+
]));
|
|
694
1129
|
|
|
695
|
-
const list = node('div', { class:
|
|
1130
|
+
const list = node('div', { class: ui.cascadeList });
|
|
696
1131
|
const rows = [];
|
|
697
1132
|
for (const link of preview.inboundWikilinks) {
|
|
698
|
-
const box = node('input', { type: 'checkbox' });
|
|
699
|
-
const row = node('label', { class:
|
|
1133
|
+
const box = node('input', { type: 'checkbox', class: ui.checkbox });
|
|
1134
|
+
const row = node('label', { class: ui.cascadeRow(false) }, [
|
|
700
1135
|
box,
|
|
701
|
-
node('span', { class:
|
|
702
|
-
node('span', { class:
|
|
703
|
-
node('span', { class:
|
|
1136
|
+
node('span', { class: ui.cascadeText }, [
|
|
1137
|
+
node('span', { class: ui.cascadeName, text: link.fromName || link.from }),
|
|
1138
|
+
node('span', { class: ui.cascadeDetail, text: link.indexLine
|
|
704
1139
|
? `${link.from} · MEMORY.md line ${link.indexLine.index + 1}`
|
|
705
1140
|
: `${link.from} · not in the index` }),
|
|
706
1141
|
]),
|
|
707
1142
|
]);
|
|
708
1143
|
box.addEventListener('change', () => {
|
|
709
|
-
row.
|
|
1144
|
+
row.className = ui.cascadeRow(box.checked);
|
|
710
1145
|
if (box.checked) cascade.add(link.from);
|
|
711
1146
|
else cascade.delete(link.from);
|
|
712
1147
|
updateButton();
|
|
@@ -715,94 +1150,87 @@ async function openDeleteDialog(file) {
|
|
|
715
1150
|
list.append(row);
|
|
716
1151
|
}
|
|
717
1152
|
block.append(list);
|
|
718
|
-
block.append(node('p', { class:
|
|
1153
|
+
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
1154
|
body.append(block);
|
|
720
1155
|
|
|
721
|
-
|
|
1156
|
+
selectAll.onclick = (event) => {
|
|
722
1157
|
event.preventDefault();
|
|
723
1158
|
const turnOn = rows.some((r) => !r.box.checked);
|
|
724
1159
|
for (const { box } of rows) {
|
|
725
1160
|
if (box.checked !== turnOn) { box.checked = turnOn; box.dispatchEvent(new Event('change')); }
|
|
726
1161
|
}
|
|
727
|
-
|
|
1162
|
+
selectAll.textContent = turnOn ? 'select none' : 'select all';
|
|
728
1163
|
};
|
|
729
1164
|
}
|
|
730
1165
|
|
|
731
|
-
body.append(node('p', { class:
|
|
1166
|
+
body.append(node('p', { class: ui.noteTight, text: 'The file moves to memory/.trash/ with a restore record, so this can be undone.' }));
|
|
1167
|
+
|
|
1168
|
+
const confirmButton = node('button', {
|
|
1169
|
+
class: ui.button({ tone: 'danger' }),
|
|
1170
|
+
text: 'Delete',
|
|
1171
|
+
onclick: async () => {
|
|
1172
|
+
closeModal();
|
|
1173
|
+
try {
|
|
1174
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete`, {
|
|
1175
|
+
method: 'POST',
|
|
1176
|
+
body: JSON.stringify({ file, alsoDelete: [...cascade] }),
|
|
1177
|
+
});
|
|
1178
|
+
if (state.selected === file || cascade.has(state.selected)) state.selected = null;
|
|
1179
|
+
await openStore(state.storeId, { keepTab: true });
|
|
1180
|
+
const count = result.record.files.length;
|
|
1181
|
+
toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
|
|
1182
|
+
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
1183
|
+
});
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
toast(err.message, { error: true });
|
|
1186
|
+
}
|
|
1187
|
+
},
|
|
1188
|
+
});
|
|
732
1189
|
|
|
733
|
-
const modal = node('div', { class:
|
|
734
|
-
node('header', {}, [
|
|
735
|
-
node('h3', { text: `Delete "${preview.name || preview.file}"?` }),
|
|
736
|
-
node('p', { class:
|
|
1190
|
+
const modal = node('div', { class: ui.modal }, [
|
|
1191
|
+
node('header', { class: ui.modalHead }, [
|
|
1192
|
+
node('h3', { class: ui.modalTitle, text: `Delete "${preview.name || preview.file}"?` }),
|
|
1193
|
+
node('p', { class: ui.note, text: preview.description || '' }),
|
|
737
1194
|
]),
|
|
738
1195
|
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
|
-
}),
|
|
1196
|
+
node('footer', { class: ui.modalFoot }, [
|
|
1197
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
1198
|
+
confirmButton,
|
|
762
1199
|
]),
|
|
763
1200
|
]);
|
|
764
1201
|
|
|
765
|
-
const backdrop = node('div', { class:
|
|
1202
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
766
1203
|
el('modal-root').append(backdrop);
|
|
767
1204
|
updateButton();
|
|
768
1205
|
|
|
769
1206
|
function updateButton() {
|
|
770
|
-
|
|
771
|
-
if (button) button.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
|
|
1207
|
+
confirmButton.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
|
|
772
1208
|
}
|
|
773
1209
|
}
|
|
774
1210
|
|
|
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
1211
|
function openRememberPathDialog() {
|
|
784
1212
|
const store = state.store;
|
|
785
1213
|
const input = node('input', {
|
|
786
1214
|
type: 'text',
|
|
787
|
-
class:
|
|
1215
|
+
class: ui.pathInput,
|
|
788
1216
|
value: store.guess || '',
|
|
789
1217
|
placeholder: '/Users/you/repos/the-project',
|
|
790
1218
|
spellcheck: 'false',
|
|
791
1219
|
});
|
|
792
1220
|
|
|
793
|
-
const body = node('div', { class:
|
|
794
|
-
node('p', { class:
|
|
1221
|
+
const body = node('div', { class: ui.modalBody }, [
|
|
1222
|
+
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
1223
|
input,
|
|
796
|
-
node('p', { class:
|
|
1224
|
+
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
1225
|
]);
|
|
798
1226
|
|
|
799
|
-
const modal = node('div', { class:
|
|
800
|
-
node('header', {}, [node('h3', { text: 'Remember this project\u2019s path' })]),
|
|
1227
|
+
const modal = node('div', { class: ui.modal }, [
|
|
1228
|
+
node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Remember this project\u2019s path' })]),
|
|
801
1229
|
body,
|
|
802
|
-
node('footer', {}, [
|
|
803
|
-
node('button', { class:
|
|
1230
|
+
node('footer', { class: ui.modalFoot }, [
|
|
1231
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
804
1232
|
node('button', {
|
|
805
|
-
class: '
|
|
1233
|
+
class: ui.button({ tone: 'primary' }),
|
|
806
1234
|
text: 'Remember',
|
|
807
1235
|
onclick: async () => {
|
|
808
1236
|
const value = input.value.trim();
|
|
@@ -824,7 +1252,7 @@ function openRememberPathDialog() {
|
|
|
824
1252
|
]),
|
|
825
1253
|
]);
|
|
826
1254
|
|
|
827
|
-
const backdrop = node('div', { class:
|
|
1255
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
828
1256
|
el('modal-root').append(backdrop);
|
|
829
1257
|
input.focus();
|
|
830
1258
|
input.select();
|
|
@@ -853,40 +1281,39 @@ async function openStoreDeleteDialog() {
|
|
|
853
1281
|
return toast('This store has no memory to delete');
|
|
854
1282
|
}
|
|
855
1283
|
|
|
856
|
-
const body = node('div', { class:
|
|
857
|
-
const removals = node('div', { class:
|
|
1284
|
+
const body = node('div', { class: ui.modalBody });
|
|
1285
|
+
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
|
|
858
1286
|
if (preview.hasIndex) {
|
|
859
|
-
removals.append(node('div', { class: '
|
|
1287
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md (${preview.indexLines} lines) → memory/.trash/` }));
|
|
860
1288
|
}
|
|
861
1289
|
for (const entry of preview.files) {
|
|
862
|
-
removals.append(node('div', { class: '
|
|
1290
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `${entry.file} → memory/.trash/` }));
|
|
863
1291
|
}
|
|
864
1292
|
body.append(removals);
|
|
865
1293
|
|
|
866
|
-
body.append(node('div', { class:
|
|
867
|
-
node('div', { class:
|
|
868
|
-
node('div', { class: '
|
|
1294
|
+
body.append(node('div', { class: ui.willBlock }, [
|
|
1295
|
+
node('div', { class: ui.willTitle, text: 'Will be kept' }),
|
|
1296
|
+
node('div', { class: ui.willItem('keep'), text: state.store.kind === 'auto'
|
|
869
1297
|
? 'Session transcripts (*.jsonl) and the project folder itself'
|
|
870
1298
|
: 'The agent-memory folder itself, and every other agent\u2019s memory' }),
|
|
871
1299
|
]));
|
|
872
1300
|
|
|
873
|
-
body.append(node('p', { class:
|
|
1301
|
+
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
1302
|
|
|
875
|
-
// A project-scoped agent store is committed, so this is a working-tree change.
|
|
876
1303
|
if (state.store.kind === 'agent-project') {
|
|
877
|
-
body.append(node('p', { class:
|
|
1304
|
+
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
1305
|
}
|
|
879
1306
|
|
|
880
|
-
const modal = node('div', { class:
|
|
881
|
-
node('header', {}, [
|
|
882
|
-
node('h3', { text: `Delete all memory for ${state.store.label}?` }),
|
|
883
|
-
node('p', { class:
|
|
1307
|
+
const modal = node('div', { class: ui.modal }, [
|
|
1308
|
+
node('header', { class: ui.modalHead }, [
|
|
1309
|
+
node('h3', { class: ui.modalTitle, text: `Delete all memory for ${state.store.label}?` }),
|
|
1310
|
+
node('p', { class: ui.note, text: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}` }),
|
|
884
1311
|
]),
|
|
885
1312
|
body,
|
|
886
|
-
node('footer', {}, [
|
|
887
|
-
node('button', { class:
|
|
1313
|
+
node('footer', { class: ui.modalFoot }, [
|
|
1314
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
888
1315
|
node('button', {
|
|
889
|
-
class: '
|
|
1316
|
+
class: ui.button({ tone: 'danger' }),
|
|
890
1317
|
text: `Delete ${preview.files.length + (preview.hasIndex ? 1 : 0)} files`,
|
|
891
1318
|
onclick: async () => {
|
|
892
1319
|
closeModal();
|
|
@@ -905,13 +1332,10 @@ async function openStoreDeleteDialog() {
|
|
|
905
1332
|
]),
|
|
906
1333
|
]);
|
|
907
1334
|
|
|
908
|
-
const backdrop = node('div', { class:
|
|
1335
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
909
1336
|
el('modal-root').append(backdrop);
|
|
910
1337
|
}
|
|
911
1338
|
|
|
912
|
-
|
|
913
|
-
/* --------------------------------------------------------------- prune */
|
|
914
|
-
|
|
915
1339
|
function highlight(text, terms) {
|
|
916
1340
|
const wrapper = node('span');
|
|
917
1341
|
if (!terms.length) { wrapper.textContent = text; return wrapper; }
|
|
@@ -919,7 +1343,7 @@ function highlight(text, terms) {
|
|
|
919
1343
|
let last = 0;
|
|
920
1344
|
for (const match of text.matchAll(pattern)) {
|
|
921
1345
|
if (match.index > last) wrapper.append(document.createTextNode(text.slice(last, match.index)));
|
|
922
|
-
wrapper.append(node('mark', { text: match[0] }));
|
|
1346
|
+
wrapper.append(node('mark', { class: ui.resultMark, text: match[0] }));
|
|
923
1347
|
last = match.index + match[0].length;
|
|
924
1348
|
}
|
|
925
1349
|
if (last < text.length) wrapper.append(document.createTextNode(text.slice(last)));
|
|
@@ -944,7 +1368,7 @@ const PRUNE_SORTS = {
|
|
|
944
1368
|
function renderBudget(container) {
|
|
945
1369
|
const { index } = state.store.stats;
|
|
946
1370
|
const percent = Math.min(100, index.worstPercent);
|
|
947
|
-
const level = index.level
|
|
1371
|
+
const level = index.level;
|
|
948
1372
|
|
|
949
1373
|
const note = index.level === 'over'
|
|
950
1374
|
? '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 +1378,43 @@ function renderBudget(container) {
|
|
|
954
1378
|
|
|
955
1379
|
if (index.cutoff) {
|
|
956
1380
|
const dropped = index.cutoff.droppedEntries;
|
|
957
|
-
const box = node('div', { class:
|
|
958
|
-
box.append(node('div', { class:
|
|
1381
|
+
const box = node('div', { class: ui.cutSummary });
|
|
1382
|
+
box.append(node('div', { class: ui.cutSummaryHead, text: dropped.length
|
|
959
1383
|
? `${dropped.length} ${dropped.length === 1 ? 'memory is' : 'memories are'} past the cutoff and not loaded`
|
|
960
1384
|
: `${index.cutoff.droppedLines} lines are past the cutoff and not loaded` }));
|
|
961
|
-
box.append(node('p', { class:
|
|
1385
|
+
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
1386
|
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:
|
|
1387
|
+
box.append(node('div', { class: ui.issue(true) }, [
|
|
1388
|
+
node('div', { class: ui.issueBody }, [
|
|
1389
|
+
node('div', { class: ui.issueTitle, text: entry.title || entry.file }),
|
|
1390
|
+
node('div', { class: ui.issueDetail, text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
|
|
967
1391
|
]),
|
|
968
|
-
node('button', { class:
|
|
1392
|
+
node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(entry.file) }),
|
|
1393
|
+
node('button', { class: ui.buttonPrimarySmall, text: 'Move up', onclick: () => openMoveDialog(entry) }),
|
|
969
1394
|
]));
|
|
970
1395
|
}
|
|
971
1396
|
if (dropped.length > 20) {
|
|
972
|
-
box.append(node('p', { class:
|
|
1397
|
+
box.append(node('p', { class: ui.note, text: `${dropped.length - 20} more not listed.` }));
|
|
973
1398
|
}
|
|
974
1399
|
container.append(box);
|
|
975
1400
|
}
|
|
976
1401
|
|
|
977
|
-
container.append(node('div', { class:
|
|
978
|
-
node('div', { class:
|
|
979
|
-
node('span', { class:
|
|
980
|
-
node('span', { class:
|
|
1402
|
+
container.append(node('div', { class: ui.meter }, [
|
|
1403
|
+
node('div', { class: ui.meterTop }, [
|
|
1404
|
+
node('span', { class: ui.meterValue, text: `${Math.round(index.worstPercent)}%` }),
|
|
1405
|
+
node('span', { class: ui.meterUnit, text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
|
|
981
1406
|
]),
|
|
982
|
-
node('div', { class:
|
|
983
|
-
node('div', { class:
|
|
1407
|
+
node('div', { class: ui.meterBar }, [
|
|
1408
|
+
node('div', { class: ui.meterFill(level), style: `width:${percent.toFixed(1)}%` }),
|
|
984
1409
|
]),
|
|
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')]),
|
|
1410
|
+
node('p', { class: ui.meterNote, text: note }),
|
|
1411
|
+
node('div', { class: ui.meterFacts }, [
|
|
1412
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
|
|
1413
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
|
|
1414
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(index.entryCount) }), document.createTextNode(' index entries')]),
|
|
1415
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
|
|
991
1416
|
]),
|
|
992
|
-
node('p', { class:
|
|
1417
|
+
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
1418
|
]));
|
|
994
1419
|
}
|
|
995
1420
|
|
|
@@ -998,49 +1423,48 @@ function renderPrune(container) {
|
|
|
998
1423
|
renderBudget(container);
|
|
999
1424
|
|
|
1000
1425
|
if (!project.memories.length) {
|
|
1001
|
-
container.append(node('p', { class:
|
|
1426
|
+
container.append(node('p', { class: ui.note, text: 'Nothing to prune - this project has no memories.' }));
|
|
1002
1427
|
return;
|
|
1003
1428
|
}
|
|
1004
1429
|
|
|
1005
|
-
// --- bulk selection list ---
|
|
1006
1430
|
const selected = state.pruneSelection;
|
|
1007
|
-
const bar = node('div', { class:
|
|
1008
|
-
const sort = node('select');
|
|
1431
|
+
const bar = node('div', { class: ui.pruneBar });
|
|
1432
|
+
const sort = node('select', { class: ui.select });
|
|
1009
1433
|
for (const [key, config] of Object.entries(PRUNE_SORTS)) {
|
|
1010
1434
|
sort.append(node('option', { value: key, text: config.label, selected: key === state.pruneSort ? '' : null }));
|
|
1011
1435
|
}
|
|
1012
1436
|
sort.value = state.pruneSort;
|
|
1013
1437
|
sort.onchange = () => { state.pruneSort = sort.value; renderTab(); };
|
|
1014
1438
|
|
|
1015
|
-
const count = node('span', { class:
|
|
1016
|
-
const deleteButton = node('button', { class:
|
|
1017
|
-
const clearButton = node('button', { class:
|
|
1439
|
+
const count = node('span', { class: ui.note });
|
|
1440
|
+
const deleteButton = node('button', { class: ui.buttonDangerSmall });
|
|
1441
|
+
const clearButton = node('button', { class: ui.buttonSmall, text: 'Clear selection' });
|
|
1018
1442
|
|
|
1019
|
-
bar.append(node('span', { class:
|
|
1443
|
+
bar.append(node('span', { class: ui.sectionLabel, text: 'Prune' }), sort, node('span', { class: ui.pruneSpacer }), count, clearButton, deleteButton);
|
|
1020
1444
|
container.append(bar);
|
|
1021
1445
|
|
|
1022
1446
|
const list = node('div');
|
|
1023
1447
|
const ordered = [...project.memories].sort(PRUNE_SORTS[state.pruneSort].compare);
|
|
1024
1448
|
const rows = [];
|
|
1025
1449
|
for (const memory of ordered) {
|
|
1026
|
-
const box = node('input', { type: 'checkbox' });
|
|
1450
|
+
const box = node('input', { type: 'checkbox', class: ui.checkbox });
|
|
1027
1451
|
box.checked = selected.has(memory.file);
|
|
1028
|
-
const row = node('label', { class:
|
|
1452
|
+
const row = node('label', { class: ui.pruneRow(box.checked) }, [
|
|
1029
1453
|
box,
|
|
1030
|
-
node('span', { class:
|
|
1031
|
-
node('span', { class:
|
|
1032
|
-
node('span', { class:
|
|
1454
|
+
node('span', { class: ui.pruneMain }, [
|
|
1455
|
+
node('span', { class: ui.pruneName, text: memory.name }),
|
|
1456
|
+
node('span', { class: ui.pruneDesc, text: memory.description || memory.file }),
|
|
1033
1457
|
]),
|
|
1034
|
-
node('span', { class:
|
|
1035
|
-
node('span', { class: (memory.ageDays ?? 0) > 180
|
|
1458
|
+
node('span', { class: ui.pruneFacts }, [
|
|
1459
|
+
node('span', { class: ui.pruneFact((memory.ageDays ?? 0) > 180), text: formatAge(memory.ageDays) }),
|
|
1036
1460
|
node('span', { text: `${(memory.bytes / 1024).toFixed(1)}K` }),
|
|
1037
|
-
node('span', { class: memory.inbound.length
|
|
1038
|
-
node('span', { class: memory.status
|
|
1461
|
+
node('span', { class: ui.pruneFact(!memory.inbound.length), text: `${memory.inbound.length} in` }),
|
|
1462
|
+
node('span', { class: ui.pruneFact(memory.status !== 'indexed'), text: memory.status }),
|
|
1039
1463
|
]),
|
|
1040
1464
|
]);
|
|
1041
1465
|
box.addEventListener('change', () => {
|
|
1042
1466
|
if (box.checked) selected.add(memory.file); else selected.delete(memory.file);
|
|
1043
|
-
row.
|
|
1467
|
+
row.className = ui.pruneRow(box.checked);
|
|
1044
1468
|
updateBar();
|
|
1045
1469
|
});
|
|
1046
1470
|
rows.push({ box, memory });
|
|
@@ -1052,7 +1476,6 @@ function renderPrune(container) {
|
|
|
1052
1476
|
count.textContent = selected.size ? `${selected.size} selected` : '';
|
|
1053
1477
|
deleteButton.textContent = `Delete ${selected.size || ''}`.trim();
|
|
1054
1478
|
deleteButton.disabled = selected.size === 0;
|
|
1055
|
-
deleteButton.style.opacity = selected.size ? '1' : '.45';
|
|
1056
1479
|
}
|
|
1057
1480
|
clearButton.onclick = () => {
|
|
1058
1481
|
selected.clear();
|
|
@@ -1061,41 +1484,56 @@ function renderPrune(container) {
|
|
|
1061
1484
|
deleteButton.onclick = () => openBulkDeleteDialog([...selected]);
|
|
1062
1485
|
updateBar();
|
|
1063
1486
|
|
|
1064
|
-
// --- long hooks ---
|
|
1065
1487
|
const { longHooks } = project.stats.index;
|
|
1066
1488
|
if (longHooks.length) {
|
|
1067
|
-
container.append(node('div', { class:
|
|
1068
|
-
container.append(node('p', { class:
|
|
1489
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Long index hooks · ${longHooks.length}` }));
|
|
1490
|
+
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
1491
|
for (const hook of longHooks.slice(0, 12)) {
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1492
|
+
const row = node('div', { class: ui.issue(false) });
|
|
1493
|
+
const editButton = node('button', { class: ui.buttonPrimarySmall, text: 'Edit hook' });
|
|
1494
|
+
row.append(
|
|
1495
|
+
node('div', { class: ui.issueBody }, [
|
|
1496
|
+
node('div', { class: ui.issueTitle, text: `${hook.hookLength} chars: ${hook.title}` }),
|
|
1497
|
+
node('div', { class: ui.issueDetail, text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
|
|
1074
1498
|
]),
|
|
1075
|
-
node('button', { class:
|
|
1076
|
-
|
|
1499
|
+
node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(hook.file) }),
|
|
1500
|
+
editButton,
|
|
1501
|
+
);
|
|
1502
|
+
editButton.onclick = () => openHookEditor(row, hook.index);
|
|
1503
|
+
container.append(row);
|
|
1077
1504
|
}
|
|
1078
1505
|
}
|
|
1079
1506
|
|
|
1080
|
-
// --- possible overlap ---
|
|
1081
1507
|
if (project.duplicates.length) {
|
|
1082
|
-
container.append(node('div', { class:
|
|
1083
|
-
container.append(node('p', { class:
|
|
1508
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Possible overlap · ${project.duplicates.length}` }));
|
|
1509
|
+
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
1510
|
for (const pair of project.duplicates) {
|
|
1085
|
-
container.append(node('div', { class:
|
|
1086
|
-
node('div', { class:
|
|
1087
|
-
node('span', { class:
|
|
1511
|
+
container.append(node('div', { class: ui.dupe }, [
|
|
1512
|
+
node('div', { class: ui.dupeHead }, [
|
|
1513
|
+
node('span', { class: ui.dupeScore, text: `${pair.score}%` }),
|
|
1088
1514
|
node('span', { text: `shared: ${pair.shared.join(', ')}` }),
|
|
1089
1515
|
]),
|
|
1090
|
-
node('div', { class:
|
|
1516
|
+
node('div', { class: ui.dupePair }, [
|
|
1091
1517
|
...[pair.a, pair.b].map((side) => node('div', {
|
|
1092
|
-
class:
|
|
1518
|
+
class: ui.dupeSide,
|
|
1093
1519
|
onclick: () => selectMemory(side.file),
|
|
1094
1520
|
}, [
|
|
1095
|
-
node('div', { class:
|
|
1096
|
-
node('div', { class:
|
|
1521
|
+
node('div', { class: ui.dupeSideName, text: side.name }),
|
|
1522
|
+
node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
|
|
1097
1523
|
])),
|
|
1098
1524
|
]),
|
|
1525
|
+
node('div', { class: ui.dupeActions }, [
|
|
1526
|
+
node('button', {
|
|
1527
|
+
class: ui.buttonSmall,
|
|
1528
|
+
text: `Merge into ${pair.a.name}`,
|
|
1529
|
+
onclick: () => openMergeDialog(pair.a.file, pair.b.file),
|
|
1530
|
+
}),
|
|
1531
|
+
node('button', {
|
|
1532
|
+
class: ui.buttonSmall,
|
|
1533
|
+
text: `Merge into ${pair.b.name}`,
|
|
1534
|
+
onclick: () => openMergeDialog(pair.b.file, pair.a.file),
|
|
1535
|
+
}),
|
|
1536
|
+
]),
|
|
1099
1537
|
]));
|
|
1100
1538
|
}
|
|
1101
1539
|
}
|
|
@@ -1105,14 +1543,13 @@ async function openBulkDeleteDialog(files) {
|
|
|
1105
1543
|
if (!files.length) return;
|
|
1106
1544
|
const memories = files.map((file) => state.store.memories.find((m) => m.file === file)).filter(Boolean);
|
|
1107
1545
|
|
|
1108
|
-
const body = node('div', { class:
|
|
1109
|
-
const removals = node('div', { class:
|
|
1546
|
+
const body = node('div', { class: ui.modalBody });
|
|
1547
|
+
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${memories.length} memories will be trashed` })]);
|
|
1110
1548
|
for (const memory of memories) {
|
|
1111
|
-
removals.append(node('div', { class: '
|
|
1549
|
+
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
1550
|
}
|
|
1113
1551
|
body.append(removals);
|
|
1114
1552
|
|
|
1115
|
-
// Links from memories that are NOT being deleted are the ones that break.
|
|
1116
1553
|
const doomed = new Set(files);
|
|
1117
1554
|
const breaking = [];
|
|
1118
1555
|
for (const memory of memories) {
|
|
@@ -1121,20 +1558,20 @@ async function openBulkDeleteDialog(files) {
|
|
|
1121
1558
|
}
|
|
1122
1559
|
}
|
|
1123
1560
|
if (breaking.length) {
|
|
1124
|
-
const block = node('div', { class:
|
|
1125
|
-
for (const link of breaking) block.append(node('div', { class: '
|
|
1561
|
+
const block = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${breaking.length} link(s) from memories you are keeping will break` })]);
|
|
1562
|
+
for (const link of breaking) block.append(node('div', { class: ui.willItem('keep'), text: `${link.from} → [[${link.target}]]` }));
|
|
1126
1563
|
body.append(block);
|
|
1127
1564
|
}
|
|
1128
1565
|
|
|
1129
|
-
body.append(node('p', { class:
|
|
1566
|
+
body.append(node('p', { class: ui.noteTight, text: 'All of them go to memory/.trash/ as one restore point.' }));
|
|
1130
1567
|
|
|
1131
|
-
const modal = node('div', { class:
|
|
1132
|
-
node('header', {}, [node('h3', { text: `Delete ${memories.length} memories?` })]),
|
|
1568
|
+
const modal = node('div', { class: ui.modal }, [
|
|
1569
|
+
node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Delete ${memories.length} memories?` })]),
|
|
1133
1570
|
body,
|
|
1134
|
-
node('footer', {}, [
|
|
1135
|
-
node('button', { class:
|
|
1571
|
+
node('footer', { class: ui.modalFoot }, [
|
|
1572
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
1136
1573
|
node('button', {
|
|
1137
|
-
class: '
|
|
1574
|
+
class: ui.button({ tone: 'danger' }),
|
|
1138
1575
|
text: `Delete ${memories.length}`,
|
|
1139
1576
|
onclick: async () => {
|
|
1140
1577
|
closeModal();
|
|
@@ -1156,12 +1593,10 @@ async function openBulkDeleteDialog(files) {
|
|
|
1156
1593
|
}),
|
|
1157
1594
|
]),
|
|
1158
1595
|
]);
|
|
1159
|
-
const backdrop = node('div', { class:
|
|
1596
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
1160
1597
|
el('modal-root').append(backdrop);
|
|
1161
1598
|
}
|
|
1162
1599
|
|
|
1163
|
-
/* -------------------------------------------------------------- search */
|
|
1164
|
-
|
|
1165
1600
|
let searchTimer = null;
|
|
1166
1601
|
|
|
1167
1602
|
function scheduleSearch(value) {
|
|
@@ -1195,44 +1630,44 @@ function renderSearch() {
|
|
|
1195
1630
|
: `No results for "${state.query.trim()}"`;
|
|
1196
1631
|
|
|
1197
1632
|
if (!data.total && !data.stores.length) {
|
|
1198
|
-
host.append(node('p', { class:
|
|
1633
|
+
host.append(node('p', { class: ui.note, text: 'Nothing matched. Every word has to appear somewhere in a memory, so try fewer words.' }));
|
|
1199
1634
|
return;
|
|
1200
1635
|
}
|
|
1201
1636
|
|
|
1202
1637
|
for (const group of data.stores) {
|
|
1203
|
-
const block = node('div', { class:
|
|
1204
|
-
block.append(node('div', { class:
|
|
1638
|
+
const block = node('div', { class: ui.resultGroup });
|
|
1639
|
+
block.append(node('div', { class: ui.sectionLabel, text: group.sublabel
|
|
1205
1640
|
? `${group.label} · ${group.sublabel} · ${group.results.length}`
|
|
1206
1641
|
: `${group.label} · ${group.results.length}` }));
|
|
1207
1642
|
|
|
1208
1643
|
for (const result of group.results) {
|
|
1209
1644
|
const button = node('button', {
|
|
1210
|
-
class:
|
|
1645
|
+
class: ui.result,
|
|
1211
1646
|
onclick: () => openFromSearch(group.id, result.file),
|
|
1212
1647
|
});
|
|
1213
|
-
const top = node('div', { class:
|
|
1214
|
-
const name = node('span', { class:
|
|
1648
|
+
const top = node('div', { class: ui.resultTop });
|
|
1649
|
+
const name = node('span', { class: ui.resultName });
|
|
1215
1650
|
name.append(highlight(result.name, data.terms));
|
|
1216
|
-
top.append(name, node('span', { class:
|
|
1217
|
-
if (result.status !== 'indexed') top.append(node('span', { class:
|
|
1651
|
+
top.append(name, node('span', { class: ui.typeBadge(result.type), text: result.type }));
|
|
1652
|
+
if (result.status !== 'indexed') top.append(node('span', { class: ui.badge('warn'), text: result.status }));
|
|
1218
1653
|
button.append(top);
|
|
1219
1654
|
|
|
1220
1655
|
if (result.snippet) {
|
|
1221
|
-
const snippet = node('div', { class:
|
|
1656
|
+
const snippet = node('div', { class: ui.resultSnippet });
|
|
1222
1657
|
snippet.append(highlight(result.snippet.text, data.terms));
|
|
1223
1658
|
button.append(snippet);
|
|
1224
1659
|
}
|
|
1225
|
-
button.append(node('div', { class:
|
|
1660
|
+
button.append(node('div', { class: ui.resultWhere, text: `${result.file} · matched in ${result.fields.join(', ')}` }));
|
|
1226
1661
|
block.append(button);
|
|
1227
1662
|
}
|
|
1228
1663
|
|
|
1229
1664
|
for (const hit of group.indexHits) {
|
|
1230
1665
|
const button = node('button', {
|
|
1231
|
-
class:
|
|
1666
|
+
class: ui.result,
|
|
1232
1667
|
onclick: () => openFromSearch(group.id, hit.file, 'index'),
|
|
1233
1668
|
});
|
|
1234
|
-
button.append(node('div', { class:
|
|
1235
|
-
const line = node('div', { class:
|
|
1669
|
+
button.append(node('div', { class: ui.resultWhere, text: `MEMORY.md line ${hit.index + 1}` }));
|
|
1670
|
+
const line = node('div', { class: ui.resultSnippet });
|
|
1236
1671
|
line.append(highlight(hit.text.trim(), data.terms));
|
|
1237
1672
|
button.append(line);
|
|
1238
1673
|
block.append(button);
|
|
@@ -1265,8 +1700,6 @@ function renderView() {
|
|
|
1265
1700
|
if (searching) renderSearch();
|
|
1266
1701
|
}
|
|
1267
1702
|
|
|
1268
|
-
/* ------------------------------------------------------------ rendering */
|
|
1269
|
-
|
|
1270
1703
|
function renderTab() {
|
|
1271
1704
|
const container = el('tab-content');
|
|
1272
1705
|
container.textContent = '';
|
|
@@ -1275,9 +1708,10 @@ function renderTab() {
|
|
|
1275
1708
|
else if (state.tab === 'context') renderContext(container);
|
|
1276
1709
|
else if (state.tab === 'index') renderIndex(container);
|
|
1277
1710
|
else if (state.tab === 'health') renderHealth(container);
|
|
1711
|
+
else if (state.tab === 'settings') renderSettings(container);
|
|
1278
1712
|
else if (state.tab === 'trash') renderTrash(container);
|
|
1279
1713
|
else if (state.tab === 'graph') {
|
|
1280
|
-
const wrap = node('div', { id: 'graph-wrap' });
|
|
1714
|
+
const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
|
|
1281
1715
|
container.append(wrap);
|
|
1282
1716
|
renderGraph(wrap, state.store.graph, {
|
|
1283
1717
|
selected: state.selected,
|
|
@@ -1301,10 +1735,9 @@ function selectMemory(file) {
|
|
|
1301
1735
|
} else {
|
|
1302
1736
|
renderTab();
|
|
1303
1737
|
}
|
|
1304
|
-
document.querySelector('
|
|
1738
|
+
document.querySelector('[data-memory][data-active]')?.scrollIntoView({ block: 'nearest' });
|
|
1305
1739
|
}
|
|
1306
1740
|
|
|
1307
|
-
/** Re-read the store list, e.g. after a path is remembered or forgotten. */
|
|
1308
1741
|
async function reloadStores() {
|
|
1309
1742
|
const data = await api('/api/stores');
|
|
1310
1743
|
state.stores = data.stores;
|
|
@@ -1312,23 +1745,27 @@ async function reloadStores() {
|
|
|
1312
1745
|
return data;
|
|
1313
1746
|
}
|
|
1314
1747
|
|
|
1315
|
-
/** The name and provenance line above the tabs, which differs by store kind. */
|
|
1316
1748
|
function renderStoreHeader() {
|
|
1317
1749
|
const store = state.store;
|
|
1318
1750
|
el('project-title').textContent = store.label;
|
|
1751
|
+
el('delete-project').hidden = store.kind === 'global';
|
|
1319
1752
|
|
|
1320
1753
|
const sub = el('project-sub');
|
|
1321
1754
|
sub.textContent = '';
|
|
1322
1755
|
|
|
1756
|
+
if (store.kind === 'global') {
|
|
1757
|
+
sub.append(node('span', { text: store.dir }));
|
|
1758
|
+
sub.append(node('span', { class: ui.subNote, text: 'instructions every session loads, before any project is chosen' }));
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1323
1762
|
if (store.kind !== 'auto') {
|
|
1324
1763
|
const scope = store.kind === 'agent-user' ? 'user'
|
|
1325
1764
|
: store.kind === 'agent-project' ? 'project' : 'local';
|
|
1326
1765
|
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.
|
|
1766
|
+
sub.append(node('span', { class: ui.subNote, text: `subagent memory, ${scope} scope` }));
|
|
1330
1767
|
if (store.kind === 'agent-project') {
|
|
1331
|
-
sub.append(node('span', { class:
|
|
1768
|
+
sub.append(node('span', { class: ui.subWarn, text: 'checked into the repository - changes here show up in git status' }));
|
|
1332
1769
|
}
|
|
1333
1770
|
return;
|
|
1334
1771
|
}
|
|
@@ -1337,24 +1774,22 @@ function renderStoreHeader() {
|
|
|
1337
1774
|
? `${store.path} (real path unresolved)`
|
|
1338
1775
|
: store.path }));
|
|
1339
1776
|
|
|
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
1777
|
const others = (store.workingDirs || []).filter((d) => d !== store.path);
|
|
1343
1778
|
if (others.length) {
|
|
1344
|
-
sub.append(node('span', { class:
|
|
1779
|
+
sub.append(node('span', { class: ui.subNote, text: `also used from ${others.join(', ')}` }));
|
|
1345
1780
|
}
|
|
1346
1781
|
if (store.resolvedBy === 'unresolved') {
|
|
1347
|
-
sub.append(node('button', { class:
|
|
1782
|
+
sub.append(node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }));
|
|
1348
1783
|
} else if (store.resolvedBy === 'remembered') {
|
|
1349
|
-
sub.append(node('span', { class:
|
|
1784
|
+
sub.append(node('span', { class: ui.subNote }, [
|
|
1350
1785
|
document.createTextNode('path remembered, not recovered from a transcript '),
|
|
1351
|
-
node('button', { class:
|
|
1786
|
+
node('button', { class: ui.linkButton, text: 'Forget', onclick: forgetProjectPath }),
|
|
1352
1787
|
]));
|
|
1353
1788
|
}
|
|
1354
1789
|
|
|
1355
1790
|
const auto = store.autoMemory;
|
|
1356
1791
|
if (auto && auto.known && !auto.enabled) {
|
|
1357
|
-
sub.append(node('span', { class:
|
|
1792
|
+
sub.append(node('span', { class: ui.subWarn, text: auto.scope === 'env'
|
|
1358
1793
|
? 'auto memory is off (CLAUDE_CODE_DISABLE_AUTO_MEMORY) - this store will not grow'
|
|
1359
1794
|
: `auto memory is off (${auto.setBy}) - this store will not grow` }));
|
|
1360
1795
|
}
|
|
@@ -1364,7 +1799,8 @@ async function openStore(id, { keepTab = false } = {}) {
|
|
|
1364
1799
|
if (id !== state.storeId) state.pruneSelection.clear();
|
|
1365
1800
|
state.storeId = id;
|
|
1366
1801
|
if (!keepTab) {
|
|
1367
|
-
state.
|
|
1802
|
+
const opening = state.stores.find((store) => store.id === id);
|
|
1803
|
+
state.tab = opening && opening.kind === 'global' ? 'context' : 'memories';
|
|
1368
1804
|
state.selected = null;
|
|
1369
1805
|
}
|
|
1370
1806
|
renderStores();
|
|
@@ -1381,7 +1817,6 @@ async function openStore(id, { keepTab = false } = {}) {
|
|
|
1381
1817
|
|
|
1382
1818
|
renderStoreHeader();
|
|
1383
1819
|
|
|
1384
|
-
// Refresh the sidebar count in case a delete changed it.
|
|
1385
1820
|
const listed = state.stores.find((store) => store.id === id);
|
|
1386
1821
|
if (listed) listed.memoryCount = state.store.memories.length;
|
|
1387
1822
|
renderStores();
|
|
@@ -1392,7 +1827,8 @@ async function openStore(id, { keepTab = false } = {}) {
|
|
|
1392
1827
|
}
|
|
1393
1828
|
|
|
1394
1829
|
function applyCollapsed() {
|
|
1395
|
-
el('app').
|
|
1830
|
+
el('app').className = state.collapsed ? ui.shellCollapsed : ui.shell;
|
|
1831
|
+
el('sidebar').className = state.collapsed ? ui.sidebarHidden : ui.sidebar;
|
|
1396
1832
|
el('expand').hidden = !state.collapsed;
|
|
1397
1833
|
localStorage.setItem('sidebarCollapsed', state.collapsed ? '1' : '0');
|
|
1398
1834
|
}
|
|
@@ -1400,12 +1836,17 @@ function applyCollapsed() {
|
|
|
1400
1836
|
function setCollapsed(value) {
|
|
1401
1837
|
state.collapsed = value;
|
|
1402
1838
|
applyCollapsed();
|
|
1403
|
-
// The graph sizes itself to the container, so it has to be redrawn once the
|
|
1404
|
-
// pane has actually changed width.
|
|
1405
1839
|
if (state.tab === 'graph' && state.store) requestAnimationFrame(renderTab);
|
|
1406
1840
|
}
|
|
1407
1841
|
|
|
1842
|
+
function applyStyles() {
|
|
1843
|
+
for (const element of document.querySelectorAll('[data-ui]')) {
|
|
1844
|
+
element.className = ui[element.dataset.ui] ?? '';
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1408
1848
|
async function init() {
|
|
1849
|
+
applyStyles();
|
|
1409
1850
|
el('show-all').addEventListener('change', (event) => {
|
|
1410
1851
|
state.showAll = event.target.checked;
|
|
1411
1852
|
renderStores();
|
|
@@ -1440,7 +1881,7 @@ async function init() {
|
|
|
1440
1881
|
el('root-path').title = data.rootFile
|
|
1441
1882
|
? `autoMemoryDirectory set in ${data.rootFile}`
|
|
1442
1883
|
: 'store chosen with --root';
|
|
1443
|
-
el('root-path').append(node('span', { class:
|
|
1884
|
+
el('root-path').append(node('span', { class: ui.rootSource, text: data.rootSource }));
|
|
1444
1885
|
}
|
|
1445
1886
|
if (data.rootWarning) toast(data.rootWarning, { error: true });
|
|
1446
1887
|
const first = state.stores.find((store) => store.memoryCount > 0);
|