claude-memory-admin 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -9
- package/package.json +8 -2
- package/public/app.mjs +633 -362
- package/public/graph.mjs +32 -76
- package/public/index.html +34 -29
- package/public/markdown.mjs +74 -0
- package/public/styles.css +2 -402
- package/public/ui.mjs +291 -0
- package/server.mjs +61 -20
- package/src/instructions.mjs +423 -0
- package/src/model.mjs +44 -11
- package/src/mutate.mjs +17 -23
- package/src/pathcache.mjs +93 -0
- package/src/projects.mjs +120 -34
- package/src/search.mjs +13 -11
- package/src/settings.mjs +151 -0
- package/src/stats.mjs +105 -12
- package/src/stores.mjs +146 -0
package/public/app.mjs
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
stores: [],
|
|
7
|
+
storeId: null,
|
|
8
|
+
store: null,
|
|
11
9
|
tab: 'memories',
|
|
12
10
|
selected: null,
|
|
13
11
|
showAll: false,
|
|
@@ -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,96 +59,83 @@ 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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
71
|
+
function openTarget(file) {
|
|
72
|
+
return file ? { open: () => selectMemory(file) } : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderBody(container, memory, project) {
|
|
76
|
+
const byName = memoryLookup(project);
|
|
77
|
+
renderMarkdown(container, memory.body, {
|
|
78
|
+
resolveWikilink: (target) => openTarget(byName.get(target)),
|
|
81
79
|
});
|
|
80
|
+
}
|
|
82
81
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
}
|
|
82
|
+
function storeSubtitle(store) {
|
|
83
|
+
if (store.kind === 'auto') return store.path;
|
|
84
|
+
const scope = store.kind === 'agent-user' ? 'user'
|
|
85
|
+
: store.kind === 'agent-project' ? 'project' : 'local';
|
|
86
|
+
return `${scope} · ${store.sublabel}`;
|
|
113
87
|
}
|
|
114
88
|
|
|
115
|
-
|
|
89
|
+
function storeButton(store) {
|
|
90
|
+
const health = store.memoryCount === 0 ? 'none' : 'ok';
|
|
91
|
+
const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
|
|
92
|
+
return node('button', {
|
|
93
|
+
class: ui.storeItem({ active: store.id === state.storeId, empty: !store.hasMemoryDir }),
|
|
94
|
+
onclick: () => openStore(store.id),
|
|
95
|
+
title: store.kind === 'auto' && store.resolvedBy === 'unresolved'
|
|
96
|
+
? 'Real path could not be resolved - showing the raw folder name'
|
|
97
|
+
: store.dir,
|
|
98
|
+
}, [
|
|
99
|
+
node('span', { class: ui.storeRow }, [
|
|
100
|
+
node('span', { class: ui.dot(health) }),
|
|
101
|
+
node('span', { class: ui.storeName, text: store.label }),
|
|
102
|
+
off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
|
|
103
|
+
node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
|
|
104
|
+
]),
|
|
105
|
+
node('span', { class: ui.storePath, text: storeSubtitle(store) }),
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
116
108
|
|
|
117
|
-
function
|
|
109
|
+
function renderStores() {
|
|
118
110
|
const list = el('project-list');
|
|
119
111
|
list.textContent = '';
|
|
120
112
|
|
|
121
|
-
const visible = state.
|
|
113
|
+
const visible = state.stores.filter((s) => state.showAll || s.hasMemoryDir);
|
|
122
114
|
if (!visible.length) {
|
|
123
|
-
list.append(node('p', { class:
|
|
115
|
+
list.append(node('p', { class: ui.note, text: 'No memory stores found.' }));
|
|
124
116
|
return;
|
|
125
117
|
}
|
|
126
118
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
node('span', { class: 'project-name', text: project.label }),
|
|
139
|
-
node('span', { class: 'project-count', text: project.hasMemoryDir ? String(project.memoryCount) : '-' }),
|
|
140
|
-
]),
|
|
141
|
-
node('span', { class: 'project-path', text: project.path }),
|
|
142
|
-
]);
|
|
143
|
-
list.append(button);
|
|
119
|
+
const groups = [
|
|
120
|
+
['Projects', visible.filter((s) => s.kind === 'auto')],
|
|
121
|
+
['Subagents', visible.filter((s) => s.kind !== 'auto')],
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
for (const [title, stores] of groups) {
|
|
125
|
+
if (!stores.length) continue;
|
|
126
|
+
if (groups.every(([, group]) => group.length) ) {
|
|
127
|
+
list.append(node('div', { class: ui.sidebarGroup, text: title }));
|
|
128
|
+
}
|
|
129
|
+
for (const store of stores) list.append(storeButton(store));
|
|
144
130
|
}
|
|
145
131
|
}
|
|
146
132
|
|
|
147
|
-
/* ------------------------------------------------------------------ tabs */
|
|
148
|
-
|
|
149
133
|
const TABS = [
|
|
150
134
|
{ id: 'memories', label: 'Memories' },
|
|
151
135
|
{ id: 'index', label: 'MEMORY.md' },
|
|
152
136
|
{ id: 'graph', label: 'Graph' },
|
|
153
137
|
{ id: 'prune', label: 'Prune' },
|
|
138
|
+
{ id: 'context', label: 'Context' },
|
|
154
139
|
{ id: 'health', label: 'Health' },
|
|
155
140
|
{ id: 'trash', label: 'Trash' },
|
|
156
141
|
];
|
|
@@ -158,51 +143,55 @@ const TABS = [
|
|
|
158
143
|
function renderTabs() {
|
|
159
144
|
const container = el('tabs');
|
|
160
145
|
container.textContent = '';
|
|
146
|
+
const hasProjectDir = state.store.kind === 'auto'
|
|
147
|
+
? state.store.resolvedBy !== 'unresolved'
|
|
148
|
+
: Boolean(state.store.projectPath);
|
|
149
|
+
|
|
161
150
|
for (const tab of TABS) {
|
|
151
|
+
if (tab.id === 'context' && !hasProjectDir) continue;
|
|
162
152
|
let badge = null;
|
|
163
|
-
if (tab.id === 'memories') badge = String(state.
|
|
164
|
-
if (tab.id === 'health' && state.
|
|
165
|
-
if (tab.id === 'trash' && state.
|
|
166
|
-
if (tab.id === 'prune' && state.
|
|
153
|
+
if (tab.id === 'memories') badge = String(state.store.memories.length);
|
|
154
|
+
if (tab.id === 'health' && state.store.health.issueCount) badge = String(state.store.health.issueCount);
|
|
155
|
+
if (tab.id === 'trash' && state.store.trash.length) badge = String(state.store.trash.length);
|
|
156
|
+
if (tab.id === 'prune' && state.store.stats.index.overTarget) badge = '!';
|
|
167
157
|
|
|
168
158
|
container.append(node('button', {
|
|
169
|
-
class:
|
|
159
|
+
class: ui.tab(state.tab === tab.id),
|
|
170
160
|
onclick: () => { state.tab = tab.id; renderTabs(); renderTab(); },
|
|
171
161
|
}, [
|
|
172
162
|
document.createTextNode(tab.label),
|
|
173
|
-
badge ? node('span', { class:
|
|
163
|
+
badge ? node('span', { class: ui.tabBadge(tab.id === 'health' || tab.id === 'prune'), text: badge }) : null,
|
|
174
164
|
]));
|
|
175
165
|
}
|
|
176
166
|
}
|
|
177
167
|
|
|
178
|
-
/* -------------------------------------------------------------- memories */
|
|
179
|
-
|
|
180
168
|
function memoryButton(memory) {
|
|
169
|
+
const active = state.selected === memory.file;
|
|
181
170
|
return node('button', {
|
|
182
|
-
class:
|
|
171
|
+
class: ui.memoryItem(active),
|
|
172
|
+
'data-memory': memory.file,
|
|
173
|
+
'data-active': active ? '' : null,
|
|
183
174
|
onclick: () => selectMemory(memory.file),
|
|
184
175
|
}, [
|
|
185
|
-
node('span', { class:
|
|
186
|
-
node('span', { class:
|
|
187
|
-
node('span', { class:
|
|
176
|
+
node('span', { class: ui.memoryTop }, [
|
|
177
|
+
node('span', { class: ui.memoryName, text: memory.name }),
|
|
178
|
+
node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
|
|
188
179
|
memory.status !== 'indexed'
|
|
189
|
-
? node('span', { class:
|
|
180
|
+
? node('span', { class: ui.badge('warn'), text: memory.status })
|
|
190
181
|
: null,
|
|
191
182
|
]),
|
|
192
|
-
memory.description ? node('span', { class:
|
|
183
|
+
memory.description ? node('span', { class: ui.memoryDesc, text: memory.description }) : null,
|
|
193
184
|
]);
|
|
194
185
|
}
|
|
195
186
|
|
|
196
187
|
function renderMemories(container) {
|
|
197
|
-
const project = state.
|
|
188
|
+
const project = state.store;
|
|
198
189
|
if (!project.memories.length) {
|
|
199
|
-
container.append(node('p', { class:
|
|
190
|
+
container.append(node('p', { class: ui.note, text: 'This project has no memory files yet.' }));
|
|
200
191
|
return;
|
|
201
192
|
}
|
|
202
193
|
|
|
203
194
|
const left = node('div');
|
|
204
|
-
// Group by the MEMORY.md heading each entry sits under, so the list mirrors
|
|
205
|
-
// how the index is actually organised.
|
|
206
195
|
const groups = new Map();
|
|
207
196
|
for (const memory of project.memories) {
|
|
208
197
|
const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
|
|
@@ -210,12 +199,12 @@ function renderMemories(container) {
|
|
|
210
199
|
groups.get(key).push(memory);
|
|
211
200
|
}
|
|
212
201
|
for (const [section, memories] of groups) {
|
|
213
|
-
left.append(node('div', { class:
|
|
202
|
+
left.append(node('div', { class: ui.sectionLabel, text: `${section} · ${memories.length}` }));
|
|
214
203
|
for (const memory of memories) left.append(memoryButton(memory));
|
|
215
204
|
}
|
|
216
205
|
|
|
217
|
-
const right = node('div', { id: 'detail' });
|
|
218
|
-
container.append(node('div', { class:
|
|
206
|
+
const right = node('div', { id: 'detail', class: ui.detailPane });
|
|
207
|
+
container.append(node('div', { class: ui.split }, [left, right]));
|
|
219
208
|
renderDetail();
|
|
220
209
|
}
|
|
221
210
|
|
|
@@ -224,30 +213,32 @@ function renderDetail() {
|
|
|
224
213
|
if (!host) return;
|
|
225
214
|
host.textContent = '';
|
|
226
215
|
|
|
227
|
-
const memory = state.
|
|
216
|
+
const memory = state.store.memories.find((m) => m.file === state.selected);
|
|
228
217
|
if (!memory) {
|
|
229
|
-
host.append(node('p', { class:
|
|
218
|
+
host.append(node('p', { class: ui.note, text: 'Select a memory to read it.' }));
|
|
230
219
|
return;
|
|
231
220
|
}
|
|
232
221
|
|
|
233
|
-
const card = node('div', { class:
|
|
234
|
-
card.append(node('div', { class:
|
|
235
|
-
node('h3', { text: memory.name }),
|
|
236
|
-
node('span', { class:
|
|
222
|
+
const card = node('div', { class: ui.card });
|
|
223
|
+
card.append(node('div', { class: ui.detailHead }, [
|
|
224
|
+
node('h3', { class: ui.detailTitle, text: memory.name }),
|
|
225
|
+
node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
|
|
237
226
|
node('button', {
|
|
238
|
-
class:
|
|
227
|
+
class: ui.buttonDangerSmall,
|
|
239
228
|
text: 'Delete',
|
|
240
229
|
onclick: () => openDeleteDialog(memory.file),
|
|
241
230
|
}),
|
|
242
231
|
]));
|
|
243
232
|
|
|
244
|
-
if (memory.description) card.append(node('p', { class:
|
|
233
|
+
if (memory.description) card.append(node('p', { class: ui.detailDesc, text: memory.description }));
|
|
245
234
|
|
|
246
|
-
const meta = node('dl', { class:
|
|
235
|
+
const meta = node('dl', { class: ui.metaList });
|
|
247
236
|
const rows = [
|
|
248
237
|
['file', memory.file],
|
|
249
238
|
['status', memory.status],
|
|
250
|
-
['modified', memory.modified
|
|
239
|
+
['modified', memory.modified
|
|
240
|
+
? `${memory.modified.replace('T', ' ').replace(/\..*$/, '')}${memory.modifiedFrom === 'mtime' ? ' (file mtime)' : ''}`
|
|
241
|
+
: '-'],
|
|
251
242
|
['size', `${(memory.bytes / 1024).toFixed(1)} KB`],
|
|
252
243
|
];
|
|
253
244
|
if (!memory.nameMatchesFile) rows.push(['name', `${memory.name} (differs from filename)`]);
|
|
@@ -255,23 +246,23 @@ function renderDetail() {
|
|
|
255
246
|
if (key !== 'type' && key !== 'modified') rows.push([key, value]);
|
|
256
247
|
}
|
|
257
248
|
for (const [key, value] of rows) {
|
|
258
|
-
meta.append(node('dt', { text: key }), node('dd', { text: String(value) }));
|
|
249
|
+
meta.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: String(value) }));
|
|
259
250
|
}
|
|
260
251
|
card.append(meta);
|
|
261
252
|
|
|
262
|
-
const body = node('div', { class:
|
|
253
|
+
const body = node('div', { class: ui.prose });
|
|
263
254
|
card.append(body);
|
|
264
|
-
renderBody(body, memory, state.
|
|
255
|
+
renderBody(body, memory, state.store);
|
|
265
256
|
|
|
266
257
|
const outbound = memory.outboundResolved || [];
|
|
267
258
|
if (outbound.length || memory.inbound.length) {
|
|
268
|
-
const links = node('div', {
|
|
259
|
+
const links = node('div', { class: ui.linkSection });
|
|
269
260
|
if (outbound.length) {
|
|
270
|
-
links.append(node('div', { class:
|
|
271
|
-
const chips = node('div', { class:
|
|
261
|
+
links.append(node('div', { class: ui.sectionLabel, text: 'Links out' }));
|
|
262
|
+
const chips = node('div', { class: ui.linkRow });
|
|
272
263
|
for (const link of outbound) {
|
|
273
264
|
chips.append(node('button', {
|
|
274
|
-
class:
|
|
265
|
+
class: ui.chip(Boolean(link.file)),
|
|
275
266
|
text: link.file ? link.target : `${link.target} (missing)`,
|
|
276
267
|
onclick: link.file ? () => selectMemory(link.file) : null,
|
|
277
268
|
}));
|
|
@@ -279,12 +270,12 @@ function renderDetail() {
|
|
|
279
270
|
links.append(chips);
|
|
280
271
|
}
|
|
281
272
|
if (memory.inbound.length) {
|
|
282
|
-
links.append(node('div', { class:
|
|
283
|
-
const chips = node('div', { class:
|
|
273
|
+
links.append(node('div', { class: ui.sectionLabel, text: 'Linked from' }));
|
|
274
|
+
const chips = node('div', { class: ui.linkRow });
|
|
284
275
|
for (const link of memory.inbound) {
|
|
285
|
-
const source = state.
|
|
276
|
+
const source = state.store.memories.find((m) => m.file === link.from);
|
|
286
277
|
chips.append(node('button', {
|
|
287
|
-
class:
|
|
278
|
+
class: ui.chip(true),
|
|
288
279
|
text: source ? source.name : link.from,
|
|
289
280
|
onclick: () => selectMemory(link.from),
|
|
290
281
|
}));
|
|
@@ -297,58 +288,207 @@ function renderDetail() {
|
|
|
297
288
|
host.append(card);
|
|
298
289
|
}
|
|
299
290
|
|
|
300
|
-
|
|
291
|
+
const INDEX_VIEWS = [
|
|
292
|
+
{ id: 'rendered', label: 'Rendered' },
|
|
293
|
+
{ id: 'source', label: 'Source' },
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
function setIndexView(view) {
|
|
297
|
+
state.indexView = view;
|
|
298
|
+
localStorage.setItem('memoryIndexView', view);
|
|
299
|
+
renderTab();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function indexViewPicker() {
|
|
303
|
+
return node('div', { class: ui.segmentGroup, role: 'tablist' }, INDEX_VIEWS.map((view) => node('button', {
|
|
304
|
+
class: ui.segment(state.indexView === view.id),
|
|
305
|
+
text: view.label,
|
|
306
|
+
role: 'tab',
|
|
307
|
+
'aria-selected': String(state.indexView === view.id),
|
|
308
|
+
onclick: () => setIndexView(view.id),
|
|
309
|
+
})));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function cutMarker(cutoff) {
|
|
313
|
+
return node('div', { class: ui.cutLine }, [
|
|
314
|
+
node('span', { class: ui.cutLabel, text: `not loaded past here — ${cutoff.droppedLines} lines dropped on ${cutoff.by}` }),
|
|
315
|
+
]);
|
|
316
|
+
}
|
|
301
317
|
|
|
302
318
|
function renderIndex(container) {
|
|
303
|
-
const project = state.
|
|
319
|
+
const project = state.store;
|
|
304
320
|
if (!project.hasIndex) {
|
|
305
|
-
container.append(node('p', { class:
|
|
321
|
+
container.append(node('p', { class: ui.note, text: 'This project has no MEMORY.md.' }));
|
|
306
322
|
return;
|
|
307
323
|
}
|
|
308
324
|
|
|
325
|
+
const card = node('div', { class: ui.card });
|
|
326
|
+
card.append(node('div', { class: ui.cardHeadRow }, [
|
|
327
|
+
node('p', { class: ui.note, text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }),
|
|
328
|
+
indexViewPicker(),
|
|
329
|
+
]));
|
|
330
|
+
|
|
331
|
+
if (state.indexView === 'source') renderIndexSource(card, project);
|
|
332
|
+
else renderIndexRendered(card, project);
|
|
333
|
+
|
|
334
|
+
container.append(card);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function renderIndexRendered(card, project) {
|
|
338
|
+
const { cutoff } = project.stats.index;
|
|
339
|
+
const files = new Set(project.memories.map((memory) => memory.file));
|
|
340
|
+
const byName = memoryLookup(project);
|
|
341
|
+
const options = {
|
|
342
|
+
resolveWikilink: (target) => openTarget(byName.get(target)),
|
|
343
|
+
resolveHref: (href) => openTarget(files.has(href) ? href : null),
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const lines = project.index.raw.split('\n');
|
|
347
|
+
const loaded = node('div', { class: ui.prose });
|
|
348
|
+
renderMarkdown(loaded, cutoff ? lines.slice(0, cutoff.rawLine).join('\n') : project.index.raw, options);
|
|
349
|
+
card.append(loaded);
|
|
350
|
+
|
|
351
|
+
if (!cutoff) return;
|
|
352
|
+
card.append(cutMarker(cutoff));
|
|
353
|
+
const dropped = node('div', { class: ui.proseDim });
|
|
354
|
+
renderMarkdown(dropped, lines.slice(cutoff.rawLine).join('\n'), options);
|
|
355
|
+
card.append(dropped);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function renderIndexSource(card, project) {
|
|
309
359
|
const missing = new Set(project.health.danglingIndex.map((d) => d.index));
|
|
310
|
-
const
|
|
311
|
-
card.append(node('p', { class: 'muted', style: 'margin-top:0', text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }));
|
|
360
|
+
const { cutoff } = project.stats.index;
|
|
312
361
|
|
|
313
362
|
for (const line of project.index.lines) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
363
|
+
if (cutoff && line.index === cutoff.rawLine) card.append(cutMarker(cutoff));
|
|
364
|
+
|
|
365
|
+
const dangling = missing.has(line.index);
|
|
366
|
+
const dropped = Boolean(cutoff) && line.index >= cutoff.rawLine;
|
|
367
|
+
const clickable = line.kind === 'index' && !dangling;
|
|
368
|
+
const content = node('span', {
|
|
369
|
+
class: ui.indexLineText({ kind: line.kind, clickable, dropped }),
|
|
370
|
+
text: line.text || ' ',
|
|
371
|
+
});
|
|
372
|
+
if (clickable) content.onclick = () => selectMemory(line.file);
|
|
373
|
+
|
|
374
|
+
card.append(node('div', { class: ui.indexLine({ dangling, dropped }) }, [
|
|
375
|
+
node('span', { class: ui.indexLineNumber, text: String(line.index + 1) }),
|
|
376
|
+
content,
|
|
377
|
+
]));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const SCOPE_ORDER = ['managed', 'user', 'project', 'local'];
|
|
382
|
+
|
|
383
|
+
const CONTEXT_PROBLEMS = {
|
|
384
|
+
missing: (p) => ['Import does not resolve', `${p.spec} from ${p.from} - nothing at ${p.file}`],
|
|
385
|
+
'too-deep': (p) => ['Import chain is too deep', `${p.file} is a fifth hop; Claude Code follows four, so this never loads`],
|
|
386
|
+
cycle: (p) => ['Circular import', `${p.from} imports ${p.file}, which was already loaded`],
|
|
387
|
+
external: (p) => ['Import resolves outside the project', `${p.file} - Claude Code asks you to approve these once, and a declined one stays disabled silently`],
|
|
388
|
+
'invalid-glob': (p) => [`Rule matches nothing: ${p.pattern}`, `${p.file} - ${p.reason}`],
|
|
389
|
+
'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`],
|
|
390
|
+
'long-claude-md': (p) => [`${p.lines} lines, over the 200-line guidance`, `${p.file} - long files cost context every session and reduce adherence`],
|
|
391
|
+
'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.`],
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
async function renderContext(container) {
|
|
395
|
+
container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
|
|
396
|
+
let data;
|
|
397
|
+
try {
|
|
398
|
+
data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
container.textContent = '';
|
|
401
|
+
return container.append(node('p', { class: ui.note, text: err.message }));
|
|
402
|
+
}
|
|
403
|
+
if (state.tab !== 'context') return;
|
|
404
|
+
container.textContent = '';
|
|
405
|
+
|
|
406
|
+
if (!data.projectDir) {
|
|
407
|
+
return container.append(node('p', { class: ui.note, text: 'This store is not tied to a project directory, so there are no instruction files to resolve.' }));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const { totals } = data;
|
|
411
|
+
container.append(node('div', { class: ui.meter }, [
|
|
412
|
+
node('div', { class: ui.meterTop }, [
|
|
413
|
+
node('span', { class: ui.meterValue, text: `~${totals.alwaysTokens.toLocaleString()}` }),
|
|
414
|
+
node('span', { class: ui.meterUnit, text: 'estimated tokens of instructions, every session' }),
|
|
415
|
+
]),
|
|
416
|
+
node('div', { class: ui.meterFacts }, [
|
|
417
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.files) }), document.createTextNode(' files resolved')]),
|
|
418
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.alwaysLines) }), document.createTextNode(' lines always loaded')]),
|
|
419
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(totals.alwaysBytes / 1024).toFixed(1)} KB` }), document.createTextNode(' always loaded')]),
|
|
420
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.conditionalFiles) }), document.createTextNode(' path-scoped rules, loaded only on a match')]),
|
|
421
|
+
]),
|
|
422
|
+
node('p', { class: ui.meterNote, text: 'Unlike MEMORY.md, none of this is truncated: CLAUDE.md files load in full however long they are. The 200-line figure is Claude Code’s guidance, not a cutoff, because long files cost context every session and are followed less reliably.' }),
|
|
423
|
+
]));
|
|
424
|
+
|
|
425
|
+
if (data.problems.length) {
|
|
426
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
|
|
427
|
+
for (const problem of data.problems) {
|
|
428
|
+
const describe = CONTEXT_PROBLEMS[problem.kind];
|
|
429
|
+
const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
|
|
430
|
+
const bad = problem.kind === 'missing' || problem.kind === 'invalid-glob' || problem.kind === 'glob-budget';
|
|
431
|
+
container.append(node('div', { class: ui.issue(bad) }, [
|
|
432
|
+
node('div', { class: ui.issueBody }, [
|
|
433
|
+
node('div', { class: ui.issueTitle, text: title }),
|
|
434
|
+
node('div', { class: ui.issueDetail, text: detail }),
|
|
435
|
+
]),
|
|
436
|
+
]));
|
|
322
437
|
}
|
|
323
|
-
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Loaded in this order · ${data.files.length}` }));
|
|
441
|
+
const card = node('div', { class: ui.card });
|
|
442
|
+
for (const file of data.files) {
|
|
443
|
+
const tags = [node('span', { class: ui.scopeBadge(file.scope), text: file.scope })];
|
|
444
|
+
if (file.kind === 'import') tags.push(node('span', { class: ui.badge(), text: `import · depth ${file.depth}` }));
|
|
445
|
+
if (file.kind === 'rule') tags.push(node('span', { class: ui.badge(), text: 'rule' }));
|
|
446
|
+
if (file.kind === 'managed-settings') tags.push(node('span', { class: ui.badge(), text: 'claudeMd setting' }));
|
|
447
|
+
if (file.conditional) tags.push(node('span', { class: ui.badge('warn'), text: 'only on a path match' }));
|
|
448
|
+
|
|
449
|
+
card.append(node('div', { class: ui.contextRow }, [
|
|
450
|
+
node('div', { class: ui.contextMain }, [
|
|
451
|
+
node('div', { class: ui.contextTags }, tags),
|
|
452
|
+
node('div', { class: ui.contextFile, text: file.file }),
|
|
453
|
+
]),
|
|
454
|
+
node('div', { class: ui.contextSize, text: `${file.lines} L · ~${file.tokens.toLocaleString()} tok` }),
|
|
455
|
+
]));
|
|
324
456
|
}
|
|
325
457
|
container.append(card);
|
|
326
|
-
}
|
|
327
458
|
|
|
328
|
-
|
|
459
|
+
if (data.excluded.length) {
|
|
460
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Excluded by claudeMdExcludes · ${data.excluded.length}` }));
|
|
461
|
+
for (const file of data.excluded) {
|
|
462
|
+
container.append(node('div', { class: ui.issue(false) }, [
|
|
463
|
+
node('div', { class: ui.issueBody }, [node('div', { class: ui.issueDetail, text: file })]),
|
|
464
|
+
]));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
container.append(node('p', { class: ui.meterNote, text: 'This is re-derived from the documented resolution rules, not a report from Claude Code. Run /context in a session to see what it actually loaded.' }));
|
|
469
|
+
}
|
|
329
470
|
|
|
330
471
|
function issue(title, detail, { bad = false, action, secondary } = {}) {
|
|
331
|
-
return node('div', { class:
|
|
332
|
-
node('div', { class:
|
|
333
|
-
node('div', { class:
|
|
334
|
-
node('div', { class:
|
|
472
|
+
return node('div', { class: ui.issue(bad) }, [
|
|
473
|
+
node('div', { class: ui.issueBody }, [
|
|
474
|
+
node('div', { class: ui.issueTitle, text: title }),
|
|
475
|
+
node('div', { class: ui.issueDetail, text: detail }),
|
|
335
476
|
]),
|
|
336
|
-
secondary ? node('button', { class:
|
|
337
|
-
action ? node('button', { class:
|
|
477
|
+
secondary ? node('button', { class: ui.buttonSmall, text: secondary.label, onclick: secondary.run }) : null,
|
|
478
|
+
action ? node('button', { class: ui.buttonSmall, text: action.label, onclick: action.run }) : null,
|
|
338
479
|
]);
|
|
339
480
|
}
|
|
340
481
|
|
|
341
482
|
function renderHealth(container) {
|
|
342
|
-
const { health, memories } = state.
|
|
483
|
+
const { health, memories } = state.store;
|
|
343
484
|
if (!health.issues.length) {
|
|
344
|
-
container.append(node('div', { class:
|
|
345
|
-
node('p', {
|
|
346
|
-
node('p', { class:
|
|
485
|
+
container.append(node('div', { class: ui.card }, [
|
|
486
|
+
node('p', { class: ui.okLine, text: '\u2713 No consistency problems found.' }),
|
|
487
|
+
node('p', { class: ui.noteTight, text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
|
|
347
488
|
]));
|
|
348
489
|
return;
|
|
349
490
|
}
|
|
350
491
|
|
|
351
|
-
// Rendered straight from health.issues so the tab and the badge always agree.
|
|
352
492
|
for (const item of health.issues) {
|
|
353
493
|
container.append(renderIssue(item, memories));
|
|
354
494
|
}
|
|
@@ -369,12 +509,12 @@ function renderIssue(item, memories) {
|
|
|
369
509
|
run: async () => {
|
|
370
510
|
if (!confirm(`Remove line ${entry.index + 1} from MEMORY.md?\n\n${entry.text}`)) return;
|
|
371
511
|
try {
|
|
372
|
-
await api(`/api/
|
|
512
|
+
await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
|
|
373
513
|
method: 'POST',
|
|
374
514
|
body: JSON.stringify({ lineIndex: entry.index, expectedText: entry.text }),
|
|
375
515
|
});
|
|
376
516
|
toast('Pointer removed');
|
|
377
|
-
await
|
|
517
|
+
await openStore(state.storeId, { keepTab: true });
|
|
378
518
|
} catch (err) {
|
|
379
519
|
toast(err.message, { error: true });
|
|
380
520
|
}
|
|
@@ -397,11 +537,11 @@ function renderIssue(item, memories) {
|
|
|
397
537
|
run: async () => {
|
|
398
538
|
if (!confirm(`Remove [[${link.target}]] from ${link.from}?\n\nThe link markup goes, the words stay:\n "see [[${link.target}]]" -> "see ${link.target}"\n\nA copy of the file is kept in the trash, so this can be undone.`)) return;
|
|
399
539
|
try {
|
|
400
|
-
const result = await api(`/api/
|
|
540
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/wikilink/remove`, {
|
|
401
541
|
method: 'POST',
|
|
402
542
|
body: JSON.stringify({ file: link.from, target: link.target }),
|
|
403
543
|
});
|
|
404
|
-
await
|
|
544
|
+
await openStore(state.storeId, { keepTab: true });
|
|
405
545
|
toast(`Unlinked ${result.occurrences} reference(s)`, {
|
|
406
546
|
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
407
547
|
});
|
|
@@ -467,11 +607,11 @@ function renderIssue(item, memories) {
|
|
|
467
607
|
|
|
468
608
|
async function restoreFromTrash(id) {
|
|
469
609
|
try {
|
|
470
|
-
const result = await api(`/api/
|
|
610
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/restore`, {
|
|
471
611
|
method: 'POST',
|
|
472
612
|
body: JSON.stringify({ id }),
|
|
473
613
|
});
|
|
474
|
-
await
|
|
614
|
+
await openStore(state.storeId, { keepTab: true });
|
|
475
615
|
toast(result.indexRestored === 'appended'
|
|
476
616
|
? 'Restored, but MEMORY.md had changed - the index line was appended at the end'
|
|
477
617
|
: 'Restored');
|
|
@@ -480,12 +620,10 @@ async function restoreFromTrash(id) {
|
|
|
480
620
|
}
|
|
481
621
|
}
|
|
482
622
|
|
|
483
|
-
/* --------------------------------------------------------------- trash */
|
|
484
|
-
|
|
485
623
|
function renderTrash(container) {
|
|
486
|
-
const trash = state.
|
|
624
|
+
const trash = state.store.trash;
|
|
487
625
|
if (!trash.length) {
|
|
488
|
-
container.append(node('p', { class:
|
|
626
|
+
container.append(node('p', { class: ui.note, text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
|
|
489
627
|
return;
|
|
490
628
|
}
|
|
491
629
|
for (const record of trash) {
|
|
@@ -493,20 +631,18 @@ function renderTrash(container) {
|
|
|
493
631
|
const detail = record.kind === 'wikilink'
|
|
494
632
|
? `unlinked in ${record.sourceFile} · ${when}`
|
|
495
633
|
: `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
|
|
496
|
-
container.append(node('div', { class:
|
|
497
|
-
node('div', { class:
|
|
498
|
-
node('div', { class:
|
|
499
|
-
node('div', { class:
|
|
634
|
+
container.append(node('div', { class: ui.issue(!record.present) }, [
|
|
635
|
+
node('div', { class: ui.issueBody }, [
|
|
636
|
+
node('div', { class: ui.issueTitle, text: record.label || record.id }),
|
|
637
|
+
node('div', { class: ui.issueDetail, text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
|
|
500
638
|
]),
|
|
501
639
|
record.present
|
|
502
|
-
? node('button', { class:
|
|
640
|
+
? node('button', { class: ui.buttonPrimarySmall, text: 'Restore', onclick: () => restoreFromTrash(record.id) })
|
|
503
641
|
: null,
|
|
504
642
|
]));
|
|
505
643
|
}
|
|
506
644
|
}
|
|
507
645
|
|
|
508
|
-
/* -------------------------------------------------------- delete dialog */
|
|
509
|
-
|
|
510
646
|
function closeModal() {
|
|
511
647
|
el('modal-root').textContent = '';
|
|
512
648
|
}
|
|
@@ -514,7 +650,7 @@ function closeModal() {
|
|
|
514
650
|
async function openDeleteDialog(file) {
|
|
515
651
|
let preview;
|
|
516
652
|
try {
|
|
517
|
-
preview = await api(`/api/
|
|
653
|
+
preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete-preview`, {
|
|
518
654
|
method: 'POST',
|
|
519
655
|
body: JSON.stringify({ file }),
|
|
520
656
|
});
|
|
@@ -522,53 +658,50 @@ async function openDeleteDialog(file) {
|
|
|
522
658
|
return toast(err.message, { error: true });
|
|
523
659
|
}
|
|
524
660
|
|
|
525
|
-
const body = node('div', { class:
|
|
661
|
+
const body = node('div', { class: ui.modalBody });
|
|
526
662
|
|
|
527
|
-
const removals = node('div', { class:
|
|
528
|
-
removals.append(node('div', { class: '
|
|
663
|
+
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
|
|
664
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.file} → memory/.trash/` }));
|
|
529
665
|
for (const line of [...preview.indexLines, ...preview.continuations]) {
|
|
530
|
-
removals.append(node('div', { class: '
|
|
666
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
|
|
531
667
|
}
|
|
532
668
|
if (!preview.indexLines.length && preview.hasIndex) {
|
|
533
|
-
removals.append(node('div', { class:
|
|
669
|
+
removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for this file - nothing to unlink there.' }));
|
|
534
670
|
}
|
|
535
671
|
body.append(removals);
|
|
536
672
|
|
|
537
673
|
if (preview.inlineRefs.length) {
|
|
538
|
-
const kept = node('div', { class:
|
|
674
|
+
const kept = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' })]);
|
|
539
675
|
for (const ref of preview.inlineRefs) {
|
|
540
|
-
kept.append(node('div', { class: '
|
|
676
|
+
kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
|
|
541
677
|
}
|
|
542
678
|
body.append(kept);
|
|
543
679
|
}
|
|
544
680
|
|
|
545
|
-
// Memories that link here can be deleted in the same operation. Ticking one
|
|
546
|
-
// is the common follow-up, so it lives in the dialog rather than forcing a
|
|
547
|
-
// second pass through the list.
|
|
548
681
|
const cascade = new Set();
|
|
549
682
|
if (preview.inboundWikilinks.length) {
|
|
550
|
-
const block = node('div', { class:
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
node('
|
|
554
|
-
|
|
555
|
-
|
|
683
|
+
const block = node('div', { class: ui.willBlock });
|
|
684
|
+
const selectAll = node('button', { class: ui.linkButton, text: 'select all' });
|
|
685
|
+
block.append(node('div', { class: ui.cascadeHead }, [
|
|
686
|
+
node('div', { class: ui.willTitle, text: `${preview.inboundWikilinks.length} memory(s) link here and will break` }),
|
|
687
|
+
selectAll,
|
|
688
|
+
]));
|
|
556
689
|
|
|
557
|
-
const list = node('div', { class:
|
|
690
|
+
const list = node('div', { class: ui.cascadeList });
|
|
558
691
|
const rows = [];
|
|
559
692
|
for (const link of preview.inboundWikilinks) {
|
|
560
|
-
const box = node('input', { type: 'checkbox' });
|
|
561
|
-
const row = node('label', { class:
|
|
693
|
+
const box = node('input', { type: 'checkbox', class: ui.checkbox });
|
|
694
|
+
const row = node('label', { class: ui.cascadeRow(false) }, [
|
|
562
695
|
box,
|
|
563
|
-
node('span', { class:
|
|
564
|
-
node('span', { class:
|
|
565
|
-
node('span', { class:
|
|
696
|
+
node('span', { class: ui.cascadeText }, [
|
|
697
|
+
node('span', { class: ui.cascadeName, text: link.fromName || link.from }),
|
|
698
|
+
node('span', { class: ui.cascadeDetail, text: link.indexLine
|
|
566
699
|
? `${link.from} · MEMORY.md line ${link.indexLine.index + 1}`
|
|
567
700
|
: `${link.from} · not in the index` }),
|
|
568
701
|
]),
|
|
569
702
|
]);
|
|
570
703
|
box.addEventListener('change', () => {
|
|
571
|
-
row.
|
|
704
|
+
row.className = ui.cascadeRow(box.checked);
|
|
572
705
|
if (box.checked) cascade.add(link.from);
|
|
573
706
|
else cascade.delete(link.from);
|
|
574
707
|
updateButton();
|
|
@@ -577,110 +710,178 @@ async function openDeleteDialog(file) {
|
|
|
577
710
|
list.append(row);
|
|
578
711
|
}
|
|
579
712
|
block.append(list);
|
|
580
|
-
block.append(node('p', { class:
|
|
713
|
+
block.append(node('p', { class: ui.noteTight, text: 'Tick any you also want deleted - they go to the trash together and restore as one step.' }));
|
|
581
714
|
body.append(block);
|
|
582
715
|
|
|
583
|
-
|
|
716
|
+
selectAll.onclick = (event) => {
|
|
584
717
|
event.preventDefault();
|
|
585
718
|
const turnOn = rows.some((r) => !r.box.checked);
|
|
586
719
|
for (const { box } of rows) {
|
|
587
720
|
if (box.checked !== turnOn) { box.checked = turnOn; box.dispatchEvent(new Event('change')); }
|
|
588
721
|
}
|
|
589
|
-
|
|
722
|
+
selectAll.textContent = turnOn ? 'select none' : 'select all';
|
|
590
723
|
};
|
|
591
724
|
}
|
|
592
725
|
|
|
593
|
-
body.append(node('p', { class:
|
|
726
|
+
body.append(node('p', { class: ui.noteTight, text: 'The file moves to memory/.trash/ with a restore record, so this can be undone.' }));
|
|
727
|
+
|
|
728
|
+
const confirmButton = node('button', {
|
|
729
|
+
class: ui.button({ tone: 'danger' }),
|
|
730
|
+
text: 'Delete',
|
|
731
|
+
onclick: async () => {
|
|
732
|
+
closeModal();
|
|
733
|
+
try {
|
|
734
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete`, {
|
|
735
|
+
method: 'POST',
|
|
736
|
+
body: JSON.stringify({ file, alsoDelete: [...cascade] }),
|
|
737
|
+
});
|
|
738
|
+
if (state.selected === file || cascade.has(state.selected)) state.selected = null;
|
|
739
|
+
await openStore(state.storeId, { keepTab: true });
|
|
740
|
+
const count = result.record.files.length;
|
|
741
|
+
toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
|
|
742
|
+
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
743
|
+
});
|
|
744
|
+
} catch (err) {
|
|
745
|
+
toast(err.message, { error: true });
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
});
|
|
594
749
|
|
|
595
|
-
const modal = node('div', { class:
|
|
596
|
-
node('header', {}, [
|
|
597
|
-
node('h3', { text: `Delete "${preview.name || preview.file}"?` }),
|
|
598
|
-
node('p', { class:
|
|
750
|
+
const modal = node('div', { class: ui.modal }, [
|
|
751
|
+
node('header', { class: ui.modalHead }, [
|
|
752
|
+
node('h3', { class: ui.modalTitle, text: `Delete "${preview.name || preview.file}"?` }),
|
|
753
|
+
node('p', { class: ui.note, text: preview.description || '' }),
|
|
599
754
|
]),
|
|
600
755
|
body,
|
|
601
|
-
node('footer', {}, [
|
|
602
|
-
node('button', { class:
|
|
756
|
+
node('footer', { class: ui.modalFoot }, [
|
|
757
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
758
|
+
confirmButton,
|
|
759
|
+
]),
|
|
760
|
+
]);
|
|
761
|
+
|
|
762
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
763
|
+
el('modal-root').append(backdrop);
|
|
764
|
+
updateButton();
|
|
765
|
+
|
|
766
|
+
function updateButton() {
|
|
767
|
+
confirmButton.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function openRememberPathDialog() {
|
|
772
|
+
const store = state.store;
|
|
773
|
+
const input = node('input', {
|
|
774
|
+
type: 'text',
|
|
775
|
+
class: ui.pathInput,
|
|
776
|
+
value: store.guess || '',
|
|
777
|
+
placeholder: '/Users/you/repos/the-project',
|
|
778
|
+
spellcheck: 'false',
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
const body = node('div', { class: ui.modalBody }, [
|
|
782
|
+
node('p', { class: ui.note, text: `The folder on disk is "${store.slug}", and its name is a lossy encoding of a path. No session transcript remains to recover the real one from, so tell it once and it will be used from now on.` }),
|
|
783
|
+
input,
|
|
784
|
+
node('p', { class: ui.noteTight, text: 'Saved to ~/.claude-memory-admin/paths.json. That file records slugs and folder paths only, never memory content, and is the one thing this app writes outside a memory/ directory.' }),
|
|
785
|
+
]);
|
|
786
|
+
|
|
787
|
+
const modal = node('div', { class: ui.modal }, [
|
|
788
|
+
node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Remember this project\u2019s path' })]),
|
|
789
|
+
body,
|
|
790
|
+
node('footer', { class: ui.modalFoot }, [
|
|
791
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
603
792
|
node('button', {
|
|
604
|
-
class: '
|
|
605
|
-
text: '
|
|
793
|
+
class: ui.button({ tone: 'primary' }),
|
|
794
|
+
text: 'Remember',
|
|
606
795
|
onclick: async () => {
|
|
607
|
-
|
|
796
|
+
const value = input.value.trim();
|
|
797
|
+
if (!value) return;
|
|
608
798
|
try {
|
|
609
|
-
|
|
799
|
+
await api(`/api/stores/${encodeURIComponent(state.storeId)}/path/remember`, {
|
|
610
800
|
method: 'POST',
|
|
611
|
-
body: JSON.stringify({
|
|
612
|
-
});
|
|
613
|
-
if (state.selected === file || cascade.has(state.selected)) state.selected = null;
|
|
614
|
-
await openProject(state.slug, { keepTab: true });
|
|
615
|
-
const count = result.record.files.length;
|
|
616
|
-
toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
|
|
617
|
-
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
801
|
+
body: JSON.stringify({ path: value }),
|
|
618
802
|
});
|
|
619
803
|
} catch (err) {
|
|
620
|
-
toast(err.message, { error: true });
|
|
804
|
+
return toast(err.message, { error: true });
|
|
621
805
|
}
|
|
806
|
+
closeModal();
|
|
807
|
+
await reloadStores();
|
|
808
|
+
await openStore(state.storeId, { keepTab: true });
|
|
809
|
+
toast(`Remembered ${value}`);
|
|
622
810
|
},
|
|
623
811
|
}),
|
|
624
812
|
]),
|
|
625
813
|
]);
|
|
626
814
|
|
|
627
|
-
const backdrop = node('div', { class:
|
|
815
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
628
816
|
el('modal-root').append(backdrop);
|
|
629
|
-
|
|
817
|
+
input.focus();
|
|
818
|
+
input.select();
|
|
819
|
+
}
|
|
630
820
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
821
|
+
async function forgetProjectPath() {
|
|
822
|
+
try {
|
|
823
|
+
await api(`/api/stores/${encodeURIComponent(state.storeId)}/path/forget`, { method: 'POST' });
|
|
824
|
+
} catch (err) {
|
|
825
|
+
return toast(err.message, { error: true });
|
|
634
826
|
}
|
|
827
|
+
await reloadStores();
|
|
828
|
+
await openStore(state.storeId, { keepTab: true });
|
|
829
|
+
toast('Forgot the remembered path');
|
|
635
830
|
}
|
|
636
831
|
|
|
637
|
-
async function
|
|
832
|
+
async function openStoreDeleteDialog() {
|
|
638
833
|
let preview;
|
|
639
834
|
try {
|
|
640
|
-
preview = await api(`/api/
|
|
835
|
+
preview = await api(`/api/stores/${encodeURIComponent(state.storeId)}/project/delete-preview`, { method: 'POST' });
|
|
641
836
|
} catch (err) {
|
|
642
837
|
return toast(err.message, { error: true });
|
|
643
838
|
}
|
|
644
839
|
|
|
645
840
|
if (!preview.files.length && !preview.hasIndex) {
|
|
646
|
-
return toast('This
|
|
841
|
+
return toast('This store has no memory to delete');
|
|
647
842
|
}
|
|
648
843
|
|
|
649
|
-
const body = node('div', { class:
|
|
650
|
-
const removals = node('div', { class:
|
|
844
|
+
const body = node('div', { class: ui.modalBody });
|
|
845
|
+
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
|
|
651
846
|
if (preview.hasIndex) {
|
|
652
|
-
removals.append(node('div', { class: '
|
|
847
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md (${preview.indexLines} lines) → memory/.trash/` }));
|
|
653
848
|
}
|
|
654
849
|
for (const entry of preview.files) {
|
|
655
|
-
removals.append(node('div', { class: '
|
|
850
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `${entry.file} → memory/.trash/` }));
|
|
656
851
|
}
|
|
657
852
|
body.append(removals);
|
|
658
853
|
|
|
659
|
-
body.append(node('div', { class:
|
|
660
|
-
node('div', { class:
|
|
661
|
-
node('div', { class: '
|
|
854
|
+
body.append(node('div', { class: ui.willBlock }, [
|
|
855
|
+
node('div', { class: ui.willTitle, text: 'Will be kept' }),
|
|
856
|
+
node('div', { class: ui.willItem('keep'), text: state.store.kind === 'auto'
|
|
857
|
+
? 'Session transcripts (*.jsonl) and the project folder itself'
|
|
858
|
+
: 'The agent-memory folder itself, and every other agent\u2019s memory' }),
|
|
662
859
|
]));
|
|
663
860
|
|
|
664
|
-
body.append(node('p', { class:
|
|
861
|
+
body.append(node('p', { class: ui.noteTight, text: 'Everything moves to .trash/ inside the store as one restore point, so it can be put back in a single step from the Trash tab.' }));
|
|
862
|
+
|
|
863
|
+
if (state.store.kind === 'agent-project') {
|
|
864
|
+
body.append(node('p', { class: ui.subWarn, text: 'This store is checked into the repository. Deleting from it changes tracked files, and will show up in git status.' }));
|
|
865
|
+
}
|
|
665
866
|
|
|
666
|
-
const modal = node('div', { class:
|
|
667
|
-
node('header', {}, [
|
|
668
|
-
node('h3', { text: `Delete all memory for ${state.
|
|
669
|
-
node('p', { class:
|
|
867
|
+
const modal = node('div', { class: ui.modal }, [
|
|
868
|
+
node('header', { class: ui.modalHead }, [
|
|
869
|
+
node('h3', { class: ui.modalTitle, text: `Delete all memory for ${state.store.label}?` }),
|
|
870
|
+
node('p', { class: ui.note, text: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}` }),
|
|
670
871
|
]),
|
|
671
872
|
body,
|
|
672
|
-
node('footer', {}, [
|
|
673
|
-
node('button', { class:
|
|
873
|
+
node('footer', { class: ui.modalFoot }, [
|
|
874
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
674
875
|
node('button', {
|
|
675
|
-
class: '
|
|
876
|
+
class: ui.button({ tone: 'danger' }),
|
|
676
877
|
text: `Delete ${preview.files.length + (preview.hasIndex ? 1 : 0)} files`,
|
|
677
878
|
onclick: async () => {
|
|
678
879
|
closeModal();
|
|
679
880
|
try {
|
|
680
|
-
const result = await api(`/api/
|
|
881
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/project/delete`, { method: 'POST' });
|
|
681
882
|
state.selected = null;
|
|
682
|
-
await
|
|
683
|
-
toast(`Cleared ${state.
|
|
883
|
+
await openStore(state.storeId, { keepTab: true });
|
|
884
|
+
toast(`Cleared ${state.store.label}`, {
|
|
684
885
|
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
685
886
|
});
|
|
686
887
|
} catch (err) {
|
|
@@ -691,13 +892,10 @@ async function openProjectDeleteDialog() {
|
|
|
691
892
|
]),
|
|
692
893
|
]);
|
|
693
894
|
|
|
694
|
-
const backdrop = node('div', { class:
|
|
895
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
695
896
|
el('modal-root').append(backdrop);
|
|
696
897
|
}
|
|
697
898
|
|
|
698
|
-
|
|
699
|
-
/* --------------------------------------------------------------- prune */
|
|
700
|
-
|
|
701
899
|
function highlight(text, terms) {
|
|
702
900
|
const wrapper = node('span');
|
|
703
901
|
if (!terms.length) { wrapper.textContent = text; return wrapper; }
|
|
@@ -705,7 +903,7 @@ function highlight(text, terms) {
|
|
|
705
903
|
let last = 0;
|
|
706
904
|
for (const match of text.matchAll(pattern)) {
|
|
707
905
|
if (match.index > last) wrapper.append(document.createTextNode(text.slice(last, match.index)));
|
|
708
|
-
wrapper.append(node('mark', { text: match[0] }));
|
|
906
|
+
wrapper.append(node('mark', { class: ui.resultMark, text: match[0] }));
|
|
709
907
|
last = match.index + match[0].length;
|
|
710
908
|
}
|
|
711
909
|
if (last < text.length) wrapper.append(document.createTextNode(text.slice(last)));
|
|
@@ -728,9 +926,9 @@ const PRUNE_SORTS = {
|
|
|
728
926
|
};
|
|
729
927
|
|
|
730
928
|
function renderBudget(container) {
|
|
731
|
-
const { index } = state.
|
|
929
|
+
const { index } = state.store.stats;
|
|
732
930
|
const percent = Math.min(100, index.worstPercent);
|
|
733
|
-
const level = index.level
|
|
931
|
+
const level = index.level;
|
|
734
932
|
|
|
735
933
|
const note = index.level === 'over'
|
|
736
934
|
? 'Over the limit. Everything past the cutoff is dropped when the session loads it, so those entries are effectively invisible to Claude right now.'
|
|
@@ -738,73 +936,94 @@ function renderBudget(container) {
|
|
|
738
936
|
? 'Approaching the limit. Once MEMORY.md passes it, everything after the cutoff stops being loaded at all.'
|
|
739
937
|
: 'Comfortably inside the limit. MEMORY.md is loaded at the start of every session.';
|
|
740
938
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
939
|
+
if (index.cutoff) {
|
|
940
|
+
const dropped = index.cutoff.droppedEntries;
|
|
941
|
+
const box = node('div', { class: ui.cutSummary });
|
|
942
|
+
box.append(node('div', { class: ui.cutSummaryHead, text: dropped.length
|
|
943
|
+
? `${dropped.length} ${dropped.length === 1 ? 'memory is' : 'memories are'} past the cutoff and not loaded`
|
|
944
|
+
: `${index.cutoff.droppedLines} lines are past the cutoff and not loaded` }));
|
|
945
|
+
box.append(node('p', { class: ui.note, text: `MEMORY.md stops loading at line ${index.cutoff.rawLine + 1}, bounded by ${index.cutoff.by}. Claude cannot see anything below it.` }));
|
|
946
|
+
for (const entry of dropped.slice(0, 20)) {
|
|
947
|
+
box.append(node('div', { class: ui.issue(true) }, [
|
|
948
|
+
node('div', { class: ui.issueBody }, [
|
|
949
|
+
node('div', { class: ui.issueTitle, text: entry.title || entry.file }),
|
|
950
|
+
node('div', { class: ui.issueDetail, text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
|
|
951
|
+
]),
|
|
952
|
+
node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(entry.file) }),
|
|
953
|
+
]));
|
|
954
|
+
}
|
|
955
|
+
if (dropped.length > 20) {
|
|
956
|
+
box.append(node('p', { class: ui.note, text: `${dropped.length - 20} more not listed.` }));
|
|
957
|
+
}
|
|
958
|
+
container.append(box);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
container.append(node('div', { class: ui.meter }, [
|
|
962
|
+
node('div', { class: ui.meterTop }, [
|
|
963
|
+
node('span', { class: ui.meterValue, text: `${Math.round(index.worstPercent)}%` }),
|
|
964
|
+
node('span', { class: ui.meterUnit, text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
|
|
745
965
|
]),
|
|
746
|
-
node('div', { class:
|
|
747
|
-
node('div', { class:
|
|
966
|
+
node('div', { class: ui.meterBar }, [
|
|
967
|
+
node('div', { class: ui.meterFill(level), style: `width:${percent.toFixed(1)}%` }),
|
|
748
968
|
]),
|
|
749
|
-
node('p', { class:
|
|
750
|
-
node('div', { class:
|
|
751
|
-
node('span', {}, [node('b', { text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
|
|
752
|
-
node('span', {}, [node('b', { text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
|
|
753
|
-
node('span', {}, [node('b', { text: String(index.entryCount) }), document.createTextNode(' index entries')]),
|
|
754
|
-
node('span', {}, [node('b', { text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
|
|
969
|
+
node('p', { class: ui.meterNote, text: note }),
|
|
970
|
+
node('div', { class: ui.meterFacts }, [
|
|
971
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
|
|
972
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
|
|
973
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(index.entryCount) }), document.createTextNode(' index entries')]),
|
|
974
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
|
|
755
975
|
]),
|
|
756
|
-
node('p', { class:
|
|
976
|
+
node('p', { class: ui.meterNote, text: 'Claude Code loads the first 200 lines or 25KB of MEMORY.md, whichever comes first, and drops the rest. Topic files are only read when Claude follows a link, so detail belongs in them and MEMORY.md should stay one line per entry.' }),
|
|
757
977
|
]));
|
|
758
978
|
}
|
|
759
979
|
|
|
760
980
|
function renderPrune(container) {
|
|
761
|
-
const project = state.
|
|
981
|
+
const project = state.store;
|
|
762
982
|
renderBudget(container);
|
|
763
983
|
|
|
764
984
|
if (!project.memories.length) {
|
|
765
|
-
container.append(node('p', { class:
|
|
985
|
+
container.append(node('p', { class: ui.note, text: 'Nothing to prune - this project has no memories.' }));
|
|
766
986
|
return;
|
|
767
987
|
}
|
|
768
988
|
|
|
769
|
-
// --- bulk selection list ---
|
|
770
989
|
const selected = state.pruneSelection;
|
|
771
|
-
const bar = node('div', { class:
|
|
772
|
-
const sort = node('select');
|
|
990
|
+
const bar = node('div', { class: ui.pruneBar });
|
|
991
|
+
const sort = node('select', { class: ui.select });
|
|
773
992
|
for (const [key, config] of Object.entries(PRUNE_SORTS)) {
|
|
774
993
|
sort.append(node('option', { value: key, text: config.label, selected: key === state.pruneSort ? '' : null }));
|
|
775
994
|
}
|
|
776
995
|
sort.value = state.pruneSort;
|
|
777
996
|
sort.onchange = () => { state.pruneSort = sort.value; renderTab(); };
|
|
778
997
|
|
|
779
|
-
const count = node('span', { class:
|
|
780
|
-
const deleteButton = node('button', { class:
|
|
781
|
-
const clearButton = node('button', { class:
|
|
998
|
+
const count = node('span', { class: ui.note });
|
|
999
|
+
const deleteButton = node('button', { class: ui.buttonDangerSmall });
|
|
1000
|
+
const clearButton = node('button', { class: ui.buttonSmall, text: 'Clear selection' });
|
|
782
1001
|
|
|
783
|
-
bar.append(node('span', { class:
|
|
1002
|
+
bar.append(node('span', { class: ui.sectionLabel, text: 'Prune' }), sort, node('span', { class: ui.pruneSpacer }), count, clearButton, deleteButton);
|
|
784
1003
|
container.append(bar);
|
|
785
1004
|
|
|
786
1005
|
const list = node('div');
|
|
787
1006
|
const ordered = [...project.memories].sort(PRUNE_SORTS[state.pruneSort].compare);
|
|
788
1007
|
const rows = [];
|
|
789
1008
|
for (const memory of ordered) {
|
|
790
|
-
const box = node('input', { type: 'checkbox' });
|
|
1009
|
+
const box = node('input', { type: 'checkbox', class: ui.checkbox });
|
|
791
1010
|
box.checked = selected.has(memory.file);
|
|
792
|
-
const row = node('label', { class:
|
|
1011
|
+
const row = node('label', { class: ui.pruneRow(box.checked) }, [
|
|
793
1012
|
box,
|
|
794
|
-
node('span', { class:
|
|
795
|
-
node('span', { class:
|
|
796
|
-
node('span', { class:
|
|
1013
|
+
node('span', { class: ui.pruneMain }, [
|
|
1014
|
+
node('span', { class: ui.pruneName, text: memory.name }),
|
|
1015
|
+
node('span', { class: ui.pruneDesc, text: memory.description || memory.file }),
|
|
797
1016
|
]),
|
|
798
|
-
node('span', { class:
|
|
799
|
-
node('span', { class: (memory.ageDays ?? 0) > 180
|
|
1017
|
+
node('span', { class: ui.pruneFacts }, [
|
|
1018
|
+
node('span', { class: ui.pruneFact((memory.ageDays ?? 0) > 180), text: formatAge(memory.ageDays) }),
|
|
800
1019
|
node('span', { text: `${(memory.bytes / 1024).toFixed(1)}K` }),
|
|
801
|
-
node('span', { class: memory.inbound.length
|
|
802
|
-
node('span', { class: memory.status
|
|
1020
|
+
node('span', { class: ui.pruneFact(!memory.inbound.length), text: `${memory.inbound.length} in` }),
|
|
1021
|
+
node('span', { class: ui.pruneFact(memory.status !== 'indexed'), text: memory.status }),
|
|
803
1022
|
]),
|
|
804
1023
|
]);
|
|
805
1024
|
box.addEventListener('change', () => {
|
|
806
1025
|
if (box.checked) selected.add(memory.file); else selected.delete(memory.file);
|
|
807
|
-
row.
|
|
1026
|
+
row.className = ui.pruneRow(box.checked);
|
|
808
1027
|
updateBar();
|
|
809
1028
|
});
|
|
810
1029
|
rows.push({ box, memory });
|
|
@@ -816,7 +1035,6 @@ function renderPrune(container) {
|
|
|
816
1035
|
count.textContent = selected.size ? `${selected.size} selected` : '';
|
|
817
1036
|
deleteButton.textContent = `Delete ${selected.size || ''}`.trim();
|
|
818
1037
|
deleteButton.disabled = selected.size === 0;
|
|
819
|
-
deleteButton.style.opacity = selected.size ? '1' : '.45';
|
|
820
1038
|
}
|
|
821
1039
|
clearButton.onclick = () => {
|
|
822
1040
|
selected.clear();
|
|
@@ -825,39 +1043,37 @@ function renderPrune(container) {
|
|
|
825
1043
|
deleteButton.onclick = () => openBulkDeleteDialog([...selected]);
|
|
826
1044
|
updateBar();
|
|
827
1045
|
|
|
828
|
-
// --- long hooks ---
|
|
829
1046
|
const { longHooks } = project.stats.index;
|
|
830
1047
|
if (longHooks.length) {
|
|
831
|
-
container.append(node('div', { class:
|
|
832
|
-
container.append(node('p', { class:
|
|
1048
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Long index hooks · ${longHooks.length}` }));
|
|
1049
|
+
container.append(node('p', { class: ui.noteTight, text: 'The hook is the text after the dash in MEMORY.md. It is loaded every session, so a 400-character hook costs more than the memory it points at. Shortening these is the cheapest win.' }));
|
|
833
1050
|
for (const hook of longHooks.slice(0, 12)) {
|
|
834
|
-
container.append(node('div', { class:
|
|
835
|
-
node('div', { class:
|
|
836
|
-
node('div', { class:
|
|
837
|
-
node('div', { class:
|
|
1051
|
+
container.append(node('div', { class: ui.issue(false) }, [
|
|
1052
|
+
node('div', { class: ui.issueBody }, [
|
|
1053
|
+
node('div', { class: ui.issueTitle, text: `${hook.hookLength} chars: ${hook.title}` }),
|
|
1054
|
+
node('div', { class: ui.issueDetail, text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
|
|
838
1055
|
]),
|
|
839
|
-
node('button', { class:
|
|
1056
|
+
node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(hook.file) }),
|
|
840
1057
|
]));
|
|
841
1058
|
}
|
|
842
1059
|
}
|
|
843
1060
|
|
|
844
|
-
// --- possible overlap ---
|
|
845
1061
|
if (project.duplicates.length) {
|
|
846
|
-
container.append(node('div', { class:
|
|
847
|
-
container.append(node('p', { class:
|
|
1062
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Possible overlap · ${project.duplicates.length}` }));
|
|
1063
|
+
container.append(node('p', { class: ui.noteTight, text: 'Ranked by how much rare vocabulary these pairs share. This is a hint, not a verdict - open both and decide whether one covers the other.' }));
|
|
848
1064
|
for (const pair of project.duplicates) {
|
|
849
|
-
container.append(node('div', { class:
|
|
850
|
-
node('div', { class:
|
|
851
|
-
node('span', { class:
|
|
1065
|
+
container.append(node('div', { class: ui.dupe }, [
|
|
1066
|
+
node('div', { class: ui.dupeHead }, [
|
|
1067
|
+
node('span', { class: ui.dupeScore, text: `${pair.score}%` }),
|
|
852
1068
|
node('span', { text: `shared: ${pair.shared.join(', ')}` }),
|
|
853
1069
|
]),
|
|
854
|
-
node('div', { class:
|
|
1070
|
+
node('div', { class: ui.dupePair }, [
|
|
855
1071
|
...[pair.a, pair.b].map((side) => node('div', {
|
|
856
|
-
class:
|
|
1072
|
+
class: ui.dupeSide,
|
|
857
1073
|
onclick: () => selectMemory(side.file),
|
|
858
1074
|
}, [
|
|
859
|
-
node('div', { class:
|
|
860
|
-
node('div', { class:
|
|
1075
|
+
node('div', { class: ui.dupeSideName, text: side.name }),
|
|
1076
|
+
node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
|
|
861
1077
|
])),
|
|
862
1078
|
]),
|
|
863
1079
|
]));
|
|
@@ -867,16 +1083,15 @@ function renderPrune(container) {
|
|
|
867
1083
|
|
|
868
1084
|
async function openBulkDeleteDialog(files) {
|
|
869
1085
|
if (!files.length) return;
|
|
870
|
-
const memories = files.map((file) => state.
|
|
1086
|
+
const memories = files.map((file) => state.store.memories.find((m) => m.file === file)).filter(Boolean);
|
|
871
1087
|
|
|
872
|
-
const body = node('div', { class:
|
|
873
|
-
const removals = node('div', { class:
|
|
1088
|
+
const body = node('div', { class: ui.modalBody });
|
|
1089
|
+
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${memories.length} memories will be trashed` })]);
|
|
874
1090
|
for (const memory of memories) {
|
|
875
|
-
removals.append(node('div', { class: '
|
|
1091
|
+
removals.append(node('div', { class: ui.willItem('remove'), text: `${memory.file}${memory.entry ? ` · MEMORY.md line ${memory.entry.index + 1}` : ' · not in the index'}` }));
|
|
876
1092
|
}
|
|
877
1093
|
body.append(removals);
|
|
878
1094
|
|
|
879
|
-
// Links from memories that are NOT being deleted are the ones that break.
|
|
880
1095
|
const doomed = new Set(files);
|
|
881
1096
|
const breaking = [];
|
|
882
1097
|
for (const memory of memories) {
|
|
@@ -885,31 +1100,31 @@ async function openBulkDeleteDialog(files) {
|
|
|
885
1100
|
}
|
|
886
1101
|
}
|
|
887
1102
|
if (breaking.length) {
|
|
888
|
-
const block = node('div', { class:
|
|
889
|
-
for (const link of breaking) block.append(node('div', { class: '
|
|
1103
|
+
const block = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${breaking.length} link(s) from memories you are keeping will break` })]);
|
|
1104
|
+
for (const link of breaking) block.append(node('div', { class: ui.willItem('keep'), text: `${link.from} → [[${link.target}]]` }));
|
|
890
1105
|
body.append(block);
|
|
891
1106
|
}
|
|
892
1107
|
|
|
893
|
-
body.append(node('p', { class:
|
|
1108
|
+
body.append(node('p', { class: ui.noteTight, text: 'All of them go to memory/.trash/ as one restore point.' }));
|
|
894
1109
|
|
|
895
|
-
const modal = node('div', { class:
|
|
896
|
-
node('header', {}, [node('h3', { text: `Delete ${memories.length} memories?` })]),
|
|
1110
|
+
const modal = node('div', { class: ui.modal }, [
|
|
1111
|
+
node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Delete ${memories.length} memories?` })]),
|
|
897
1112
|
body,
|
|
898
|
-
node('footer', {}, [
|
|
899
|
-
node('button', { class:
|
|
1113
|
+
node('footer', { class: ui.modalFoot }, [
|
|
1114
|
+
node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
|
|
900
1115
|
node('button', {
|
|
901
|
-
class: '
|
|
1116
|
+
class: ui.button({ tone: 'danger' }),
|
|
902
1117
|
text: `Delete ${memories.length}`,
|
|
903
1118
|
onclick: async () => {
|
|
904
1119
|
closeModal();
|
|
905
1120
|
try {
|
|
906
|
-
const result = await api(`/api/
|
|
1121
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete-many`, {
|
|
907
1122
|
method: 'POST',
|
|
908
1123
|
body: JSON.stringify({ files, label: `${memories.length} pruned memories` }),
|
|
909
1124
|
});
|
|
910
1125
|
state.pruneSelection.clear();
|
|
911
1126
|
if (doomed.has(state.selected)) state.selected = null;
|
|
912
|
-
await
|
|
1127
|
+
await openStore(state.storeId, { keepTab: true });
|
|
913
1128
|
toast(`Deleted ${memories.length} memories`, {
|
|
914
1129
|
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
915
1130
|
});
|
|
@@ -920,12 +1135,10 @@ async function openBulkDeleteDialog(files) {
|
|
|
920
1135
|
}),
|
|
921
1136
|
]),
|
|
922
1137
|
]);
|
|
923
|
-
const backdrop = node('div', { class:
|
|
1138
|
+
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
924
1139
|
el('modal-root').append(backdrop);
|
|
925
1140
|
}
|
|
926
1141
|
|
|
927
|
-
/* -------------------------------------------------------------- search */
|
|
928
|
-
|
|
929
1142
|
let searchTimer = null;
|
|
930
1143
|
|
|
931
1144
|
function scheduleSearch(value) {
|
|
@@ -958,43 +1171,45 @@ function renderSearch() {
|
|
|
958
1171
|
? `${data.total} result${data.total === 1 ? '' : 's'} for "${state.query.trim()}"`
|
|
959
1172
|
: `No results for "${state.query.trim()}"`;
|
|
960
1173
|
|
|
961
|
-
if (!data.total && !data.
|
|
962
|
-
host.append(node('p', { class:
|
|
1174
|
+
if (!data.total && !data.stores.length) {
|
|
1175
|
+
host.append(node('p', { class: ui.note, text: 'Nothing matched. Every word has to appear somewhere in a memory, so try fewer words.' }));
|
|
963
1176
|
return;
|
|
964
1177
|
}
|
|
965
1178
|
|
|
966
|
-
for (const group of data.
|
|
967
|
-
const block = node('div', { class:
|
|
968
|
-
block.append(node('div', { class:
|
|
1179
|
+
for (const group of data.stores) {
|
|
1180
|
+
const block = node('div', { class: ui.resultGroup });
|
|
1181
|
+
block.append(node('div', { class: ui.sectionLabel, text: group.sublabel
|
|
1182
|
+
? `${group.label} · ${group.sublabel} · ${group.results.length}`
|
|
1183
|
+
: `${group.label} · ${group.results.length}` }));
|
|
969
1184
|
|
|
970
1185
|
for (const result of group.results) {
|
|
971
1186
|
const button = node('button', {
|
|
972
|
-
class:
|
|
973
|
-
onclick: () => openFromSearch(group.
|
|
1187
|
+
class: ui.result,
|
|
1188
|
+
onclick: () => openFromSearch(group.id, result.file),
|
|
974
1189
|
});
|
|
975
|
-
const top = node('div', { class:
|
|
976
|
-
const name = node('span', { class:
|
|
1190
|
+
const top = node('div', { class: ui.resultTop });
|
|
1191
|
+
const name = node('span', { class: ui.resultName });
|
|
977
1192
|
name.append(highlight(result.name, data.terms));
|
|
978
|
-
top.append(name, node('span', { class:
|
|
979
|
-
if (result.status !== 'indexed') top.append(node('span', { class:
|
|
1193
|
+
top.append(name, node('span', { class: ui.typeBadge(result.type), text: result.type }));
|
|
1194
|
+
if (result.status !== 'indexed') top.append(node('span', { class: ui.badge('warn'), text: result.status }));
|
|
980
1195
|
button.append(top);
|
|
981
1196
|
|
|
982
1197
|
if (result.snippet) {
|
|
983
|
-
const snippet = node('div', { class:
|
|
1198
|
+
const snippet = node('div', { class: ui.resultSnippet });
|
|
984
1199
|
snippet.append(highlight(result.snippet.text, data.terms));
|
|
985
1200
|
button.append(snippet);
|
|
986
1201
|
}
|
|
987
|
-
button.append(node('div', { class:
|
|
1202
|
+
button.append(node('div', { class: ui.resultWhere, text: `${result.file} · matched in ${result.fields.join(', ')}` }));
|
|
988
1203
|
block.append(button);
|
|
989
1204
|
}
|
|
990
1205
|
|
|
991
1206
|
for (const hit of group.indexHits) {
|
|
992
1207
|
const button = node('button', {
|
|
993
|
-
class:
|
|
994
|
-
onclick: () => openFromSearch(group.
|
|
1208
|
+
class: ui.result,
|
|
1209
|
+
onclick: () => openFromSearch(group.id, hit.file, 'index'),
|
|
995
1210
|
});
|
|
996
|
-
button.append(node('div', { class:
|
|
997
|
-
const line = node('div', { class:
|
|
1211
|
+
button.append(node('div', { class: ui.resultWhere, text: `MEMORY.md line ${hit.index + 1}` }));
|
|
1212
|
+
const line = node('div', { class: ui.resultSnippet });
|
|
998
1213
|
line.append(highlight(hit.text.trim(), data.terms));
|
|
999
1214
|
button.append(line);
|
|
1000
1215
|
block.append(button);
|
|
@@ -1003,9 +1218,9 @@ function renderSearch() {
|
|
|
1003
1218
|
}
|
|
1004
1219
|
}
|
|
1005
1220
|
|
|
1006
|
-
async function openFromSearch(
|
|
1221
|
+
async function openFromSearch(id, file, tab = 'memories') {
|
|
1007
1222
|
clearSearch();
|
|
1008
|
-
if (
|
|
1223
|
+
if (id !== state.storeId) await openStore(id);
|
|
1009
1224
|
state.tab = tab;
|
|
1010
1225
|
if (file) state.selected = file;
|
|
1011
1226
|
renderTabs();
|
|
@@ -1022,25 +1237,24 @@ function clearSearch() {
|
|
|
1022
1237
|
function renderView() {
|
|
1023
1238
|
const searching = state.search !== null;
|
|
1024
1239
|
el('search-view').hidden = !searching;
|
|
1025
|
-
el('project-view').hidden = searching || !state.
|
|
1026
|
-
el('empty-state').hidden = searching || Boolean(state.
|
|
1240
|
+
el('project-view').hidden = searching || !state.store;
|
|
1241
|
+
el('empty-state').hidden = searching || Boolean(state.store);
|
|
1027
1242
|
if (searching) renderSearch();
|
|
1028
1243
|
}
|
|
1029
1244
|
|
|
1030
|
-
/* ------------------------------------------------------------ rendering */
|
|
1031
|
-
|
|
1032
1245
|
function renderTab() {
|
|
1033
1246
|
const container = el('tab-content');
|
|
1034
1247
|
container.textContent = '';
|
|
1035
1248
|
if (state.tab === 'memories') renderMemories(container);
|
|
1036
1249
|
else if (state.tab === 'prune') renderPrune(container);
|
|
1250
|
+
else if (state.tab === 'context') renderContext(container);
|
|
1037
1251
|
else if (state.tab === 'index') renderIndex(container);
|
|
1038
1252
|
else if (state.tab === 'health') renderHealth(container);
|
|
1039
1253
|
else if (state.tab === 'trash') renderTrash(container);
|
|
1040
1254
|
else if (state.tab === 'graph') {
|
|
1041
|
-
const wrap = node('div', { id: 'graph-wrap' });
|
|
1255
|
+
const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
|
|
1042
1256
|
container.append(wrap);
|
|
1043
|
-
renderGraph(wrap, state.
|
|
1257
|
+
renderGraph(wrap, state.store.graph, {
|
|
1044
1258
|
selected: state.selected,
|
|
1045
1259
|
spread: state.spread,
|
|
1046
1260
|
onSelect: (file) => selectMemory(file),
|
|
@@ -1062,37 +1276,83 @@ function selectMemory(file) {
|
|
|
1062
1276
|
} else {
|
|
1063
1277
|
renderTab();
|
|
1064
1278
|
}
|
|
1065
|
-
document.querySelector('
|
|
1279
|
+
document.querySelector('[data-memory][data-active]')?.scrollIntoView({ block: 'nearest' });
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
async function reloadStores() {
|
|
1283
|
+
const data = await api('/api/stores');
|
|
1284
|
+
state.stores = data.stores;
|
|
1285
|
+
renderStores();
|
|
1286
|
+
return data;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function renderStoreHeader() {
|
|
1290
|
+
const store = state.store;
|
|
1291
|
+
el('project-title').textContent = store.label;
|
|
1292
|
+
|
|
1293
|
+
const sub = el('project-sub');
|
|
1294
|
+
sub.textContent = '';
|
|
1295
|
+
|
|
1296
|
+
if (store.kind !== 'auto') {
|
|
1297
|
+
const scope = store.kind === 'agent-user' ? 'user'
|
|
1298
|
+
: store.kind === 'agent-project' ? 'project' : 'local';
|
|
1299
|
+
sub.append(node('span', { text: store.dir }));
|
|
1300
|
+
sub.append(node('span', { class: ui.subNote, text: `subagent memory, ${scope} scope` }));
|
|
1301
|
+
if (store.kind === 'agent-project') {
|
|
1302
|
+
sub.append(node('span', { class: ui.subWarn, text: 'checked into the repository - changes here show up in git status' }));
|
|
1303
|
+
}
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
sub.append(node('span', { text: store.resolvedBy === 'unresolved'
|
|
1308
|
+
? `${store.path} (real path unresolved)`
|
|
1309
|
+
: store.path }));
|
|
1310
|
+
|
|
1311
|
+
const others = (store.workingDirs || []).filter((d) => d !== store.path);
|
|
1312
|
+
if (others.length) {
|
|
1313
|
+
sub.append(node('span', { class: ui.subNote, text: `also used from ${others.join(', ')}` }));
|
|
1314
|
+
}
|
|
1315
|
+
if (store.resolvedBy === 'unresolved') {
|
|
1316
|
+
sub.append(node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }));
|
|
1317
|
+
} else if (store.resolvedBy === 'remembered') {
|
|
1318
|
+
sub.append(node('span', { class: ui.subNote }, [
|
|
1319
|
+
document.createTextNode('path remembered, not recovered from a transcript '),
|
|
1320
|
+
node('button', { class: ui.linkButton, text: 'Forget', onclick: forgetProjectPath }),
|
|
1321
|
+
]));
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
const auto = store.autoMemory;
|
|
1325
|
+
if (auto && auto.known && !auto.enabled) {
|
|
1326
|
+
sub.append(node('span', { class: ui.subWarn, text: auto.scope === 'env'
|
|
1327
|
+
? 'auto memory is off (CLAUDE_CODE_DISABLE_AUTO_MEMORY) - this store will not grow'
|
|
1328
|
+
: `auto memory is off (${auto.setBy}) - this store will not grow` }));
|
|
1329
|
+
}
|
|
1066
1330
|
}
|
|
1067
1331
|
|
|
1068
|
-
async function
|
|
1069
|
-
if (
|
|
1070
|
-
state.
|
|
1332
|
+
async function openStore(id, { keepTab = false } = {}) {
|
|
1333
|
+
if (id !== state.storeId) state.pruneSelection.clear();
|
|
1334
|
+
state.storeId = id;
|
|
1071
1335
|
if (!keepTab) {
|
|
1072
1336
|
state.tab = 'memories';
|
|
1073
1337
|
state.selected = null;
|
|
1074
1338
|
}
|
|
1075
|
-
|
|
1339
|
+
renderStores();
|
|
1076
1340
|
|
|
1077
1341
|
try {
|
|
1078
|
-
state.
|
|
1342
|
+
state.store = await api(`/api/stores/${encodeURIComponent(id)}`);
|
|
1079
1343
|
} catch (err) {
|
|
1080
1344
|
return toast(err.message, { error: true });
|
|
1081
1345
|
}
|
|
1082
1346
|
|
|
1083
|
-
if (state.selected && !state.
|
|
1347
|
+
if (state.selected && !state.store.memories.some((m) => m.file === state.selected)) {
|
|
1084
1348
|
state.selected = null;
|
|
1085
1349
|
}
|
|
1086
1350
|
|
|
1087
|
-
|
|
1088
|
-
el('project-sub').textContent = state.project.resolvedBy === 'unresolved'
|
|
1089
|
-
? `${state.project.path} (real path unresolved)`
|
|
1090
|
-
: state.project.path;
|
|
1351
|
+
renderStoreHeader();
|
|
1091
1352
|
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
renderProjects();
|
|
1353
|
+
const listed = state.stores.find((store) => store.id === id);
|
|
1354
|
+
if (listed) listed.memoryCount = state.store.memories.length;
|
|
1355
|
+
renderStores();
|
|
1096
1356
|
|
|
1097
1357
|
renderView();
|
|
1098
1358
|
renderTabs();
|
|
@@ -1100,7 +1360,8 @@ async function openProject(slug, { keepTab = false } = {}) {
|
|
|
1100
1360
|
}
|
|
1101
1361
|
|
|
1102
1362
|
function applyCollapsed() {
|
|
1103
|
-
el('app').
|
|
1363
|
+
el('app').className = state.collapsed ? ui.shellCollapsed : ui.shell;
|
|
1364
|
+
el('sidebar').className = state.collapsed ? ui.sidebarHidden : ui.sidebar;
|
|
1104
1365
|
el('expand').hidden = !state.collapsed;
|
|
1105
1366
|
localStorage.setItem('sidebarCollapsed', state.collapsed ? '1' : '0');
|
|
1106
1367
|
}
|
|
@@ -1108,22 +1369,27 @@ function applyCollapsed() {
|
|
|
1108
1369
|
function setCollapsed(value) {
|
|
1109
1370
|
state.collapsed = value;
|
|
1110
1371
|
applyCollapsed();
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1372
|
+
if (state.tab === 'graph' && state.store) requestAnimationFrame(renderTab);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function applyStyles() {
|
|
1376
|
+
for (const element of document.querySelectorAll('[data-ui]')) {
|
|
1377
|
+
element.className = ui[element.dataset.ui] ?? '';
|
|
1378
|
+
}
|
|
1114
1379
|
}
|
|
1115
1380
|
|
|
1116
1381
|
async function init() {
|
|
1382
|
+
applyStyles();
|
|
1117
1383
|
el('show-all').addEventListener('change', (event) => {
|
|
1118
1384
|
state.showAll = event.target.checked;
|
|
1119
|
-
|
|
1385
|
+
renderStores();
|
|
1120
1386
|
});
|
|
1121
1387
|
|
|
1122
1388
|
el('search').addEventListener('input', (event) => scheduleSearch(event.target.value));
|
|
1123
1389
|
el('search-clear').addEventListener('click', clearSearch);
|
|
1124
1390
|
el('collapse').addEventListener('click', () => setCollapsed(true));
|
|
1125
1391
|
el('expand').addEventListener('click', () => setCollapsed(false));
|
|
1126
|
-
el('delete-project').addEventListener('click',
|
|
1392
|
+
el('delete-project').addEventListener('click', openStoreDeleteDialog);
|
|
1127
1393
|
document.addEventListener('keydown', (event) => {
|
|
1128
1394
|
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName);
|
|
1129
1395
|
if (event.key === '/' && !typing) {
|
|
@@ -1142,12 +1408,17 @@ async function init() {
|
|
|
1142
1408
|
applyCollapsed();
|
|
1143
1409
|
|
|
1144
1410
|
try {
|
|
1145
|
-
const data = await
|
|
1146
|
-
state.projects = data.projects;
|
|
1411
|
+
const data = await reloadStores();
|
|
1147
1412
|
el('root-path').textContent = data.root;
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1413
|
+
if (data.rootSource && data.rootSource !== 'default') {
|
|
1414
|
+
el('root-path').title = data.rootFile
|
|
1415
|
+
? `autoMemoryDirectory set in ${data.rootFile}`
|
|
1416
|
+
: 'store chosen with --root';
|
|
1417
|
+
el('root-path').append(node('span', { class: ui.rootSource, text: data.rootSource }));
|
|
1418
|
+
}
|
|
1419
|
+
if (data.rootWarning) toast(data.rootWarning, { error: true });
|
|
1420
|
+
const first = state.stores.find((store) => store.memoryCount > 0);
|
|
1421
|
+
if (first) openStore(first.id);
|
|
1151
1422
|
} catch (err) {
|
|
1152
1423
|
toast(err.message, { error: true });
|
|
1153
1424
|
}
|