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
|
Binary file
|
package/public/graph.mjs
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
// Force-directed SVG graph of the [[wikilink]] edges within one project.
|
|
2
|
+
//
|
|
3
|
+
// Hand-rolled rather than pulled from a library: the largest project has ~60
|
|
4
|
+
// nodes, which a plain spring/repulsion relaxation handles fine, and it keeps
|
|
5
|
+
// the app at two dependencies with no bundler.
|
|
6
|
+
|
|
7
|
+
const TYPE_COLORS = {
|
|
8
|
+
project: '#5b8def',
|
|
9
|
+
feedback: '#d98324',
|
|
10
|
+
user: '#8b5cf6',
|
|
11
|
+
reference: '#3f9d6d',
|
|
12
|
+
unknown: '#9c988d',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function colorFor(type) {
|
|
16
|
+
return TYPE_COLORS[type] || TYPE_COLORS.unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function simulate(nodes, edges, { width, height, iterations = 320, repulsion = 1.6 }) {
|
|
20
|
+
const area = width * height;
|
|
21
|
+
const k = Math.sqrt(area / Math.max(nodes.length, 1)) * 0.72;
|
|
22
|
+
const index = new Map(nodes.map((n, i) => [n.id, i]));
|
|
23
|
+
|
|
24
|
+
// Deterministic ring start: same project always lays out the same way.
|
|
25
|
+
nodes.forEach((node, i) => {
|
|
26
|
+
const angle = (i / nodes.length) * Math.PI * 2;
|
|
27
|
+
const radius = Math.min(width, height) * 0.32 * (0.55 + ((i * 37) % 100) / 220);
|
|
28
|
+
node.x = width / 2 + Math.cos(angle) * radius;
|
|
29
|
+
node.y = height / 2 + Math.sin(angle) * radius;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const links = edges
|
|
33
|
+
.map((e) => ({ a: index.get(e.from), b: index.get(e.to) }))
|
|
34
|
+
.filter((l) => l.a !== undefined && l.b !== undefined && l.a !== l.b);
|
|
35
|
+
|
|
36
|
+
let temperature = Math.min(width, height) * 0.14;
|
|
37
|
+
for (let step = 0; step < iterations; step++) {
|
|
38
|
+
for (const node of nodes) { node.dx = 0; node.dy = 0; }
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
41
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
42
|
+
const a = nodes[i];
|
|
43
|
+
const b = nodes[j];
|
|
44
|
+
let dx = a.x - b.x;
|
|
45
|
+
let dy = a.y - b.y;
|
|
46
|
+
let dist = Math.hypot(dx, dy);
|
|
47
|
+
if (dist < 0.01) { dx = (i % 7) - 3 || 1; dy = (j % 5) - 2 || 1; dist = Math.hypot(dx, dy); }
|
|
48
|
+
const force = ((k * k) / dist) * repulsion;
|
|
49
|
+
const fx = (dx / dist) * force;
|
|
50
|
+
const fy = (dy / dist) * force;
|
|
51
|
+
a.dx += fx; a.dy += fy;
|
|
52
|
+
b.dx -= fx; b.dy -= fy;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const link of links) {
|
|
57
|
+
const a = nodes[link.a];
|
|
58
|
+
const b = nodes[link.b];
|
|
59
|
+
const dx = a.x - b.x;
|
|
60
|
+
const dy = a.y - b.y;
|
|
61
|
+
const dist = Math.hypot(dx, dy) || 0.01;
|
|
62
|
+
const force = ((dist * dist) / k) * 0.85;
|
|
63
|
+
const fx = (dx / dist) * force;
|
|
64
|
+
const fy = (dy / dist) * force;
|
|
65
|
+
a.dx -= fx; a.dy -= fy;
|
|
66
|
+
b.dx += fx; b.dy += fy;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Pull toward the centre, proportional to distance, so disconnected
|
|
70
|
+
// components stay in frame instead of drifting outward forever.
|
|
71
|
+
for (const node of nodes) {
|
|
72
|
+
node.dx += (width / 2 - node.x) * 0.006;
|
|
73
|
+
node.dy += (height / 2 - node.y) * 0.006;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const node of nodes) {
|
|
77
|
+
const disp = Math.hypot(node.dx, node.dy) || 0.01;
|
|
78
|
+
node.x += (node.dx / disp) * Math.min(disp, temperature);
|
|
79
|
+
node.y += (node.dy / disp) * Math.min(disp, temperature);
|
|
80
|
+
}
|
|
81
|
+
temperature *= 0.99;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Rescale the settled layout to fill the viewport. Clamping to the walls
|
|
85
|
+
// during the simulation would instead flatten everything onto the border,
|
|
86
|
+
// which is what makes hand-rolled force layouts look broken.
|
|
87
|
+
fitToBox(nodes, width, height, 46);
|
|
88
|
+
return nodes;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Map the settled positions onto the drawing area, preserving aspect ratio. */
|
|
92
|
+
function fitToBox(nodes, width, height, pad) {
|
|
93
|
+
const xs = nodes.map((n) => n.x);
|
|
94
|
+
const ys = nodes.map((n) => n.y);
|
|
95
|
+
const minX = Math.min(...xs);
|
|
96
|
+
const maxX = Math.max(...xs);
|
|
97
|
+
const minY = Math.min(...ys);
|
|
98
|
+
const maxY = Math.max(...ys);
|
|
99
|
+
const spanX = maxX - minX || 1;
|
|
100
|
+
const spanY = maxY - minY || 1;
|
|
101
|
+
// Scale each axis to the box, but cap how far they may differ: a uniform
|
|
102
|
+
// scale wastes half the canvas on a wide panel, and an uncapped one stretches
|
|
103
|
+
// the graph into something misleading.
|
|
104
|
+
let scaleX = (width - pad * 2) / spanX;
|
|
105
|
+
let scaleY = (height - pad * 2) / spanY;
|
|
106
|
+
const MAX_ANISOTROPY = 1.45;
|
|
107
|
+
if (scaleX > scaleY * MAX_ANISOTROPY) scaleX = scaleY * MAX_ANISOTROPY;
|
|
108
|
+
if (scaleY > scaleX * MAX_ANISOTROPY) scaleY = scaleX * MAX_ANISOTROPY;
|
|
109
|
+
|
|
110
|
+
const offsetX = (width - spanX * scaleX) / 2;
|
|
111
|
+
const offsetY = (height - spanY * scaleY) / 2;
|
|
112
|
+
for (const node of nodes) {
|
|
113
|
+
node.x = offsetX + (node.x - minX) * scaleX;
|
|
114
|
+
node.y = offsetY + (node.y - minY) * scaleY;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Connected components, so each cluster can be laid out on its own. */
|
|
119
|
+
function findComponents(nodes, edges) {
|
|
120
|
+
const adjacency = new Map(nodes.map((n) => [n.id, []]));
|
|
121
|
+
for (const edge of edges) {
|
|
122
|
+
if (!adjacency.has(edge.from) || !adjacency.has(edge.to)) continue;
|
|
123
|
+
adjacency.get(edge.from).push(edge.to);
|
|
124
|
+
adjacency.get(edge.to).push(edge.from);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
const groups = [];
|
|
129
|
+
for (const node of nodes) {
|
|
130
|
+
if (seen.has(node.id)) continue;
|
|
131
|
+
const stack = [node.id];
|
|
132
|
+
const group = [];
|
|
133
|
+
seen.add(node.id);
|
|
134
|
+
while (stack.length) {
|
|
135
|
+
const id = stack.pop();
|
|
136
|
+
group.push(id);
|
|
137
|
+
for (const neighbour of adjacency.get(id) || []) {
|
|
138
|
+
if (seen.has(neighbour)) continue;
|
|
139
|
+
seen.add(neighbour);
|
|
140
|
+
stack.push(neighbour);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
groups.push(group);
|
|
144
|
+
}
|
|
145
|
+
return groups;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function boundsOf(nodes) {
|
|
149
|
+
const xs = nodes.map((n) => n.x);
|
|
150
|
+
const ys = nodes.map((n) => n.y);
|
|
151
|
+
return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Lay out each connected component on its own, then pack the components into
|
|
156
|
+
* the canvas.
|
|
157
|
+
*
|
|
158
|
+
* Simulating everything in one field looks wrong as soon as a project has more
|
|
159
|
+
* than one cluster: unconnected components drift apart, and scaling the result
|
|
160
|
+
* to fit then squashes the largest cluster into an unreadable knot. Packing
|
|
161
|
+
* gives every cluster the same generous internal spacing regardless of how many
|
|
162
|
+
* there are.
|
|
163
|
+
*/
|
|
164
|
+
function layoutComponents(nodes, edges, { width, height, repulsion }) {
|
|
165
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
166
|
+
const groups = findComponents(nodes, edges)
|
|
167
|
+
.map((ids) => ids.map((id) => byId.get(id)))
|
|
168
|
+
.sort((a, b) => b.length - a.length);
|
|
169
|
+
|
|
170
|
+
const placed = [];
|
|
171
|
+
for (const group of groups) {
|
|
172
|
+
const ids = new Set(group.map((n) => n.id));
|
|
173
|
+
const inner = edges.filter((e) => ids.has(e.from) && ids.has(e.to));
|
|
174
|
+
// A box roughly proportional to the node count keeps density even.
|
|
175
|
+
const side = Math.max(180, Math.sqrt(group.length) * 190);
|
|
176
|
+
simulate(group, inner, { width: side, height: side, iterations: 420, repulsion });
|
|
177
|
+
const bounds = boundsOf(group);
|
|
178
|
+
placed.push({
|
|
179
|
+
group,
|
|
180
|
+
width: Math.max(bounds.maxX - bounds.minX, 60) + 90,
|
|
181
|
+
height: Math.max(bounds.maxY - bounds.minY, 40) + 60,
|
|
182
|
+
bounds,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Shelf packing: fill a row until it would overflow, then start a new one.
|
|
187
|
+
const gap = 26;
|
|
188
|
+
let cursorX = 0;
|
|
189
|
+
let cursorY = 0;
|
|
190
|
+
let rowHeight = 0;
|
|
191
|
+
for (const item of placed) {
|
|
192
|
+
if (cursorX > 0 && cursorX + item.width > width) {
|
|
193
|
+
cursorX = 0;
|
|
194
|
+
cursorY += rowHeight + gap;
|
|
195
|
+
rowHeight = 0;
|
|
196
|
+
}
|
|
197
|
+
item.offsetX = cursorX;
|
|
198
|
+
item.offsetY = cursorY;
|
|
199
|
+
cursorX += item.width + gap;
|
|
200
|
+
rowHeight = Math.max(rowHeight, item.height);
|
|
201
|
+
}
|
|
202
|
+
for (const item of placed) {
|
|
203
|
+
for (const node of item.group) {
|
|
204
|
+
node.x = item.offsetX + (node.x - item.bounds.minX) + 45;
|
|
205
|
+
node.y = item.offsetY + (node.y - item.bounds.minY) + 30;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Reuse the same fit the single-component path uses, so one big cluster still
|
|
210
|
+
// fills the canvas instead of being shrunk to fit a square working box.
|
|
211
|
+
fitToBox(nodes, width, height, 40);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
215
|
+
function svgEl(tag, attrs = {}) {
|
|
216
|
+
const el = document.createElementNS(NS, tag);
|
|
217
|
+
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, value);
|
|
218
|
+
return el;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Render the graph into `container`. Dangling wikilink targets are drawn as
|
|
223
|
+
* hollow nodes so a broken link is visible rather than simply absent.
|
|
224
|
+
*/
|
|
225
|
+
export function renderGraph(container, graph, { onSelect, onSpreadChange, selected, spread = 1.6 } = {}) {
|
|
226
|
+
container.textContent = '';
|
|
227
|
+
|
|
228
|
+
const real = graph.nodes.map((n) => ({ ...n, ghost: false }));
|
|
229
|
+
const ghostTargets = [...new Set(graph.dangling.map((d) => d.target))];
|
|
230
|
+
const ghosts = ghostTargets.map((target) => ({
|
|
231
|
+
id: `ghost:${target}`, label: target, type: 'unknown', status: 'missing', ghost: true, degree: 0,
|
|
232
|
+
}));
|
|
233
|
+
const nodes = [...real, ...ghosts];
|
|
234
|
+
|
|
235
|
+
if (nodes.length === 0) {
|
|
236
|
+
const note = document.createElement('p');
|
|
237
|
+
note.className = 'muted';
|
|
238
|
+
note.style.padding = '40px';
|
|
239
|
+
note.textContent = 'No memories to graph yet.';
|
|
240
|
+
container.append(note);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const byName = new Map(real.map((n) => [n.label, n.id]));
|
|
245
|
+
const edges = [
|
|
246
|
+
...graph.edges,
|
|
247
|
+
...graph.dangling.map((d) => ({ from: d.from, to: `ghost:${d.target}` })),
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
const width = Math.max(container.clientWidth || 900, 480);
|
|
251
|
+
const baseHeight = 620;
|
|
252
|
+
|
|
253
|
+
// Nodes with no links at all are laid out in their own strip rather than
|
|
254
|
+
// left in the simulation: a single unlinked node drifts to the far edge and
|
|
255
|
+
// squashes everything else when the layout is scaled to fit.
|
|
256
|
+
const degree = new Map(nodes.map((n) => [n.id, 0]));
|
|
257
|
+
for (const edge of edges) {
|
|
258
|
+
degree.set(edge.from, (degree.get(edge.from) || 0) + 1);
|
|
259
|
+
degree.set(edge.to, (degree.get(edge.to) || 0) + 1);
|
|
260
|
+
}
|
|
261
|
+
const linked = nodes.filter((n) => degree.get(n.id) > 0);
|
|
262
|
+
const unlinked = nodes.filter((n) => !degree.get(n.id));
|
|
263
|
+
|
|
264
|
+
// The strip grows with the number of rows it has to hold. A fixed height
|
|
265
|
+
// overflowed the canvas as soon as a project had more than one row of
|
|
266
|
+
// unlinked notes, dropping them behind the legend.
|
|
267
|
+
const perRow = Math.max(1, Math.floor((width - 120) / 160));
|
|
268
|
+
const stripRows = unlinked.length ? Math.ceil(unlinked.length / perRow) : 0;
|
|
269
|
+
const strip = stripRows ? 34 + stripRows * 46 : 0;
|
|
270
|
+
const legendRoom = 46;
|
|
271
|
+
const height = baseHeight + Math.max(0, strip - 104);
|
|
272
|
+
|
|
273
|
+
if (linked.length) {
|
|
274
|
+
const linkedIds = new Set(linked.map((n) => n.id));
|
|
275
|
+
const innerEdges = edges.filter((e) => linkedIds.has(e.from) && linkedIds.has(e.to));
|
|
276
|
+
layoutComponents(linked, innerEdges, { width, height: height - strip, repulsion: spread });
|
|
277
|
+
}
|
|
278
|
+
unlinked.forEach((node, i) => {
|
|
279
|
+
const row = Math.floor(i / perRow);
|
|
280
|
+
const inRow = Math.min(perRow, unlinked.length - row * perRow);
|
|
281
|
+
const slot = i % perRow;
|
|
282
|
+
node.x = (width / (inRow + 1)) * (slot + 1);
|
|
283
|
+
node.y = height - strip + 34 + row * 46;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const svg = svgEl('svg', {
|
|
287
|
+
viewBox: `0 0 ${width} ${height + (stripRows ? legendRoom : 0)}`,
|
|
288
|
+
preserveAspectRatio: 'xMidYMid meet',
|
|
289
|
+
});
|
|
290
|
+
svg.style.height = `${height + (stripRows ? legendRoom : 0)}px`;
|
|
291
|
+
const root = svgEl('g');
|
|
292
|
+
svg.append(root);
|
|
293
|
+
|
|
294
|
+
const marker = svgEl('marker', {
|
|
295
|
+
id: 'arrow', viewBox: '0 0 10 10', refX: 18, refY: 5,
|
|
296
|
+
markerWidth: 5, markerHeight: 5, orient: 'auto-start-reverse',
|
|
297
|
+
});
|
|
298
|
+
marker.append(svgEl('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'currentColor', opacity: '.45' }));
|
|
299
|
+
const defs = svgEl('defs');
|
|
300
|
+
defs.append(marker);
|
|
301
|
+
svg.append(defs);
|
|
302
|
+
|
|
303
|
+
const position = new Map(nodes.map((n) => [n.id, n]));
|
|
304
|
+
const edgeGroup = svgEl('g', { stroke: 'currentColor', 'stroke-opacity': '.28', 'marker-end': 'url(#arrow)' });
|
|
305
|
+
// Kept as pairs rather than relying on child order: edges whose endpoints are
|
|
306
|
+
// missing are skipped, so the indices would not line up.
|
|
307
|
+
const drawnEdges = [];
|
|
308
|
+
for (const edge of edges) {
|
|
309
|
+
const a = position.get(edge.from);
|
|
310
|
+
const b = position.get(edge.to);
|
|
311
|
+
if (!a || !b) continue;
|
|
312
|
+
const line = svgEl('line', {
|
|
313
|
+
class: 'edge',
|
|
314
|
+
x1: a.x, y1: a.y, x2: b.x, y2: b.y,
|
|
315
|
+
'stroke-dasharray': String(edge.to).startsWith('ghost:') ? '4 3' : '',
|
|
316
|
+
});
|
|
317
|
+
edgeGroup.append(line);
|
|
318
|
+
drawnEdges.push({ line, edge });
|
|
319
|
+
}
|
|
320
|
+
root.append(edgeGroup);
|
|
321
|
+
|
|
322
|
+
if (unlinked.length) {
|
|
323
|
+
const y = height - strip + 12;
|
|
324
|
+
root.append(svgEl('line', {
|
|
325
|
+
x1: 40, y1: y, x2: width - 40, y2: y,
|
|
326
|
+
stroke: 'currentColor', 'stroke-opacity': '.18', 'stroke-dasharray': '3 4',
|
|
327
|
+
}));
|
|
328
|
+
const caption = svgEl('text', { x: 40, y: y - 7, 'font-size': '10', fill: 'currentColor', 'fill-opacity': '.45' });
|
|
329
|
+
caption.textContent = `not linked to anything (${unlinked.length})`;
|
|
330
|
+
root.append(caption);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const groupsById = new Map();
|
|
334
|
+
const neighbours = new Map(nodes.map((n) => [n.id, new Set([n.id])]));
|
|
335
|
+
for (const edge of edges) {
|
|
336
|
+
neighbours.get(edge.from)?.add(edge.to);
|
|
337
|
+
neighbours.get(edge.to)?.add(edge.from);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
for (const node of nodes) {
|
|
341
|
+
const group = svgEl('g', { class: `node${node.id === selected ? ' hi' : ''}`, transform: `translate(${node.x},${node.y})` });
|
|
342
|
+
const radius = node.ghost ? 6 : 7 + Math.min(node.degree, 8) * 1.1;
|
|
343
|
+
|
|
344
|
+
// A generous invisible disc widening the target: at this density the drawn
|
|
345
|
+
// circles are only a few pixels across and are painfully hard to hit.
|
|
346
|
+
// Handlers go on the group, not on this disc - the visible circle paints
|
|
347
|
+
// over its centre, so a disc-level listener would only catch the ring
|
|
348
|
+
// around the dot and clicks on the node itself would do nothing.
|
|
349
|
+
const hit = svgEl('circle', { class: 'hit', r: Math.max(radius + 9, 15) });
|
|
350
|
+
group.append(hit);
|
|
351
|
+
|
|
352
|
+
const circle = svgEl('circle', {
|
|
353
|
+
r: radius,
|
|
354
|
+
fill: node.ghost ? 'transparent' : colorFor(node.type),
|
|
355
|
+
stroke: node.ghost ? '#b3261e' : node.status === 'orphan' ? '#a86a12' : 'rgba(0,0,0,.18)',
|
|
356
|
+
'stroke-dasharray': node.ghost ? '3 2' : node.status === 'orphan' ? '3 2' : '',
|
|
357
|
+
});
|
|
358
|
+
const label = svgEl('text', {
|
|
359
|
+
y: radius + 10,
|
|
360
|
+
'text-anchor': 'middle',
|
|
361
|
+
// Halo in the panel colour so overlapping labels stay readable.
|
|
362
|
+
stroke: 'var(--panel)',
|
|
363
|
+
'stroke-width': '2.5',
|
|
364
|
+
'paint-order': 'stroke',
|
|
365
|
+
'stroke-linejoin': 'round',
|
|
366
|
+
});
|
|
367
|
+
label.textContent = node.label.length > 20 ? `${node.label.slice(0, 18)}…` : node.label;
|
|
368
|
+
group.append(circle, label);
|
|
369
|
+
|
|
370
|
+
const title = svgEl('title');
|
|
371
|
+
title.textContent = node.ghost
|
|
372
|
+
? `${node.label} - referenced by a [[wikilink]] but no such memory exists`
|
|
373
|
+
: `${node.label}\n${node.type} · ${node.status} · ${node.degree} link(s)`;
|
|
374
|
+
group.append(title);
|
|
375
|
+
|
|
376
|
+
if (!node.ghost && onSelect) {
|
|
377
|
+
group.style.cursor = 'pointer';
|
|
378
|
+
group.addEventListener('click', (event) => { event.stopPropagation(); onSelect(node.id); });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Hovering focuses the node and its neighbours: the only practical way to
|
|
382
|
+
// read a dense cluster without zooming into it first.
|
|
383
|
+
group.addEventListener('pointerenter', () => focusOn(node.id));
|
|
384
|
+
group.addEventListener('pointerleave', () => focusOn(null));
|
|
385
|
+
|
|
386
|
+
groupsById.set(node.id, group);
|
|
387
|
+
root.append(group);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function focusOn(id) {
|
|
391
|
+
if (!id) {
|
|
392
|
+
for (const group of groupsById.values()) group.classList.remove('dim');
|
|
393
|
+
for (const { line } of drawnEdges) line.classList.remove('dim');
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const near = neighbours.get(id) || new Set([id]);
|
|
397
|
+
for (const [nodeId, group] of groupsById) group.classList.toggle('dim', !near.has(nodeId));
|
|
398
|
+
for (const { line, edge } of drawnEdges) {
|
|
399
|
+
line.classList.toggle('dim', edge.from !== id && edge.to !== id);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Pan and zoom.
|
|
404
|
+
let scale = 1;
|
|
405
|
+
let panX = 0;
|
|
406
|
+
let panY = 0;
|
|
407
|
+
const apply = () => root.setAttribute('transform', `translate(${panX},${panY}) scale(${scale})`);
|
|
408
|
+
|
|
409
|
+
svg.addEventListener('wheel', (event) => {
|
|
410
|
+
event.preventDefault();
|
|
411
|
+
const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12;
|
|
412
|
+
const next = Math.max(0.3, Math.min(4, scale * factor));
|
|
413
|
+
const rect = svg.getBoundingClientRect();
|
|
414
|
+
const mx = ((event.clientX - rect.left) / rect.width) * width;
|
|
415
|
+
const my = ((event.clientY - rect.top) / rect.height) * height;
|
|
416
|
+
panX = mx - ((mx - panX) / scale) * next;
|
|
417
|
+
panY = my - ((my - panY) / scale) * next;
|
|
418
|
+
scale = next;
|
|
419
|
+
apply();
|
|
420
|
+
}, { passive: false });
|
|
421
|
+
|
|
422
|
+
// Panning only engages once the pointer has actually moved. Capturing on
|
|
423
|
+
// pointerdown instead would retarget the pointer to the <svg>, and the click
|
|
424
|
+
// on a node would never reach it - which is why nodes were unclickable.
|
|
425
|
+
const DRAG_THRESHOLD = 4;
|
|
426
|
+
let pending = null;
|
|
427
|
+
let panning = false;
|
|
428
|
+
|
|
429
|
+
svg.addEventListener('pointerdown', (event) => {
|
|
430
|
+
pending = { x: event.clientX, y: event.clientY, panX, panY, pointerId: event.pointerId };
|
|
431
|
+
panning = false;
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
svg.addEventListener('pointermove', (event) => {
|
|
435
|
+
if (!pending) return;
|
|
436
|
+
const dx = event.clientX - pending.x;
|
|
437
|
+
const dy = event.clientY - pending.y;
|
|
438
|
+
if (!panning) {
|
|
439
|
+
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
440
|
+
panning = true;
|
|
441
|
+
svg.setPointerCapture(pending.pointerId);
|
|
442
|
+
svg.style.cursor = 'grabbing';
|
|
443
|
+
}
|
|
444
|
+
const rect = svg.getBoundingClientRect();
|
|
445
|
+
panX = pending.panX + (dx / rect.width) * width;
|
|
446
|
+
panY = pending.panY + (dy / rect.height) * height;
|
|
447
|
+
apply();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const endDrag = (event) => {
|
|
451
|
+
if (panning) {
|
|
452
|
+
try { svg.releasePointerCapture(event.pointerId); } catch { /* already released */ }
|
|
453
|
+
}
|
|
454
|
+
pending = null;
|
|
455
|
+
panning = false;
|
|
456
|
+
svg.style.cursor = 'grab';
|
|
457
|
+
};
|
|
458
|
+
svg.addEventListener('pointerup', endDrag);
|
|
459
|
+
svg.addEventListener('pointercancel', endDrag);
|
|
460
|
+
|
|
461
|
+
// Zoom helpers used by the toolbar.
|
|
462
|
+
const zoomBy = (factor) => {
|
|
463
|
+
const next = Math.max(0.3, Math.min(6, scale * factor));
|
|
464
|
+
const cx = width / 2;
|
|
465
|
+
const cy = height / 2;
|
|
466
|
+
panX = cx - ((cx - panX) / scale) * next;
|
|
467
|
+
panY = cy - ((cy - panY) / scale) * next;
|
|
468
|
+
scale = next;
|
|
469
|
+
apply();
|
|
470
|
+
};
|
|
471
|
+
const resetView = () => { scale = 1; panX = 0; panY = 0; apply(); };
|
|
472
|
+
|
|
473
|
+
container.append(svg);
|
|
474
|
+
|
|
475
|
+
const toolbar = document.createElement('div');
|
|
476
|
+
toolbar.className = 'graph-toolbar';
|
|
477
|
+
|
|
478
|
+
const makeButton = (label, title, onClick) => {
|
|
479
|
+
const button = document.createElement('button');
|
|
480
|
+
button.textContent = label;
|
|
481
|
+
button.title = title;
|
|
482
|
+
button.onclick = onClick;
|
|
483
|
+
return button;
|
|
484
|
+
};
|
|
485
|
+
toolbar.append(
|
|
486
|
+
makeButton('\u2212', 'Zoom out', () => zoomBy(1 / 1.25)),
|
|
487
|
+
makeButton('+', 'Zoom in', () => zoomBy(1.25)),
|
|
488
|
+
makeButton('Fit', 'Reset zoom and position', resetView),
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
const spreadLabel = document.createElement('label');
|
|
492
|
+
spreadLabel.title = 'How far apart the layout pushes nodes';
|
|
493
|
+
const slider = document.createElement('input');
|
|
494
|
+
slider.type = 'range';
|
|
495
|
+
slider.min = '1';
|
|
496
|
+
slider.max = '4';
|
|
497
|
+
slider.step = '0.2';
|
|
498
|
+
slider.value = String(spread);
|
|
499
|
+
// Re-laying out is cheap here (<70 nodes) so it can happen on release.
|
|
500
|
+
slider.addEventListener('change', () => {
|
|
501
|
+
if (onSpreadChange) onSpreadChange(Number(slider.value));
|
|
502
|
+
});
|
|
503
|
+
spreadLabel.append(document.createTextNode('spread'), slider);
|
|
504
|
+
toolbar.append(spreadLabel);
|
|
505
|
+
container.append(toolbar);
|
|
506
|
+
|
|
507
|
+
const legend = document.createElement('div');
|
|
508
|
+
legend.className = 'legend';
|
|
509
|
+
const items = [
|
|
510
|
+
...Object.entries(TYPE_COLORS).filter(([type]) => real.some((n) => n.type === type)),
|
|
511
|
+
].map(([type, color]) => `<span><i style="background:${color}"></i>${type}</span>`);
|
|
512
|
+
items.push('<span><i style="border:1.5px dashed #a86a12"></i>orphan</span>');
|
|
513
|
+
if (ghosts.length) items.push('<span><i style="border:1.5px dashed #b3261e"></i>missing target</span>');
|
|
514
|
+
legend.innerHTML = items.join('');
|
|
515
|
+
container.append(legend);
|
|
516
|
+
|
|
517
|
+
return byName;
|
|
518
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Claude Memory Admin</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css">
|
|
8
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>🧠</text></svg>">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="app">
|
|
12
|
+
<aside id="sidebar">
|
|
13
|
+
<header class="sidebar-head">
|
|
14
|
+
<button id="collapse" class="icon-btn" title="Hide the project list" aria-label="Hide the project list">◄</button>
|
|
15
|
+
<h1>Memory Admin</h1>
|
|
16
|
+
<p id="root-path" class="muted mono"></p>
|
|
17
|
+
<input id="search" type="search" placeholder="Search all memories ( / )" autocomplete="off" spellcheck="false">
|
|
18
|
+
<label class="toggle">
|
|
19
|
+
<input type="checkbox" id="show-all"> show projects without memory
|
|
20
|
+
</label>
|
|
21
|
+
</header>
|
|
22
|
+
<nav id="project-list" aria-label="Projects"></nav>
|
|
23
|
+
<footer class="sidebar-foot">
|
|
24
|
+
<a href="https://bitspire.ch/" target="_blank" rel="noopener noreferrer">
|
|
25
|
+
<span>Brought to you by</span>
|
|
26
|
+
<img src="/assets/bitspire-logo.webp" alt="BitSpire" width="96">
|
|
27
|
+
</a>
|
|
28
|
+
</footer>
|
|
29
|
+
</aside>
|
|
30
|
+
|
|
31
|
+
<button id="expand" class="expand-tab" title="Show the project list" aria-label="Show the project list" hidden>►</button>
|
|
32
|
+
|
|
33
|
+
<main id="main">
|
|
34
|
+
<div id="empty-state" class="empty">
|
|
35
|
+
<h2>Pick a project</h2>
|
|
36
|
+
<p>Each project keeps its memories in <code>~/.claude/projects/<slug>/memory/</code>, indexed by <code>MEMORY.md</code>.</p>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div id="search-view" hidden>
|
|
40
|
+
<header id="search-head">
|
|
41
|
+
<h2 id="search-title"></h2>
|
|
42
|
+
<button id="search-clear" class="btn small">Clear</button>
|
|
43
|
+
</header>
|
|
44
|
+
<section id="search-results"></section>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div id="project-view" hidden>
|
|
48
|
+
<header id="project-head">
|
|
49
|
+
<div class="project-head-row">
|
|
50
|
+
<h2 id="project-title"></h2>
|
|
51
|
+
<button id="delete-project" class="btn danger small">Delete all memory</button>
|
|
52
|
+
</div>
|
|
53
|
+
<p id="project-sub" class="muted mono"></p>
|
|
54
|
+
<div id="tabs" role="tablist"></div>
|
|
55
|
+
</header>
|
|
56
|
+
<section id="tab-content"></section>
|
|
57
|
+
</div>
|
|
58
|
+
</main>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div id="modal-root"></div>
|
|
62
|
+
<div id="toast-root" aria-live="polite"></div>
|
|
63
|
+
|
|
64
|
+
<script type="module" src="/app.mjs"></script>
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|