claude-memory-admin 1.0.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/LICENSE +26 -0
- package/README.md +218 -0
- package/bin/claude-memory-admin.mjs +62 -0
- package/package.json +63 -0
- package/public/app.mjs +1156 -0
- package/public/assets/bitspire-logo.webp +0 -0
- package/public/graph.mjs +518 -0
- package/public/index.html +66 -0
- package/public/styles.css +402 -0
- package/server.mjs +239 -0
- package/src/model.mjs +241 -0
- package/src/mutate.mjs +414 -0
- package/src/parse.mjs +255 -0
- package/src/projects.mjs +190 -0
- package/src/search.mjs +127 -0
- package/src/stats.mjs +203 -0
package/public/app.mjs
ADDED
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
import { marked } from '/vendor/marked.js';
|
|
2
|
+
import DOMPurify from '/vendor/purify.js';
|
|
3
|
+
import { renderGraph } from '/graph.mjs';
|
|
4
|
+
|
|
5
|
+
marked.setOptions({ gfm: true, breaks: false });
|
|
6
|
+
|
|
7
|
+
const state = {
|
|
8
|
+
projects: [],
|
|
9
|
+
slug: null,
|
|
10
|
+
project: null,
|
|
11
|
+
tab: 'memories',
|
|
12
|
+
selected: null,
|
|
13
|
+
showAll: false,
|
|
14
|
+
collapsed: localStorage.getItem('sidebarCollapsed') === '1',
|
|
15
|
+
spread: Number(localStorage.getItem('graphSpread')) || 1.6,
|
|
16
|
+
query: '',
|
|
17
|
+
search: null,
|
|
18
|
+
pruneSort: 'oldest',
|
|
19
|
+
pruneSelection: new Set(),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const el = (id) => document.getElementById(id);
|
|
23
|
+
|
|
24
|
+
/* ---------------------------------------------------------------- helpers */
|
|
25
|
+
|
|
26
|
+
async function api(path, options) {
|
|
27
|
+
const response = await fetch(path, {
|
|
28
|
+
headers: { 'content-type': 'application/json' },
|
|
29
|
+
...options,
|
|
30
|
+
});
|
|
31
|
+
const data = await response.json().catch(() => ({ error: 'Bad response from server' }));
|
|
32
|
+
if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`);
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toast(message, { error = false, action } = {}) {
|
|
37
|
+
const node = document.createElement('div');
|
|
38
|
+
node.className = `toast${error ? ' err' : ''}`;
|
|
39
|
+
node.append(document.createTextNode(message));
|
|
40
|
+
if (action) {
|
|
41
|
+
const button = document.createElement('button');
|
|
42
|
+
button.textContent = action.label;
|
|
43
|
+
button.onclick = () => { node.remove(); action.run(); };
|
|
44
|
+
node.append(button);
|
|
45
|
+
}
|
|
46
|
+
el('toast-root').append(node);
|
|
47
|
+
setTimeout(() => node.remove(), action ? 12000 : 4500);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function node(tag, props = {}, children = []) {
|
|
51
|
+
const element = document.createElement(tag);
|
|
52
|
+
for (const [key, value] of Object.entries(props)) {
|
|
53
|
+
if (key === 'class') element.className = value;
|
|
54
|
+
else if (key === 'text') element.textContent = value;
|
|
55
|
+
else if (key.startsWith('on')) element.addEventListener(key.slice(2).toLowerCase(), value);
|
|
56
|
+
else if (value !== null && value !== undefined) element.setAttribute(key, value);
|
|
57
|
+
}
|
|
58
|
+
for (const child of [].concat(children)) {
|
|
59
|
+
if (child) element.append(child);
|
|
60
|
+
}
|
|
61
|
+
return element;
|
|
62
|
+
}
|
|
63
|
+
|
|
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) {
|
|
70
|
+
const byName = new Map();
|
|
71
|
+
for (const other of project.memories) {
|
|
72
|
+
byName.set(other.name, other.file);
|
|
73
|
+
byName.set(other.stem, other.file);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tokens = [];
|
|
77
|
+
const withPlaceholders = memory.body.replace(/\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_, target, alias) => {
|
|
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
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* --------------------------------------------------------------- sidebar */
|
|
116
|
+
|
|
117
|
+
function renderProjects() {
|
|
118
|
+
const list = el('project-list');
|
|
119
|
+
list.textContent = '';
|
|
120
|
+
|
|
121
|
+
const visible = state.projects.filter((p) => state.showAll || p.hasMemoryDir);
|
|
122
|
+
if (!visible.length) {
|
|
123
|
+
list.append(node('p', { class: 'muted', text: 'No projects found.', style: 'padding:12px' }));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const project of visible) {
|
|
128
|
+
const health = project.memoryCount === 0 ? 'none' : 'ok';
|
|
129
|
+
const button = node('button', {
|
|
130
|
+
class: `project${project.slug === state.slug ? ' active' : ''}${project.hasMemoryDir ? '' : ' dim'}`,
|
|
131
|
+
onclick: () => openProject(project.slug),
|
|
132
|
+
title: project.resolvedBy === 'unresolved'
|
|
133
|
+
? 'Real path could not be resolved - showing the raw folder name'
|
|
134
|
+
: project.path,
|
|
135
|
+
}, [
|
|
136
|
+
node('span', { class: 'project-row' }, [
|
|
137
|
+
node('span', { class: `dot ${health}` }),
|
|
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);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ------------------------------------------------------------------ tabs */
|
|
148
|
+
|
|
149
|
+
const TABS = [
|
|
150
|
+
{ id: 'memories', label: 'Memories' },
|
|
151
|
+
{ id: 'index', label: 'MEMORY.md' },
|
|
152
|
+
{ id: 'graph', label: 'Graph' },
|
|
153
|
+
{ id: 'prune', label: 'Prune' },
|
|
154
|
+
{ id: 'health', label: 'Health' },
|
|
155
|
+
{ id: 'trash', label: 'Trash' },
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
function renderTabs() {
|
|
159
|
+
const container = el('tabs');
|
|
160
|
+
container.textContent = '';
|
|
161
|
+
for (const tab of TABS) {
|
|
162
|
+
let badge = null;
|
|
163
|
+
if (tab.id === 'memories') badge = String(state.project.memories.length);
|
|
164
|
+
if (tab.id === 'health' && state.project.health.issueCount) badge = String(state.project.health.issueCount);
|
|
165
|
+
if (tab.id === 'trash' && state.project.trash.length) badge = String(state.project.trash.length);
|
|
166
|
+
if (tab.id === 'prune' && state.project.stats.index.overTarget) badge = '!';
|
|
167
|
+
|
|
168
|
+
container.append(node('button', {
|
|
169
|
+
class: `tab${state.tab === tab.id ? ' active' : ''}`,
|
|
170
|
+
onclick: () => { state.tab = tab.id; renderTabs(); renderTab(); },
|
|
171
|
+
}, [
|
|
172
|
+
document.createTextNode(tab.label),
|
|
173
|
+
badge ? node('span', { class: `badge${tab.id === 'health' || tab.id === 'prune' ? ' warn' : ''}`, text: badge }) : null,
|
|
174
|
+
]));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* -------------------------------------------------------------- memories */
|
|
179
|
+
|
|
180
|
+
function memoryButton(memory) {
|
|
181
|
+
return node('button', {
|
|
182
|
+
class: `mem${state.selected === memory.file ? ' active' : ''}`,
|
|
183
|
+
onclick: () => selectMemory(memory.file),
|
|
184
|
+
}, [
|
|
185
|
+
node('span', { class: 'mem-top' }, [
|
|
186
|
+
node('span', { class: 'mem-name', text: memory.name }),
|
|
187
|
+
node('span', { class: `badge type-${memory.type}`, text: memory.type }),
|
|
188
|
+
memory.status !== 'indexed'
|
|
189
|
+
? node('span', { class: `badge status-${memory.status}`, text: memory.status })
|
|
190
|
+
: null,
|
|
191
|
+
]),
|
|
192
|
+
memory.description ? node('span', { class: 'mem-desc', text: memory.description }) : null,
|
|
193
|
+
]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function renderMemories(container) {
|
|
197
|
+
const project = state.project;
|
|
198
|
+
if (!project.memories.length) {
|
|
199
|
+
container.append(node('p', { class: 'muted', text: 'This project has no memory files yet.' }));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
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
|
+
const groups = new Map();
|
|
207
|
+
for (const memory of project.memories) {
|
|
208
|
+
const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
|
|
209
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
210
|
+
groups.get(key).push(memory);
|
|
211
|
+
}
|
|
212
|
+
for (const [section, memories] of groups) {
|
|
213
|
+
left.append(node('div', { class: 'section-label', text: `${section} · ${memories.length}` }));
|
|
214
|
+
for (const memory of memories) left.append(memoryButton(memory));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const right = node('div', { id: 'detail' });
|
|
218
|
+
container.append(node('div', { class: 'split' }, [left, right]));
|
|
219
|
+
renderDetail();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderDetail() {
|
|
223
|
+
const host = el('detail');
|
|
224
|
+
if (!host) return;
|
|
225
|
+
host.textContent = '';
|
|
226
|
+
|
|
227
|
+
const memory = state.project.memories.find((m) => m.file === state.selected);
|
|
228
|
+
if (!memory) {
|
|
229
|
+
host.append(node('p', { class: 'muted', text: 'Select a memory to read it.' }));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const card = node('div', { class: 'card' });
|
|
234
|
+
card.append(node('div', { class: 'detail-head' }, [
|
|
235
|
+
node('h3', { text: memory.name }),
|
|
236
|
+
node('span', { class: `badge type-${memory.type}`, text: memory.type }),
|
|
237
|
+
node('button', {
|
|
238
|
+
class: 'btn danger small',
|
|
239
|
+
text: 'Delete',
|
|
240
|
+
onclick: () => openDeleteDialog(memory.file),
|
|
241
|
+
}),
|
|
242
|
+
]));
|
|
243
|
+
|
|
244
|
+
if (memory.description) card.append(node('p', { class: 'detail-desc', text: memory.description }));
|
|
245
|
+
|
|
246
|
+
const meta = node('dl', { class: 'meta' });
|
|
247
|
+
const rows = [
|
|
248
|
+
['file', memory.file],
|
|
249
|
+
['status', memory.status],
|
|
250
|
+
['modified', memory.modified ? memory.modified.replace('T', ' ').replace(/\..*$/, '') : '-'],
|
|
251
|
+
['size', `${(memory.bytes / 1024).toFixed(1)} KB`],
|
|
252
|
+
];
|
|
253
|
+
if (!memory.nameMatchesFile) rows.push(['name', `${memory.name} (differs from filename)`]);
|
|
254
|
+
for (const [key, value] of Object.entries(memory.metadata)) {
|
|
255
|
+
if (key !== 'type' && key !== 'modified') rows.push([key, value]);
|
|
256
|
+
}
|
|
257
|
+
for (const [key, value] of rows) {
|
|
258
|
+
meta.append(node('dt', { text: key }), node('dd', { text: String(value) }));
|
|
259
|
+
}
|
|
260
|
+
card.append(meta);
|
|
261
|
+
|
|
262
|
+
const body = node('div', { class: 'body' });
|
|
263
|
+
card.append(body);
|
|
264
|
+
renderBody(body, memory, state.project);
|
|
265
|
+
|
|
266
|
+
const outbound = memory.outboundResolved || [];
|
|
267
|
+
if (outbound.length || memory.inbound.length) {
|
|
268
|
+
const links = node('div', { style: 'margin-top:20px;border-top:1px solid var(--border);padding-top:14px' });
|
|
269
|
+
if (outbound.length) {
|
|
270
|
+
links.append(node('div', { class: 'section-label', text: 'Links out' }));
|
|
271
|
+
const chips = node('div', { class: 'links' });
|
|
272
|
+
for (const link of outbound) {
|
|
273
|
+
chips.append(node('button', {
|
|
274
|
+
class: `chip${link.file ? '' : ' dead'}`,
|
|
275
|
+
text: link.file ? link.target : `${link.target} (missing)`,
|
|
276
|
+
onclick: link.file ? () => selectMemory(link.file) : null,
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
links.append(chips);
|
|
280
|
+
}
|
|
281
|
+
if (memory.inbound.length) {
|
|
282
|
+
links.append(node('div', { class: 'section-label', text: 'Linked from' }));
|
|
283
|
+
const chips = node('div', { class: 'links' });
|
|
284
|
+
for (const link of memory.inbound) {
|
|
285
|
+
const source = state.project.memories.find((m) => m.file === link.from);
|
|
286
|
+
chips.append(node('button', {
|
|
287
|
+
class: 'chip',
|
|
288
|
+
text: source ? source.name : link.from,
|
|
289
|
+
onclick: () => selectMemory(link.from),
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
links.append(chips);
|
|
293
|
+
}
|
|
294
|
+
card.append(links);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
host.append(card);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* ----------------------------------------------------------- MEMORY.md */
|
|
301
|
+
|
|
302
|
+
function renderIndex(container) {
|
|
303
|
+
const project = state.project;
|
|
304
|
+
if (!project.hasIndex) {
|
|
305
|
+
container.append(node('p', { class: 'muted', text: 'This project has no MEMORY.md.' }));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const missing = new Set(project.health.danglingIndex.map((d) => d.index));
|
|
310
|
+
const card = node('div', { class: 'card' });
|
|
311
|
+
card.append(node('p', { class: 'muted', style: 'margin-top:0', text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }));
|
|
312
|
+
|
|
313
|
+
for (const line of project.index.lines) {
|
|
314
|
+
const row = node('div', {
|
|
315
|
+
class: `index-line kind-${line.kind}${missing.has(line.index) ? ' dangling' : ''}`,
|
|
316
|
+
}, [
|
|
317
|
+
node('span', { class: 'ln', text: String(line.index + 1) }),
|
|
318
|
+
node('span', { class: 'content', text: line.text || ' ' }),
|
|
319
|
+
]);
|
|
320
|
+
if (line.kind === 'index' && !missing.has(line.index)) {
|
|
321
|
+
row.querySelector('.content').onclick = () => selectMemory(line.file);
|
|
322
|
+
}
|
|
323
|
+
card.append(row);
|
|
324
|
+
}
|
|
325
|
+
container.append(card);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* -------------------------------------------------------------- health */
|
|
329
|
+
|
|
330
|
+
function issue(title, detail, { bad = false, action, secondary } = {}) {
|
|
331
|
+
return node('div', { class: `issue${bad ? ' bad' : ''}` }, [
|
|
332
|
+
node('div', { class: 'issue-body' }, [
|
|
333
|
+
node('div', { class: 'issue-title', text: title }),
|
|
334
|
+
node('div', { class: 'issue-detail', text: detail }),
|
|
335
|
+
]),
|
|
336
|
+
secondary ? node('button', { class: 'btn small', text: secondary.label, onclick: secondary.run }) : null,
|
|
337
|
+
action ? node('button', { class: 'btn small', text: action.label, onclick: action.run }) : null,
|
|
338
|
+
]);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function renderHealth(container) {
|
|
342
|
+
const { health, memories } = state.project;
|
|
343
|
+
if (!health.issues.length) {
|
|
344
|
+
container.append(node('div', { class: 'card' }, [
|
|
345
|
+
node('p', { style: 'margin:0', text: '\u2713 No consistency problems found.' }),
|
|
346
|
+
node('p', { class: 'muted', style: 'margin:6px 0 0;font-size:13px', text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
|
|
347
|
+
]));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Rendered straight from health.issues so the tab and the badge always agree.
|
|
352
|
+
for (const item of health.issues) {
|
|
353
|
+
container.append(renderIssue(item, memories));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function renderIssue(item, memories) {
|
|
358
|
+
const bad = item.severity === 'bad';
|
|
359
|
+
|
|
360
|
+
if (item.kind === 'dangling-index') {
|
|
361
|
+
const { entry } = item;
|
|
362
|
+
return issue(
|
|
363
|
+
`MEMORY.md points at a file that does not exist: ${entry.file}`,
|
|
364
|
+
`line ${entry.index + 1}: ${entry.text}`,
|
|
365
|
+
{
|
|
366
|
+
bad,
|
|
367
|
+
action: {
|
|
368
|
+
label: 'Remove pointer',
|
|
369
|
+
run: async () => {
|
|
370
|
+
if (!confirm(`Remove line ${entry.index + 1} from MEMORY.md?\n\n${entry.text}`)) return;
|
|
371
|
+
try {
|
|
372
|
+
await api(`/api/projects/${encodeURIComponent(state.slug)}/index-line/delete`, {
|
|
373
|
+
method: 'POST',
|
|
374
|
+
body: JSON.stringify({ lineIndex: entry.index, expectedText: entry.text }),
|
|
375
|
+
});
|
|
376
|
+
toast('Pointer removed');
|
|
377
|
+
await openProject(state.slug, { keepTab: true });
|
|
378
|
+
} catch (err) {
|
|
379
|
+
toast(err.message, { error: true });
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (item.kind === 'dangling-wikilink') {
|
|
388
|
+
const { link } = item;
|
|
389
|
+
return issue(
|
|
390
|
+
`Broken [[${link.target}]]`,
|
|
391
|
+
`referenced from ${link.from} - no memory has that name`,
|
|
392
|
+
{
|
|
393
|
+
bad,
|
|
394
|
+
secondary: { label: 'Open source', run: () => selectMemory(link.from) },
|
|
395
|
+
action: {
|
|
396
|
+
label: 'Remove link',
|
|
397
|
+
run: async () => {
|
|
398
|
+
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
|
+
try {
|
|
400
|
+
const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/wikilink/remove`, {
|
|
401
|
+
method: 'POST',
|
|
402
|
+
body: JSON.stringify({ file: link.from, target: link.target }),
|
|
403
|
+
});
|
|
404
|
+
await openProject(state.slug, { keepTab: true });
|
|
405
|
+
toast(`Unlinked ${result.occurrences} reference(s)`, {
|
|
406
|
+
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
407
|
+
});
|
|
408
|
+
} catch (err) {
|
|
409
|
+
toast(err.message, { error: true });
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (item.kind === 'orphan') {
|
|
418
|
+
const memory = memories.find((m) => m.file === item.file);
|
|
419
|
+
return issue(
|
|
420
|
+
`Not referenced anywhere in MEMORY.md: ${item.file}`,
|
|
421
|
+
`"${memory?.name || item.file}" exists on disk but nothing points to it, so Claude will not load it.`,
|
|
422
|
+
{ bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (item.kind === 'referenced-only') {
|
|
427
|
+
const memory = memories.find((m) => m.file === item.file);
|
|
428
|
+
return issue(
|
|
429
|
+
`Linked mid-sentence, not indexed: ${item.file}`,
|
|
430
|
+
`"${memory?.name || item.file}" is mentioned inside prose in MEMORY.md but has no index bullet of its own.`,
|
|
431
|
+
{ bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (item.kind === 'name-mismatch') {
|
|
436
|
+
return issue(
|
|
437
|
+
`Frontmatter name differs from filename: ${item.mismatch.file}`,
|
|
438
|
+
`name: "${item.mismatch.name}" - [[wikilinks]] must use this name, not the filename.`,
|
|
439
|
+
{ bad, action: { label: 'Open', run: () => selectMemory(item.mismatch.file) } },
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (item.kind === 'missing-frontmatter') {
|
|
444
|
+
return issue(
|
|
445
|
+
`No YAML frontmatter: ${item.file}`,
|
|
446
|
+
'Type and description cannot be read from this file.',
|
|
447
|
+
{ bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (item.kind === 'long-hooks') {
|
|
452
|
+
return issue(
|
|
453
|
+
`${item.count} index hook${item.count === 1 ? '' : 's'} over 200 characters`,
|
|
454
|
+
`longest is ${item.longest.hookLength} chars on "${item.longest.title}" - hooks load every session, so these are the cheapest thing to trim.`,
|
|
455
|
+
{
|
|
456
|
+
bad,
|
|
457
|
+
action: {
|
|
458
|
+
label: 'Open Prune',
|
|
459
|
+
run: () => { state.tab = 'prune'; renderTabs(); renderTab(); },
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return issue(item.kind, JSON.stringify(item));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function restoreFromTrash(id) {
|
|
469
|
+
try {
|
|
470
|
+
const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/restore`, {
|
|
471
|
+
method: 'POST',
|
|
472
|
+
body: JSON.stringify({ id }),
|
|
473
|
+
});
|
|
474
|
+
await openProject(state.slug, { keepTab: true });
|
|
475
|
+
toast(result.indexRestored === 'appended'
|
|
476
|
+
? 'Restored, but MEMORY.md had changed - the index line was appended at the end'
|
|
477
|
+
: 'Restored');
|
|
478
|
+
} catch (err) {
|
|
479
|
+
toast(err.message, { error: true });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/* --------------------------------------------------------------- trash */
|
|
484
|
+
|
|
485
|
+
function renderTrash(container) {
|
|
486
|
+
const trash = state.project.trash;
|
|
487
|
+
if (!trash.length) {
|
|
488
|
+
container.append(node('p', { class: 'muted', text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
for (const record of trash) {
|
|
492
|
+
const when = String(record.deletedAt).replace('T', ' ').replace(/\..*$/, '');
|
|
493
|
+
const detail = record.kind === 'wikilink'
|
|
494
|
+
? `unlinked in ${record.sourceFile} · ${when}`
|
|
495
|
+
: `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
|
|
496
|
+
container.append(node('div', { class: `issue${record.present ? '' : ' bad'}` }, [
|
|
497
|
+
node('div', { class: 'issue-body' }, [
|
|
498
|
+
node('div', { class: 'issue-title', text: record.label || record.id }),
|
|
499
|
+
node('div', { class: 'issue-detail', text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
|
|
500
|
+
]),
|
|
501
|
+
record.present
|
|
502
|
+
? node('button', { class: 'btn small primary', text: 'Restore', onclick: () => restoreFromTrash(record.id) })
|
|
503
|
+
: null,
|
|
504
|
+
]));
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/* -------------------------------------------------------- delete dialog */
|
|
509
|
+
|
|
510
|
+
function closeModal() {
|
|
511
|
+
el('modal-root').textContent = '';
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function openDeleteDialog(file) {
|
|
515
|
+
let preview;
|
|
516
|
+
try {
|
|
517
|
+
preview = await api(`/api/projects/${encodeURIComponent(state.slug)}/delete-preview`, {
|
|
518
|
+
method: 'POST',
|
|
519
|
+
body: JSON.stringify({ file }),
|
|
520
|
+
});
|
|
521
|
+
} catch (err) {
|
|
522
|
+
return toast(err.message, { error: true });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const body = node('div', { class: 'modal-body' });
|
|
526
|
+
|
|
527
|
+
const removals = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: 'Will be removed' })]);
|
|
528
|
+
removals.append(node('div', { class: 'will-item remove', text: `memory/${preview.file} → memory/.trash/` }));
|
|
529
|
+
for (const line of [...preview.indexLines, ...preview.continuations]) {
|
|
530
|
+
removals.append(node('div', { class: 'will-item remove', text: `MEMORY.md line ${line.index + 1}: ${line.text}` }));
|
|
531
|
+
}
|
|
532
|
+
if (!preview.indexLines.length && preview.hasIndex) {
|
|
533
|
+
removals.append(node('div', { class: 'will-item', text: 'MEMORY.md has no index bullet for this file - nothing to unlink there.' }));
|
|
534
|
+
}
|
|
535
|
+
body.append(removals);
|
|
536
|
+
|
|
537
|
+
if (preview.inlineRefs.length) {
|
|
538
|
+
const kept = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' })]);
|
|
539
|
+
for (const ref of preview.inlineRefs) {
|
|
540
|
+
kept.append(node('div', { class: 'will-item keep', text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
|
|
541
|
+
}
|
|
542
|
+
body.append(kept);
|
|
543
|
+
}
|
|
544
|
+
|
|
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
|
+
const cascade = new Set();
|
|
549
|
+
if (preview.inboundWikilinks.length) {
|
|
550
|
+
const block = node('div', { class: 'will' });
|
|
551
|
+
const head = node('div', { class: 'cascade-head' }, [
|
|
552
|
+
node('div', { class: 'will-title', style: 'margin:0', text: `${preview.inboundWikilinks.length} memory(s) link here and will break` }),
|
|
553
|
+
node('button', { class: 'link-btn', text: 'select all' }),
|
|
554
|
+
]);
|
|
555
|
+
block.append(head);
|
|
556
|
+
|
|
557
|
+
const list = node('div', { class: 'cascade' });
|
|
558
|
+
const rows = [];
|
|
559
|
+
for (const link of preview.inboundWikilinks) {
|
|
560
|
+
const box = node('input', { type: 'checkbox' });
|
|
561
|
+
const row = node('label', { class: 'cascade-row' }, [
|
|
562
|
+
box,
|
|
563
|
+
node('span', { class: 'cascade-text' }, [
|
|
564
|
+
node('span', { class: 'cascade-name', text: link.fromName || link.from }),
|
|
565
|
+
node('span', { class: 'cascade-detail', text: link.indexLine
|
|
566
|
+
? `${link.from} · MEMORY.md line ${link.indexLine.index + 1}`
|
|
567
|
+
: `${link.from} · not in the index` }),
|
|
568
|
+
]),
|
|
569
|
+
]);
|
|
570
|
+
box.addEventListener('change', () => {
|
|
571
|
+
row.classList.toggle('on', box.checked);
|
|
572
|
+
if (box.checked) cascade.add(link.from);
|
|
573
|
+
else cascade.delete(link.from);
|
|
574
|
+
updateButton();
|
|
575
|
+
});
|
|
576
|
+
rows.push({ box, row });
|
|
577
|
+
list.append(row);
|
|
578
|
+
}
|
|
579
|
+
block.append(list);
|
|
580
|
+
block.append(node('p', { class: 'muted', style: 'margin:6px 0 0;font-size:12px', text: 'Tick any you also want deleted - they go to the trash together and restore as one step.' }));
|
|
581
|
+
body.append(block);
|
|
582
|
+
|
|
583
|
+
head.querySelector('.link-btn').onclick = (event) => {
|
|
584
|
+
event.preventDefault();
|
|
585
|
+
const turnOn = rows.some((r) => !r.box.checked);
|
|
586
|
+
for (const { box } of rows) {
|
|
587
|
+
if (box.checked !== turnOn) { box.checked = turnOn; box.dispatchEvent(new Event('change')); }
|
|
588
|
+
}
|
|
589
|
+
event.target.textContent = turnOn ? 'select none' : 'select all';
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
body.append(node('p', { class: 'muted', style: 'margin:0;font-size:12.5px', text: 'The file moves to memory/.trash/ with a restore record, so this can be undone.' }));
|
|
594
|
+
|
|
595
|
+
const modal = node('div', { class: 'modal' }, [
|
|
596
|
+
node('header', {}, [
|
|
597
|
+
node('h3', { text: `Delete "${preview.name || preview.file}"?` }),
|
|
598
|
+
node('p', { class: 'muted', style: 'margin:0;font-size:13px', text: preview.description || '' }),
|
|
599
|
+
]),
|
|
600
|
+
body,
|
|
601
|
+
node('footer', {}, [
|
|
602
|
+
node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
|
|
603
|
+
node('button', {
|
|
604
|
+
class: 'btn danger',
|
|
605
|
+
text: 'Delete',
|
|
606
|
+
onclick: async () => {
|
|
607
|
+
closeModal();
|
|
608
|
+
try {
|
|
609
|
+
const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/delete`, {
|
|
610
|
+
method: 'POST',
|
|
611
|
+
body: JSON.stringify({ file, alsoDelete: [...cascade] }),
|
|
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) },
|
|
618
|
+
});
|
|
619
|
+
} catch (err) {
|
|
620
|
+
toast(err.message, { error: true });
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
}),
|
|
624
|
+
]),
|
|
625
|
+
]);
|
|
626
|
+
|
|
627
|
+
const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
628
|
+
el('modal-root').append(backdrop);
|
|
629
|
+
updateButton();
|
|
630
|
+
|
|
631
|
+
function updateButton() {
|
|
632
|
+
const button = modal.querySelector('footer .btn.danger');
|
|
633
|
+
if (button) button.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function openProjectDeleteDialog() {
|
|
638
|
+
let preview;
|
|
639
|
+
try {
|
|
640
|
+
preview = await api(`/api/projects/${encodeURIComponent(state.slug)}/project/delete-preview`, { method: 'POST' });
|
|
641
|
+
} catch (err) {
|
|
642
|
+
return toast(err.message, { error: true });
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (!preview.files.length && !preview.hasIndex) {
|
|
646
|
+
return toast('This project has no memory to delete');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const body = node('div', { class: 'modal-body' });
|
|
650
|
+
const removals = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: 'Will be removed' })]);
|
|
651
|
+
if (preview.hasIndex) {
|
|
652
|
+
removals.append(node('div', { class: 'will-item remove', text: `MEMORY.md (${preview.indexLines} lines) → memory/.trash/` }));
|
|
653
|
+
}
|
|
654
|
+
for (const entry of preview.files) {
|
|
655
|
+
removals.append(node('div', { class: 'will-item remove', text: `${entry.file} → memory/.trash/` }));
|
|
656
|
+
}
|
|
657
|
+
body.append(removals);
|
|
658
|
+
|
|
659
|
+
body.append(node('div', { class: 'will' }, [
|
|
660
|
+
node('div', { class: 'will-title', text: 'Will be kept' }),
|
|
661
|
+
node('div', { class: 'will-item keep', text: 'Session transcripts (*.jsonl) and the project folder itself' }),
|
|
662
|
+
]));
|
|
663
|
+
|
|
664
|
+
body.append(node('p', { class: 'muted', style: 'margin:0;font-size:12.5px', text: 'Everything moves to memory/.trash/ as one restore point, so the whole project can be put back in a single step from the Trash tab.' }));
|
|
665
|
+
|
|
666
|
+
const modal = node('div', { class: 'modal' }, [
|
|
667
|
+
node('header', {}, [
|
|
668
|
+
node('h3', { text: `Delete all memory for ${state.project.label}?` }),
|
|
669
|
+
node('p', { class: 'muted', style: 'margin:0;font-size:13px', text: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}` }),
|
|
670
|
+
]),
|
|
671
|
+
body,
|
|
672
|
+
node('footer', {}, [
|
|
673
|
+
node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
|
|
674
|
+
node('button', {
|
|
675
|
+
class: 'btn danger',
|
|
676
|
+
text: `Delete ${preview.files.length + (preview.hasIndex ? 1 : 0)} files`,
|
|
677
|
+
onclick: async () => {
|
|
678
|
+
closeModal();
|
|
679
|
+
try {
|
|
680
|
+
const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/project/delete`, { method: 'POST' });
|
|
681
|
+
state.selected = null;
|
|
682
|
+
await openProject(state.slug, { keepTab: true });
|
|
683
|
+
toast(`Cleared ${state.project.label}`, {
|
|
684
|
+
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
685
|
+
});
|
|
686
|
+
} catch (err) {
|
|
687
|
+
toast(err.message, { error: true });
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
}),
|
|
691
|
+
]),
|
|
692
|
+
]);
|
|
693
|
+
|
|
694
|
+
const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
695
|
+
el('modal-root').append(backdrop);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
/* --------------------------------------------------------------- prune */
|
|
700
|
+
|
|
701
|
+
function highlight(text, terms) {
|
|
702
|
+
const wrapper = node('span');
|
|
703
|
+
if (!terms.length) { wrapper.textContent = text; return wrapper; }
|
|
704
|
+
const pattern = new RegExp(`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'ig');
|
|
705
|
+
let last = 0;
|
|
706
|
+
for (const match of text.matchAll(pattern)) {
|
|
707
|
+
if (match.index > last) wrapper.append(document.createTextNode(text.slice(last, match.index)));
|
|
708
|
+
wrapper.append(node('mark', { text: match[0] }));
|
|
709
|
+
last = match.index + match[0].length;
|
|
710
|
+
}
|
|
711
|
+
if (last < text.length) wrapper.append(document.createTextNode(text.slice(last)));
|
|
712
|
+
return wrapper;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function formatAge(days) {
|
|
716
|
+
if (days === null || days === undefined) return 'undated';
|
|
717
|
+
if (days < 1) return 'today';
|
|
718
|
+
if (days < 31) return `${days}d`;
|
|
719
|
+
if (days < 365) return `${Math.round(days / 30)}mo`;
|
|
720
|
+
return `${(days / 365).toFixed(1)}y`;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const PRUNE_SORTS = {
|
|
724
|
+
oldest: { label: 'oldest first', compare: (a, b) => (b.ageDays ?? -1) - (a.ageDays ?? -1) },
|
|
725
|
+
largest: { label: 'largest first', compare: (a, b) => b.bytes - a.bytes },
|
|
726
|
+
unlinked: { label: 'least linked', compare: (a, b) => a.inbound.length - b.inbound.length || (b.ageDays ?? 0) - (a.ageDays ?? 0) },
|
|
727
|
+
name: { label: 'by name', compare: (a, b) => a.name.localeCompare(b.name) },
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
function renderBudget(container) {
|
|
731
|
+
const { index } = state.project.stats;
|
|
732
|
+
const percent = Math.min(100, index.worstPercent);
|
|
733
|
+
const level = index.level === 'over' ? 'way-over' : index.level === 'near' ? 'over' : '';
|
|
734
|
+
|
|
735
|
+
const note = index.level === 'over'
|
|
736
|
+
? 'Over the limit. Everything past the cutoff is dropped when the session loads it, so those entries are effectively invisible to Claude right now.'
|
|
737
|
+
: index.level === 'near'
|
|
738
|
+
? 'Approaching the limit. Once MEMORY.md passes it, everything after the cutoff stops being loaded at all.'
|
|
739
|
+
: 'Comfortably inside the limit. MEMORY.md is loaded at the start of every session.';
|
|
740
|
+
|
|
741
|
+
container.append(node('div', { class: 'meter' }, [
|
|
742
|
+
node('div', { class: 'meter-top' }, [
|
|
743
|
+
node('span', { class: 'meter-value', text: `${Math.round(index.worstPercent)}%` }),
|
|
744
|
+
node('span', { class: 'meter-unit', text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
|
|
745
|
+
]),
|
|
746
|
+
node('div', { class: 'meter-bar' }, [
|
|
747
|
+
node('div', { class: `meter-fill ${level}`, style: `width:${percent.toFixed(1)}%` }),
|
|
748
|
+
]),
|
|
749
|
+
node('p', { class: 'meter-note', text: note }),
|
|
750
|
+
node('div', { class: 'meter-facts' }, [
|
|
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')]),
|
|
755
|
+
]),
|
|
756
|
+
node('p', { class: 'meter-note', style: 'margin-top:10px', 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
|
+
]));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function renderPrune(container) {
|
|
761
|
+
const project = state.project;
|
|
762
|
+
renderBudget(container);
|
|
763
|
+
|
|
764
|
+
if (!project.memories.length) {
|
|
765
|
+
container.append(node('p', { class: 'muted', text: 'Nothing to prune - this project has no memories.' }));
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// --- bulk selection list ---
|
|
770
|
+
const selected = state.pruneSelection;
|
|
771
|
+
const bar = node('div', { class: 'prune-bar' });
|
|
772
|
+
const sort = node('select');
|
|
773
|
+
for (const [key, config] of Object.entries(PRUNE_SORTS)) {
|
|
774
|
+
sort.append(node('option', { value: key, text: config.label, selected: key === state.pruneSort ? '' : null }));
|
|
775
|
+
}
|
|
776
|
+
sort.value = state.pruneSort;
|
|
777
|
+
sort.onchange = () => { state.pruneSort = sort.value; renderTab(); };
|
|
778
|
+
|
|
779
|
+
const count = node('span', { class: 'muted', style: 'font-size:12.5px' });
|
|
780
|
+
const deleteButton = node('button', { class: 'btn danger small' });
|
|
781
|
+
const clearButton = node('button', { class: 'btn small', text: 'Clear selection' });
|
|
782
|
+
|
|
783
|
+
bar.append(node('span', { class: 'section-label', style: 'margin:0', text: 'Prune' }), sort, node('span', { class: 'spacer' }), count, clearButton, deleteButton);
|
|
784
|
+
container.append(bar);
|
|
785
|
+
|
|
786
|
+
const list = node('div');
|
|
787
|
+
const ordered = [...project.memories].sort(PRUNE_SORTS[state.pruneSort].compare);
|
|
788
|
+
const rows = [];
|
|
789
|
+
for (const memory of ordered) {
|
|
790
|
+
const box = node('input', { type: 'checkbox' });
|
|
791
|
+
box.checked = selected.has(memory.file);
|
|
792
|
+
const row = node('label', { class: `prune-row${box.checked ? ' on' : ''}` }, [
|
|
793
|
+
box,
|
|
794
|
+
node('span', { class: 'prune-main' }, [
|
|
795
|
+
node('span', { class: 'prune-name', text: memory.name }),
|
|
796
|
+
node('span', { class: 'prune-desc', text: memory.description || memory.file }),
|
|
797
|
+
]),
|
|
798
|
+
node('span', { class: 'prune-facts' }, [
|
|
799
|
+
node('span', { class: (memory.ageDays ?? 0) > 180 ? 'warn' : '', text: formatAge(memory.ageDays) }),
|
|
800
|
+
node('span', { text: `${(memory.bytes / 1024).toFixed(1)}K` }),
|
|
801
|
+
node('span', { class: memory.inbound.length ? '' : 'warn', text: `${memory.inbound.length} in` }),
|
|
802
|
+
node('span', { class: memory.status === 'indexed' ? '' : 'warn', text: memory.status }),
|
|
803
|
+
]),
|
|
804
|
+
]);
|
|
805
|
+
box.addEventListener('change', () => {
|
|
806
|
+
if (box.checked) selected.add(memory.file); else selected.delete(memory.file);
|
|
807
|
+
row.classList.toggle('on', box.checked);
|
|
808
|
+
updateBar();
|
|
809
|
+
});
|
|
810
|
+
rows.push({ box, memory });
|
|
811
|
+
list.append(row);
|
|
812
|
+
}
|
|
813
|
+
container.append(list);
|
|
814
|
+
|
|
815
|
+
function updateBar() {
|
|
816
|
+
count.textContent = selected.size ? `${selected.size} selected` : '';
|
|
817
|
+
deleteButton.textContent = `Delete ${selected.size || ''}`.trim();
|
|
818
|
+
deleteButton.disabled = selected.size === 0;
|
|
819
|
+
deleteButton.style.opacity = selected.size ? '1' : '.45';
|
|
820
|
+
}
|
|
821
|
+
clearButton.onclick = () => {
|
|
822
|
+
selected.clear();
|
|
823
|
+
renderTab();
|
|
824
|
+
};
|
|
825
|
+
deleteButton.onclick = () => openBulkDeleteDialog([...selected]);
|
|
826
|
+
updateBar();
|
|
827
|
+
|
|
828
|
+
// --- long hooks ---
|
|
829
|
+
const { longHooks } = project.stats.index;
|
|
830
|
+
if (longHooks.length) {
|
|
831
|
+
container.append(node('div', { class: 'section-label', text: `Long index hooks · ${longHooks.length}` }));
|
|
832
|
+
container.append(node('p', { class: 'muted', style: 'margin:0 0 10px;font-size:12.5px', 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
|
+
for (const hook of longHooks.slice(0, 12)) {
|
|
834
|
+
container.append(node('div', { class: 'issue' }, [
|
|
835
|
+
node('div', { class: 'issue-body' }, [
|
|
836
|
+
node('div', { class: 'issue-title', text: `${hook.hookLength} chars: ${hook.title}` }),
|
|
837
|
+
node('div', { class: 'issue-detail', text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
|
|
838
|
+
]),
|
|
839
|
+
node('button', { class: 'btn small', text: 'Open', onclick: () => selectMemory(hook.file) }),
|
|
840
|
+
]));
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// --- possible overlap ---
|
|
845
|
+
if (project.duplicates.length) {
|
|
846
|
+
container.append(node('div', { class: 'section-label', text: `Possible overlap · ${project.duplicates.length}` }));
|
|
847
|
+
container.append(node('p', { class: 'muted', style: 'margin:0 0 10px;font-size:12.5px', 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
|
+
for (const pair of project.duplicates) {
|
|
849
|
+
container.append(node('div', { class: 'dupe' }, [
|
|
850
|
+
node('div', { class: 'dupe-head' }, [
|
|
851
|
+
node('span', { class: 'dupe-score', text: `${pair.score}%` }),
|
|
852
|
+
node('span', { text: `shared: ${pair.shared.join(', ')}` }),
|
|
853
|
+
]),
|
|
854
|
+
node('div', { class: 'dupe-pair' }, [
|
|
855
|
+
...[pair.a, pair.b].map((side) => node('div', {
|
|
856
|
+
class: 'dupe-side',
|
|
857
|
+
onclick: () => selectMemory(side.file),
|
|
858
|
+
}, [
|
|
859
|
+
node('div', { class: 'n', text: side.name }),
|
|
860
|
+
node('div', { class: 'd', text: side.description || side.file }),
|
|
861
|
+
])),
|
|
862
|
+
]),
|
|
863
|
+
]));
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async function openBulkDeleteDialog(files) {
|
|
869
|
+
if (!files.length) return;
|
|
870
|
+
const memories = files.map((file) => state.project.memories.find((m) => m.file === file)).filter(Boolean);
|
|
871
|
+
|
|
872
|
+
const body = node('div', { class: 'modal-body' });
|
|
873
|
+
const removals = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: `${memories.length} memories will be trashed` })]);
|
|
874
|
+
for (const memory of memories) {
|
|
875
|
+
removals.append(node('div', { class: 'will-item remove', text: `${memory.file}${memory.entry ? ` · MEMORY.md line ${memory.entry.index + 1}` : ' · not in the index'}` }));
|
|
876
|
+
}
|
|
877
|
+
body.append(removals);
|
|
878
|
+
|
|
879
|
+
// Links from memories that are NOT being deleted are the ones that break.
|
|
880
|
+
const doomed = new Set(files);
|
|
881
|
+
const breaking = [];
|
|
882
|
+
for (const memory of memories) {
|
|
883
|
+
for (const link of memory.inbound) {
|
|
884
|
+
if (!doomed.has(link.from)) breaking.push({ from: link.from, target: link.target });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (breaking.length) {
|
|
888
|
+
const block = node('div', { class: 'will' }, [node('div', { class: 'will-title', text: `${breaking.length} link(s) from memories you are keeping will break` })]);
|
|
889
|
+
for (const link of breaking) block.append(node('div', { class: 'will-item keep', text: `${link.from} → [[${link.target}]]` }));
|
|
890
|
+
body.append(block);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
body.append(node('p', { class: 'muted', style: 'margin:0;font-size:12.5px', text: 'All of them go to memory/.trash/ as one restore point.' }));
|
|
894
|
+
|
|
895
|
+
const modal = node('div', { class: 'modal' }, [
|
|
896
|
+
node('header', {}, [node('h3', { text: `Delete ${memories.length} memories?` })]),
|
|
897
|
+
body,
|
|
898
|
+
node('footer', {}, [
|
|
899
|
+
node('button', { class: 'btn', text: 'Cancel', onclick: closeModal }),
|
|
900
|
+
node('button', {
|
|
901
|
+
class: 'btn danger',
|
|
902
|
+
text: `Delete ${memories.length}`,
|
|
903
|
+
onclick: async () => {
|
|
904
|
+
closeModal();
|
|
905
|
+
try {
|
|
906
|
+
const result = await api(`/api/projects/${encodeURIComponent(state.slug)}/delete-many`, {
|
|
907
|
+
method: 'POST',
|
|
908
|
+
body: JSON.stringify({ files, label: `${memories.length} pruned memories` }),
|
|
909
|
+
});
|
|
910
|
+
state.pruneSelection.clear();
|
|
911
|
+
if (doomed.has(state.selected)) state.selected = null;
|
|
912
|
+
await openProject(state.slug, { keepTab: true });
|
|
913
|
+
toast(`Deleted ${memories.length} memories`, {
|
|
914
|
+
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
915
|
+
});
|
|
916
|
+
} catch (err) {
|
|
917
|
+
toast(err.message, { error: true });
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
}),
|
|
921
|
+
]),
|
|
922
|
+
]);
|
|
923
|
+
const backdrop = node('div', { class: 'modal-backdrop', onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
924
|
+
el('modal-root').append(backdrop);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/* -------------------------------------------------------------- search */
|
|
928
|
+
|
|
929
|
+
let searchTimer = null;
|
|
930
|
+
|
|
931
|
+
function scheduleSearch(value) {
|
|
932
|
+
state.query = value;
|
|
933
|
+
clearTimeout(searchTimer);
|
|
934
|
+
if (!value.trim()) {
|
|
935
|
+
state.search = null;
|
|
936
|
+
renderView();
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
searchTimer = setTimeout(runSearch, 180);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async function runSearch() {
|
|
943
|
+
const query = state.query.trim();
|
|
944
|
+
if (!query) return;
|
|
945
|
+
try {
|
|
946
|
+
state.search = await api(`/api/search?q=${encodeURIComponent(query)}`);
|
|
947
|
+
} catch (err) {
|
|
948
|
+
return toast(err.message, { error: true });
|
|
949
|
+
}
|
|
950
|
+
renderView();
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function renderSearch() {
|
|
954
|
+
const data = state.search;
|
|
955
|
+
const host = el('search-results');
|
|
956
|
+
host.textContent = '';
|
|
957
|
+
el('search-title').textContent = data.total
|
|
958
|
+
? `${data.total} result${data.total === 1 ? '' : 's'} for "${state.query.trim()}"`
|
|
959
|
+
: `No results for "${state.query.trim()}"`;
|
|
960
|
+
|
|
961
|
+
if (!data.total && !data.projects.length) {
|
|
962
|
+
host.append(node('p', { class: 'muted', text: 'Nothing matched. Every word has to appear somewhere in a memory, so try fewer words.' }));
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
for (const group of data.projects) {
|
|
967
|
+
const block = node('div', { class: 'result-group' });
|
|
968
|
+
block.append(node('div', { class: 'section-label', text: `${group.label} · ${group.results.length}` }));
|
|
969
|
+
|
|
970
|
+
for (const result of group.results) {
|
|
971
|
+
const button = node('button', {
|
|
972
|
+
class: 'result',
|
|
973
|
+
onclick: () => openFromSearch(group.slug, result.file),
|
|
974
|
+
});
|
|
975
|
+
const top = node('div', { class: 'result-top' });
|
|
976
|
+
const name = node('span', { class: 'result-name' });
|
|
977
|
+
name.append(highlight(result.name, data.terms));
|
|
978
|
+
top.append(name, node('span', { class: `badge type-${result.type}`, text: result.type }));
|
|
979
|
+
if (result.status !== 'indexed') top.append(node('span', { class: `badge status-${result.status}`, text: result.status }));
|
|
980
|
+
button.append(top);
|
|
981
|
+
|
|
982
|
+
if (result.snippet) {
|
|
983
|
+
const snippet = node('div', { class: 'result-snippet' });
|
|
984
|
+
snippet.append(highlight(result.snippet.text, data.terms));
|
|
985
|
+
button.append(snippet);
|
|
986
|
+
}
|
|
987
|
+
button.append(node('div', { class: 'result-where', text: `${result.file} · matched in ${result.fields.join(', ')}` }));
|
|
988
|
+
block.append(button);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
for (const hit of group.indexHits) {
|
|
992
|
+
const button = node('button', {
|
|
993
|
+
class: 'result',
|
|
994
|
+
onclick: () => openFromSearch(group.slug, hit.file, 'index'),
|
|
995
|
+
});
|
|
996
|
+
button.append(node('div', { class: 'result-where', text: `MEMORY.md line ${hit.index + 1}` }));
|
|
997
|
+
const line = node('div', { class: 'result-snippet' });
|
|
998
|
+
line.append(highlight(hit.text.trim(), data.terms));
|
|
999
|
+
button.append(line);
|
|
1000
|
+
block.append(button);
|
|
1001
|
+
}
|
|
1002
|
+
host.append(block);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function openFromSearch(slug, file, tab = 'memories') {
|
|
1007
|
+
clearSearch();
|
|
1008
|
+
if (slug !== state.slug) await openProject(slug);
|
|
1009
|
+
state.tab = tab;
|
|
1010
|
+
if (file) state.selected = file;
|
|
1011
|
+
renderTabs();
|
|
1012
|
+
renderTab();
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function clearSearch() {
|
|
1016
|
+
state.query = '';
|
|
1017
|
+
state.search = null;
|
|
1018
|
+
el('search').value = '';
|
|
1019
|
+
renderView();
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function renderView() {
|
|
1023
|
+
const searching = state.search !== null;
|
|
1024
|
+
el('search-view').hidden = !searching;
|
|
1025
|
+
el('project-view').hidden = searching || !state.project;
|
|
1026
|
+
el('empty-state').hidden = searching || Boolean(state.project);
|
|
1027
|
+
if (searching) renderSearch();
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/* ------------------------------------------------------------ rendering */
|
|
1031
|
+
|
|
1032
|
+
function renderTab() {
|
|
1033
|
+
const container = el('tab-content');
|
|
1034
|
+
container.textContent = '';
|
|
1035
|
+
if (state.tab === 'memories') renderMemories(container);
|
|
1036
|
+
else if (state.tab === 'prune') renderPrune(container);
|
|
1037
|
+
else if (state.tab === 'index') renderIndex(container);
|
|
1038
|
+
else if (state.tab === 'health') renderHealth(container);
|
|
1039
|
+
else if (state.tab === 'trash') renderTrash(container);
|
|
1040
|
+
else if (state.tab === 'graph') {
|
|
1041
|
+
const wrap = node('div', { id: 'graph-wrap' });
|
|
1042
|
+
container.append(wrap);
|
|
1043
|
+
renderGraph(wrap, state.project.graph, {
|
|
1044
|
+
selected: state.selected,
|
|
1045
|
+
spread: state.spread,
|
|
1046
|
+
onSelect: (file) => selectMemory(file),
|
|
1047
|
+
onSpreadChange: (value) => {
|
|
1048
|
+
state.spread = value;
|
|
1049
|
+
localStorage.setItem('graphSpread', String(value));
|
|
1050
|
+
renderTab();
|
|
1051
|
+
},
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function selectMemory(file) {
|
|
1057
|
+
state.selected = file;
|
|
1058
|
+
if (state.tab !== 'memories') {
|
|
1059
|
+
state.tab = 'memories';
|
|
1060
|
+
renderTabs();
|
|
1061
|
+
renderTab();
|
|
1062
|
+
} else {
|
|
1063
|
+
renderTab();
|
|
1064
|
+
}
|
|
1065
|
+
document.querySelector('.mem.active')?.scrollIntoView({ block: 'nearest' });
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
async function openProject(slug, { keepTab = false } = {}) {
|
|
1069
|
+
if (slug !== state.slug) state.pruneSelection.clear();
|
|
1070
|
+
state.slug = slug;
|
|
1071
|
+
if (!keepTab) {
|
|
1072
|
+
state.tab = 'memories';
|
|
1073
|
+
state.selected = null;
|
|
1074
|
+
}
|
|
1075
|
+
renderProjects();
|
|
1076
|
+
|
|
1077
|
+
try {
|
|
1078
|
+
state.project = await api(`/api/projects/${encodeURIComponent(slug)}`);
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
return toast(err.message, { error: true });
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (state.selected && !state.project.memories.some((m) => m.file === state.selected)) {
|
|
1084
|
+
state.selected = null;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
el('project-title').textContent = state.project.label;
|
|
1088
|
+
el('project-sub').textContent = state.project.resolvedBy === 'unresolved'
|
|
1089
|
+
? `${state.project.path} (real path unresolved)`
|
|
1090
|
+
: state.project.path;
|
|
1091
|
+
|
|
1092
|
+
// Refresh the sidebar count in case a delete changed it.
|
|
1093
|
+
const listed = state.projects.find((p) => p.slug === slug);
|
|
1094
|
+
if (listed) listed.memoryCount = state.project.memories.length;
|
|
1095
|
+
renderProjects();
|
|
1096
|
+
|
|
1097
|
+
renderView();
|
|
1098
|
+
renderTabs();
|
|
1099
|
+
renderTab();
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function applyCollapsed() {
|
|
1103
|
+
el('app').classList.toggle('collapsed', state.collapsed);
|
|
1104
|
+
el('expand').hidden = !state.collapsed;
|
|
1105
|
+
localStorage.setItem('sidebarCollapsed', state.collapsed ? '1' : '0');
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function setCollapsed(value) {
|
|
1109
|
+
state.collapsed = value;
|
|
1110
|
+
applyCollapsed();
|
|
1111
|
+
// The graph sizes itself to the container, so it has to be redrawn once the
|
|
1112
|
+
// pane has actually changed width.
|
|
1113
|
+
if (state.tab === 'graph' && state.project) requestAnimationFrame(renderTab);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async function init() {
|
|
1117
|
+
el('show-all').addEventListener('change', (event) => {
|
|
1118
|
+
state.showAll = event.target.checked;
|
|
1119
|
+
renderProjects();
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
el('search').addEventListener('input', (event) => scheduleSearch(event.target.value));
|
|
1123
|
+
el('search-clear').addEventListener('click', clearSearch);
|
|
1124
|
+
el('collapse').addEventListener('click', () => setCollapsed(true));
|
|
1125
|
+
el('expand').addEventListener('click', () => setCollapsed(false));
|
|
1126
|
+
el('delete-project').addEventListener('click', openProjectDeleteDialog);
|
|
1127
|
+
document.addEventListener('keydown', (event) => {
|
|
1128
|
+
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName);
|
|
1129
|
+
if (event.key === '/' && !typing) {
|
|
1130
|
+
event.preventDefault();
|
|
1131
|
+
if (state.collapsed) setCollapsed(false);
|
|
1132
|
+
el('search').focus();
|
|
1133
|
+
el('search').select();
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
if (event.key === 'Escape') {
|
|
1137
|
+
if (el('modal-root').firstChild) closeModal();
|
|
1138
|
+
else if (state.search !== null) clearSearch();
|
|
1139
|
+
else if (!state.collapsed) setCollapsed(true);
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
applyCollapsed();
|
|
1143
|
+
|
|
1144
|
+
try {
|
|
1145
|
+
const data = await api('/api/projects');
|
|
1146
|
+
state.projects = data.projects;
|
|
1147
|
+
el('root-path').textContent = data.root;
|
|
1148
|
+
renderProjects();
|
|
1149
|
+
const first = state.projects.find((p) => p.memoryCount > 0);
|
|
1150
|
+
if (first) openProject(first.slug);
|
|
1151
|
+
} catch (err) {
|
|
1152
|
+
toast(err.message, { error: true });
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
init();
|