graphlin 0.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.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/.codex-plugin/plugin.json +29 -0
  3. package/.mcp.json +9 -0
  4. package/LICENSE +21 -0
  5. package/README.md +71 -0
  6. package/adapters/README.md +32 -0
  7. package/adapters/claude/hooks.json +10 -0
  8. package/adapters/claude/profile.json +18 -0
  9. package/adapters/codex/hooks.json +9 -0
  10. package/adapters/codex/profile.json +22 -0
  11. package/adapters/kiro/profile.json +8 -0
  12. package/mcp.json +11 -0
  13. package/package.json +114 -0
  14. package/plugin.json +20 -0
  15. package/runtime/collector/index.mjs +23 -0
  16. package/runtime/core/candidates.mjs +300 -0
  17. package/runtime/core/common.mjs +69 -0
  18. package/runtime/core/evidence.mjs +150 -0
  19. package/runtime/core/graph.mjs +398 -0
  20. package/runtime/core/index.mjs +4 -0
  21. package/runtime/core/lexical.mjs +255 -0
  22. package/runtime/core/privacy.mjs +206 -0
  23. package/runtime/core/tool-discovery.mjs +122 -0
  24. package/runtime/daemon/auth.mjs +50 -0
  25. package/runtime/daemon/connection-info.mjs +249 -0
  26. package/runtime/daemon/demo.mjs +195 -0
  27. package/runtime/daemon/diagnostics.mjs +404 -0
  28. package/runtime/daemon/export.mjs +7 -0
  29. package/runtime/daemon/ipc.mjs +28 -0
  30. package/runtime/daemon/lock.mjs +137 -0
  31. package/runtime/daemon/manager.mjs +320 -0
  32. package/runtime/daemon/paths.mjs +108 -0
  33. package/runtime/daemon/persistence.mjs +64 -0
  34. package/runtime/daemon/server.mjs +292 -0
  35. package/runtime/daemon/settings.mjs +103 -0
  36. package/runtime/jev/fixture.mjs +99 -0
  37. package/runtime/jev/index.mjs +784 -0
  38. package/runtime/jev/questions.mjs +268 -0
  39. package/runtime/jev/wire.mjs +152 -0
  40. package/runtime/pipeline.mjs +1071 -0
  41. package/runtime/web/app.js +2596 -0
  42. package/runtime/web/index.html +265 -0
  43. package/runtime/web/layout.js +336 -0
  44. package/runtime/web/sidebar.js +525 -0
  45. package/runtime/web/sketch.js +347 -0
  46. package/runtime/web/style.css +593 -0
  47. package/schemas/bundle.schema.json +243 -0
  48. package/schemas/event.schema.json +108 -0
  49. package/schemas/graph.schema.json +449 -0
  50. package/schemas/patch.schema.json +111 -0
  51. package/scripts/arguments.mjs +37 -0
  52. package/scripts/build-packages.mjs +160 -0
  53. package/scripts/collect.sh +23 -0
  54. package/scripts/collector.mjs +11 -0
  55. package/scripts/control.mjs +80 -0
  56. package/scripts/daemon.mjs +28 -0
  57. package/scripts/graphlin.mjs +112 -0
  58. package/scripts/onboarding.mjs +413 -0
  59. package/scripts/validate-packages.mjs +118 -0
  60. package/skills/graphlin/SKILL.md +103 -0
@@ -0,0 +1,525 @@
1
+ import { sketchOutline } from './sketch.js';
2
+
3
+ const SVG_NS = 'http://www.w3.org/2000/svg';
4
+ const THEMES = ['sketchbook', 'ocean', 'forest', 'sunset', 'berry', 'sepia', 'blueprint', 'midnight'];
5
+ const ROLES = ['client', 'service', 'datastore', 'queue', 'external', 'module', 'function', 'class', 'interface', 'event', 'configuration', 'package'];
6
+ const ROLE_SHAPES = {
7
+ client: 'browser', service: 'component', datastore: 'cylinder', queue: 'queue', external: 'cloud',
8
+ module: 'rect', function: 'hexagon', class: 'class_box', interface: 'interface_box',
9
+ event: 'document', configuration: 'parallelogram', package: 'folder',
10
+ };
11
+ const LIMITS = { hooks: 200, frames: 101, cards: 30, tiles: 12, sessions: 16 };
12
+ const CHANGES = { added: ['+', 'Added'], removed: ['−', 'Removed'], changed: ['↻', 'Changed'] };
13
+ const CLAIM_FIELDS = ['id', 'label', 'evidenceState', 'classification', 'validity'];
14
+ const NODE_FIELDS = [...CLAIM_FIELDS, 'kind', 'shape'];
15
+ const EDGE_FIELDS = [...CLAIM_FIELDS, 'source', 'target', 'relation'];
16
+ const list = value => Array.isArray(value) ? value : [];
17
+ const text = (value, max = 180) => typeof value === 'string'
18
+ ? value.replace(/[\u0000-\u0008\u000b-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, '').slice(0, max) : '';
19
+ const revision = value => Number.isSafeInteger(value) && value >= 0;
20
+ const timestamp = value => {
21
+ const time = typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : NaN;
22
+ return Number.isFinite(time) && Math.abs(time) <= 8.64e15 ? time : null;
23
+ };
24
+ const shortSession = value => text(value).replace(/^session-/, '').slice(-8);
25
+ const itemKey = (type, id) => JSON.stringify([type, id]);
26
+
27
+ function html(document, tag, value, className) {
28
+ const element = document.createElement(tag);
29
+ if (value !== undefined) element.textContent = value;
30
+ if (className) element.setAttribute('class', className);
31
+ return element;
32
+ }
33
+
34
+ function svg(document, tag, attributes) {
35
+ const element = document.createElementNS(SVG_NS, tag);
36
+ for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value);
37
+ return element;
38
+ }
39
+
40
+ function timeLabel(document, value) {
41
+ const at = timestamp(value);
42
+ const element = html(document, 'time', at === null ? 'Time unavailable'
43
+ : new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }));
44
+ if (at !== null) {
45
+ element.setAttribute('datetime', new Date(at).toISOString());
46
+ element.setAttribute('title', new Date(at).toLocaleString());
47
+ }
48
+ return element;
49
+ }
50
+
51
+ // Keep only visible architectural meaning. Layout, activity, confidence and
52
+ // changing source excerpts must not produce a strip of fictional shape edits.
53
+ function semanticGraph(graph) {
54
+ const claim = item => ({
55
+ id: text(item.id), label: text(item.label), evidenceState: text(item.evidenceState, 40),
56
+ classification: text(item.classification, 40), validity: text(item.validity, 40),
57
+ });
58
+ return {
59
+ nodes: list(graph?.nodes).slice(0, 500).map(item => ({
60
+ ...claim(item), kind: ROLES.includes(item.kind) ? item.kind : 'module', shape: text(item.shape, 32),
61
+ })),
62
+ edges: list(graph?.edges).slice(0, 1500).map(item => ({
63
+ ...claim(item), source: text(item.source), target: text(item.target), relation: text(item.relation, 40),
64
+ })),
65
+ };
66
+ }
67
+
68
+ function sameItem(cached, incoming, fields) {
69
+ if (!cached || !incoming) return false;
70
+ return fields.every(field => cached[field] === (field === 'kind'
71
+ ? ROLES.includes(incoming.kind) ? incoming.kind : 'module'
72
+ : text(incoming[field], field === 'shape' ? 32 : ['evidenceState', 'classification', 'validity', 'relation'].includes(field) ? 40 : 180)));
73
+ }
74
+
75
+ function sameGraph(cached, incoming) {
76
+ const nodes = list(incoming?.nodes), edges = list(incoming?.edges);
77
+ return cached.nodes.length === Math.min(nodes.length, 500) && cached.edges.length === Math.min(edges.length, 1500)
78
+ && cached.nodes.every((item, index) => sameItem(item, nodes[index], NODE_FIELDS))
79
+ && cached.edges.every((item, index) => sameItem(item, edges[index], EDGE_FIELDS));
80
+ }
81
+
82
+ function frameStamp(frame) {
83
+ // A retained revision is immutable architectural history. Its timestamp and
84
+ // counts detect replacement; the current graph and visible labels are also
85
+ // checked because privacy projection can change without a new revision.
86
+ return `${timestamp(frame.at)}:${list(frame.graph.nodes).length}:${list(frame.graph.edges).length}`;
87
+ }
88
+
89
+ function renderSignature(entry) {
90
+ return JSON.stringify([entry.revision, entry.at, entry.beforeRevision, entry.baseline,
91
+ entry.baseline ? [entry.graph.nodes.length, entry.graph.edges.length] : [
92
+ Object.keys(CHANGES).map(kind => entry.changes.filter(change => change.change === kind).length),
93
+ entry.changes.slice(0, LIMITS.tiles),
94
+ ]]);
95
+ }
96
+
97
+ function differences(before, after) {
98
+ const changes = [];
99
+ const beforeNames = new Map(before.nodes.map(node => [node.id, node.label || node.id]));
100
+ const afterNames = new Map(after.nodes.map(node => [node.id, node.label || node.id]));
101
+ function changeItem(type, change, item, names) {
102
+ const label = type === 'edge'
103
+ ? `${names.get(item.source) || item.source} ${item.label || item.relation.replaceAll('_', ' ')} ${names.get(item.target) || item.target}`
104
+ : item.label || item.id;
105
+ return { type, change, item, label };
106
+ }
107
+ for (const [type, collection] of [['node', 'nodes'], ['edge', 'edges']]) {
108
+ const previous = new Map(before[collection].map(item => [item.id, item]));
109
+ const current = new Map(after[collection].map(item => [item.id, item]));
110
+ for (const item of after[collection]) {
111
+ const older = previous.get(item.id);
112
+ if (!older) changes.push(changeItem(type, 'added', item, afterNames));
113
+ else if (JSON.stringify(older) !== JSON.stringify(item)) changes.push(changeItem(type, 'changed', item, afterNames));
114
+ }
115
+ for (const item of before[collection]) if (!current.has(item.id)) changes.push(changeItem(type, 'removed', item, beforeNames));
116
+ }
117
+ return changes;
118
+ }
119
+
120
+ function miniature(document, change) {
121
+ const { item, type } = change;
122
+ const image = svg(document, 'svg', {
123
+ viewBox: '-20 -20 232 144', 'aria-hidden': 'true', focusable: 'false', class: 'change-miniature',
124
+ });
125
+ if (type === 'edge') {
126
+ image.append(svg(document, 'path', {
127
+ d: 'M 9 56 C 60 51 122 57 177 48 M 156 32 L 180 48 L 160 65',
128
+ class: 'miniature-edge',
129
+ }));
130
+ return image;
131
+ }
132
+ const outlines = sketchOutline(item.shape, item.id);
133
+ const paths = outlines.length ? outlines : sketchOutline(ROLE_SHAPES[item.kind], item.id);
134
+ paths.forEach((d, index) => image.append(svg(document, 'path', {
135
+ d, class: index ? 'miniature-outline miniature-second' : 'miniature-outline',
136
+ })));
137
+ const details = {
138
+ cylinder: 'M 0 17 C 0 39 190 39 190 17',
139
+ browser: 'M 0 23 L 190 23 M 13 12 L 19 12 M 28 12 L 34 12',
140
+ class_box: 'M 0 35 L 190 35 M 0 68 L 190 68',
141
+ interface_box: 'M 0 35 L 190 35',
142
+ queue: 'M 30 0 L 30 104 M 160 0 L 160 104 M 75 52 L 116 52 M 101 38 L 117 52 L 101 66',
143
+ document: 'M 167 0 L 167 23 L 190 23 M 24 47 L 156 47 M 24 68 L 132 68',
144
+ }[item.shape || ROLE_SHAPES[item.kind]];
145
+ if (details) image.append(svg(document, 'path', { d: details, class: 'miniature-detail' }));
146
+ return image;
147
+ }
148
+
149
+ /**
150
+ * Render already-normalized snapshots. Hooks remain project-wide and live;
151
+ * shape history belongs only to the snapshot's project and selected session.
152
+ * onInspect(type, id) opens a current node/edge. onReplay(revision) opens a
153
+ * retained historical frame, including the frame before a removed item.
154
+ */
155
+ export function createLiveSidebar({ onInspect, onReplay } = {}) {
156
+ const document = globalThis.document;
157
+ const ids = ['live-sidebar', 'sidebar-hooks', 'sidebar-hook-list', 'sidebar-hook-count',
158
+ 'sidebar-hook-dot', 'sidebar-hook-coverage', 'sidebar-hook-empty', 'sidebar-history',
159
+ 'sidebar-change-list', 'sidebar-change-count', 'sidebar-change-note', 'sidebar-change-empty'];
160
+ const elements = Object.fromEntries(ids.map(id => [id, document?.getElementById(id)]));
161
+ if (ids.some(id => !elements[id])) return { update() {}, destroy() {} };
162
+ const $ = id => elements[id];
163
+ const histories = new Map();
164
+ const hookRows = new Map();
165
+ const cards = new Map();
166
+ const actions = new WeakMap();
167
+ let currentItems = new Set();
168
+ let availableRevisions = new Set();
169
+ let availableSignature = '';
170
+ let identity = null;
171
+ let hookIdentity = null;
172
+ let hookKeys = new Set();
173
+ let pulse = null;
174
+ let destroyed = false;
175
+ function pulseReceipt() {
176
+ const reduced = globalThis.window?.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
177
+ if (reduced || document.hidden) return;
178
+ pulse?.cancel();
179
+ pulse = $('sidebar-hook-dot').animate?.([
180
+ { transform: 'scale(1)', boxShadow: '0 0 0 0 currentColor' },
181
+ { transform: 'scale(1.35)', boxShadow: '0 0 0 5px transparent', offset: .5 },
182
+ { transform: 'scale(1)', boxShadow: '0 0 0 8px transparent' },
183
+ ], { duration: 700, easing: 'ease-out' }) ?? null;
184
+ }
185
+
186
+ function hooks(snapshot, replay) {
187
+ const detailed = Array.isArray(snapshot.hookEvents);
188
+ const incoming = detailed ? snapshot.hookEvents : list(snapshot.activity);
189
+ const sorted = incoming.map((event, index) => ({ event, index })).sort((a, b) => {
190
+ if (detailed && revision(a.event.receipt) && revision(b.event.receipt)) return b.event.receipt - a.event.receipt;
191
+ return (timestamp(b.event.at) ?? 0) - (timestamp(a.event.at) ?? 0)
192
+ || (b.event.sequence || 0) - (a.event.sequence || 0) || (detailed ? a.index - b.index : b.index - a.index);
193
+ }).slice(0, LIMITS.hooks);
194
+ const nextIdentity = JSON.stringify([snapshot.projectId, detailed ? 'hooks' : snapshot.sessionId, detailed]);
195
+ const nextKeys = new Set();
196
+ const rows = [];
197
+ for (const { event, index } of sorted) {
198
+ const key = JSON.stringify([nextIdentity, detailed && revision(event.receipt) ? event.receipt
199
+ : [text(event.sessionId), text(event.id), text(event.kind), event.sequence, event.at, index]]);
200
+ if (nextKeys.has(key)) continue;
201
+ nextKeys.add(key);
202
+ const signature = JSON.stringify([event.label, event.kind, event.toolCategory, event.state, event.at, event.sessionId]);
203
+ let row = hookRows.get(key);
204
+ if (!row || row.signature !== signature) {
205
+ const element = html(document, 'li', undefined, 'hook-receipt');
206
+ element.setAttribute('data-receipt', String(event.receipt ?? event.sequence ?? ''));
207
+ const heading = html(document, 'div', undefined, 'hook-receipt-heading');
208
+ const state = ['succeeded', 'failed', 'pending', 'interrupted', 'unresolved', 'observed'].includes(event.state) ? event.state : 'observed';
209
+ const dot = html(document, 'i', undefined, 'event-dot');
210
+ dot.setAttribute('aria-hidden', 'true');
211
+ dot.dataset.state = state;
212
+ heading.append(dot, html(document, 'strong', text(event.label) || 'Hook received'), timeLabel(document, event.at));
213
+ const meta = html(document, 'p', undefined, 'hook-receipt-meta');
214
+ const category = text(event.toolCategory, 50);
215
+ meta.append(html(document, 'span', [text(event.kind, 60), category && category !== 'other' ? category : ''].filter(Boolean).join(' · ')));
216
+ meta.append(html(document, 'span', shortSession(event.sessionId) ? `Session ${shortSession(event.sessionId)}` : 'Project'));
217
+ element.append(heading, meta);
218
+ if (state === 'failed' || state === 'interrupted' || state === 'unresolved') {
219
+ element.append(html(document, 'span', state === 'unresolved' ? 'Outcome not known' : state[0].toUpperCase() + state.slice(1), 'hook-outcome'));
220
+ }
221
+ row = { element, signature };
222
+ hookRows.set(key, row);
223
+ }
224
+ rows.push(row.element);
225
+ }
226
+ const hasNew = hookIdentity === nextIdentity && [...nextKeys].some(key => !hookKeys.has(key));
227
+ hookIdentity = nextIdentity;
228
+ hookKeys = nextKeys;
229
+ for (const [key, row] of hookRows) if (!nextKeys.has(key)) { row.element.remove(); hookRows.delete(key); }
230
+ reconcileChildren($('sidebar-hook-list'), rows);
231
+ $('sidebar-hook-count').textContent = String(rows.length);
232
+ $('sidebar-hook-dot').dataset.state = rows.length ? 'received' : 'waiting';
233
+ $('sidebar-hook-coverage').textContent = detailed
234
+ ? `Received hooks across this project. Up to ${LIMITS.hooks} shown.${replay ? ' This feed stays live during replay.' : ''}`
235
+ : 'Detailed hook feed needs a server restart. Showing the selected session’s activity; this is not a list of every received hook.';
236
+ $('sidebar-hook-empty').hidden = rows.length > 0;
237
+ $('sidebar-hook-empty').textContent = detailed
238
+ ? 'Waiting for hooks. Work in a connected agent to see each receipt here.'
239
+ : 'No session activity received yet.';
240
+ if (hasNew) pulseReceipt();
241
+ }
242
+
243
+ function actionLabel(action) {
244
+ if (action.kind === 'revision') return `View diagram at revision ${action.revision}`;
245
+ const { change, revision: at, beforeRevision } = action;
246
+ if (change.change !== 'removed' && currentItems.has(itemKey(change.type, change.item.id)) && typeof onInspect === 'function') {
247
+ return `Inspect current ${change.type === 'edge' ? 'connection' : 'component'} ${change.label}`;
248
+ }
249
+ const target = change.change === 'removed' ? beforeRevision : at;
250
+ return availableRevisions.has(target) && typeof onReplay === 'function'
251
+ ? `View ${change.change === 'removed' ? 'removed ' : ''}${change.label} at revision ${target}`
252
+ : `Historical view of ${change.label} is no longer available`;
253
+ }
254
+
255
+ function refreshAction(button, action) {
256
+ const current = action.kind !== 'revision' && action.change.change !== 'removed'
257
+ && currentItems.has(itemKey(action.change.type, action.change.item.id)) && typeof onInspect === 'function';
258
+ const target = action.kind === 'revision' || action.change.change !== 'removed' ? action.revision : action.beforeRevision;
259
+ button.disabled = !current && !(availableRevisions.has(target) && typeof onReplay === 'function');
260
+ button.setAttribute('aria-label', actionLabel(action));
261
+ button.setAttribute('title', actionLabel(action));
262
+ }
263
+
264
+ function historyCard(entry) {
265
+ const element = html(document, 'li', undefined, 'diagram-change');
266
+ element.setAttribute('data-revision', entry.revision);
267
+ const heading = html(document, 'div', undefined, 'change-heading');
268
+ const replayButton = html(document, 'button', `Revision ${entry.revision}`, 'change-revision');
269
+ replayButton.setAttribute('type', 'button');
270
+ const revisionAction = { kind: 'revision', revision: entry.revision };
271
+ actions.set(replayButton, revisionAction);
272
+ const buttons = [[replayButton, revisionAction]];
273
+ heading.append(replayButton, timeLabel(document, entry.at));
274
+ element.append(heading);
275
+ if (entry.baseline) {
276
+ element.classList.add('history-baseline');
277
+ element.append(html(document, 'p',
278
+ `History baseline · ${entry.graph.nodes.length} components, ${entry.graph.edges.length} connections. Earlier changes are unavailable.`,
279
+ 'change-baseline-note'));
280
+ return { element, buttons };
281
+ }
282
+ const totals = Object.keys(CHANGES).map(kind => {
283
+ const count = entry.changes.filter(change => change.change === kind).length;
284
+ return count ? `${count} ${kind}` : '';
285
+ }).filter(Boolean).join(' · ');
286
+ element.append(html(document, 'p', totals, 'change-totals'));
287
+ if (entry.revision > entry.beforeRevision + 1) {
288
+ element.append(html(document, 'p', `Net changes since revision ${entry.beforeRevision}; intervening revisions are unavailable.`, 'change-gap'));
289
+ }
290
+ const tiles = html(document, 'ul', undefined, 'change-tiles');
291
+ tiles.setAttribute('aria-label', `Changes in revision ${entry.revision}`);
292
+ for (const change of entry.changes.slice(0, LIMITS.tiles)) {
293
+ const tile = html(document, 'li', undefined, 'change-tile');
294
+ tile.dataset.change = change.change;
295
+ tile.dataset.kind = change.type === 'edge' ? 'module' : change.item.kind;
296
+ tile.dataset.type = change.type;
297
+ const button = html(document, 'button', undefined, 'change-item');
298
+ button.setAttribute('type', 'button');
299
+ const [symbol, name] = CHANGES[change.change];
300
+ const label = html(document, 'span', undefined, 'change-item-copy');
301
+ label.append(html(document, 'span', `${symbol} ${name}`, 'change-verb'));
302
+ label.append(html(document, 'strong', change.label));
303
+ if (change.type === 'edge') label.append(html(document, 'span', 'Connection', 'change-item-kind'));
304
+ button.append(miniature(document, change), label);
305
+ const action = { kind: 'item', change, revision: entry.revision, beforeRevision: entry.beforeRevision };
306
+ actions.set(button, action);
307
+ buttons.push([button, action]);
308
+ tile.append(button);
309
+ tiles.append(tile);
310
+ }
311
+ element.append(tiles);
312
+ if (entry.changes.length > LIMITS.tiles) {
313
+ element.append(html(document, 'p', `+ ${entry.changes.length - LIMITS.tiles} more changes in this revision`, 'change-overflow'));
314
+ }
315
+ return { element, buttons };
316
+ }
317
+
318
+ function history(snapshot, replay) {
319
+ const nextIdentity = JSON.stringify([snapshot.projectId, snapshot.sessionId]);
320
+ // Session IDs can be reused after retention eviction or a daemon restart.
321
+ // Forget disappeared sessions before considering a later appearance.
322
+ if (Array.isArray(snapshot.sessions)) {
323
+ const retained = new Set(snapshot.sessions.map(session => session.id));
324
+ for (const [key, cached] of histories) {
325
+ if (cached.projectId === snapshot.projectId && !retained.has(cached.sessionId)) histories.delete(key);
326
+ }
327
+ }
328
+ let cache = histories.get(nextIdentity);
329
+ const reset = cache && revision(snapshot.graph?.revision) && snapshot.graph.revision < cache.lastRevision;
330
+ if (reset) { histories.delete(nextIdentity); cache = null; }
331
+ const switched = identity !== nextIdentity || !cache;
332
+ identity = nextIdentity;
333
+ cache ??= {
334
+ projectId: snapshot.projectId, sessionId: snapshot.sessionId, lastRevision: -1,
335
+ frames: new Map(), visible: [], currentGraph: null,
336
+ };
337
+ const frames = cache.frames;
338
+ histories.delete(identity);
339
+ histories.set(identity, cache);
340
+ while (histories.size > LIMITS.sessions) histories.delete(histories.keys().next().value);
341
+ const currentRevision = snapshot.graph?.revision;
342
+ if (revision(currentRevision)) cache.lastRevision = currentRevision;
343
+ const provided = new Map(list(snapshot.history).slice(-LIMITS.frames)
344
+ .filter(frame => revision(frame?.revision) && frame.graph && frame.revision <= currentRevision)
345
+ .map(frame => [frame.revision, frame]));
346
+ if (revision(currentRevision)) provided.set(currentRevision, {
347
+ revision: currentRevision, graph: snapshot.graph, at: provided.get(currentRevision)?.at ?? null,
348
+ });
349
+ const nextAvailable = JSON.stringify([...provided.keys()].sort((a, b) => a - b));
350
+ const availabilityChanged = switched || nextAvailable !== availableSignature;
351
+ if (availabilityChanged) {
352
+ availableSignature = nextAvailable;
353
+ availableRevisions = new Set(provided.keys());
354
+ }
355
+ const changed = new Set();
356
+ const check = new Set([currentRevision]);
357
+ const cachedCurrent = frames.get(currentRevision);
358
+ const sameCurrent = cachedCurrent && sameGraph(cachedCurrent.graph, snapshot.graph);
359
+ let projectionChanged = cachedCurrent && !sameCurrent;
360
+ for (const [number, frame] of provided) {
361
+ if (!frames.has(number) || frames.get(number).stamp !== frameStamp(frame) || switched) check.add(number);
362
+ }
363
+ // A hook-only snapshot may be freshly parsed JSON, so reference equality
364
+ // cannot identify unchanged history. Inspect only displayed items, by
365
+ // cached index, to honor privacy reprojection without traversing all frames.
366
+ for (const entry of cache.visible) {
367
+ for (const change of (entry.changes ?? []).slice(0, LIMITS.tiles)) {
368
+ const number = change.change === 'removed' ? entry.beforeRevision : entry.revision;
369
+ const cached = frames.get(number), raw = provided.get(number)?.graph;
370
+ if (!cached || !raw || check.has(number)) continue;
371
+ const collection = change.type === 'node' ? 'nodes' : 'edges';
372
+ const index = cached.positions[collection].get(change.item.id);
373
+ if (!sameItem(cached.graph[collection][index], raw[collection]?.[index], change.type === 'node' ? NODE_FIELDS : EDGE_FIELDS)) {
374
+ check.add(number);
375
+ projectionChanged = true;
376
+ continue;
377
+ }
378
+ if (change.type === 'edge') {
379
+ for (const id of [change.item.source, change.item.target]) {
380
+ const position = cached.positions.nodes.get(id);
381
+ if (!sameItem(cached.graph.nodes[position], raw.nodes?.[position], NODE_FIELDS)) {
382
+ check.add(number);
383
+ projectionChanged = true;
384
+ }
385
+ }
386
+ }
387
+ }
388
+ }
389
+ // Label projection applies across history. Recheck it once when an actual
390
+ // displayed change is detected, including when the live graph is empty.
391
+ if (projectionChanged) for (const number of provided.keys()) check.add(number);
392
+ // Refresh the preceding projection before comparing a newly arrived frame.
393
+ // This is one adjacent graph, not a replay of every historical comparison.
394
+ const incomingOrder = [...provided.keys()].sort((a, b) => a - b);
395
+ incomingOrder.forEach((number, index) => {
396
+ if (!frames.has(number) && index > 0) check.add(incomingOrder[index - 1]);
397
+ });
398
+ for (const [number, frame] of provided) {
399
+ const previous = frames.get(number);
400
+ const at = timestamp(frame.at) ?? previous?.at ?? null;
401
+ const different = !previous || check.has(number)
402
+ && !(number === currentRevision ? sameCurrent : sameGraph(previous.graph, frame.graph));
403
+ if (different || previous.at !== at) changed.add(number);
404
+ if (different) {
405
+ const graph = semanticGraph(frame.graph);
406
+ frames.set(number, {
407
+ revision: number, at, graph, stamp: frameStamp(frame),
408
+ positions: {
409
+ nodes: new Map(graph.nodes.map((item, index) => [item.id, index])),
410
+ edges: new Map(graph.edges.map((item, index) => [item.id, index])),
411
+ },
412
+ });
413
+ } else {
414
+ previous.at = at;
415
+ previous.stamp = frameStamp(frame);
416
+ }
417
+ }
418
+ const currentGraph = frames.get(currentRevision)?.graph;
419
+ const currentChanged = switched || cache.currentGraph !== currentGraph;
420
+ if (currentChanged) {
421
+ currentItems = new Set([
422
+ ...list(currentGraph?.nodes).map(item => itemKey('node', item.id)),
423
+ ...list(currentGraph?.edges).map(item => itemKey('edge', item.id)),
424
+ ]);
425
+ cache.currentGraph = currentGraph;
426
+ }
427
+ $('sidebar-change-note').textContent = replay
428
+ ? 'Newest changes first. This history stays live while you replay a revision.'
429
+ : 'Newest changes first. Select a shape to inspect it, or a revision to replay.';
430
+ if (!switched && !changed.size && !availabilityChanged && !currentChanged) return;
431
+ const ordered = [...frames.values()].sort((a, b) => a.revision - b.revision);
432
+ for (const older of ordered.splice(0, Math.max(0, ordered.length - LIMITS.frames))) frames.delete(older.revision);
433
+ const entries = [];
434
+ for (let index = 0; index < ordered.length; index++) {
435
+ const frame = ordered[index];
436
+ const previous = ordered[index - 1];
437
+ if (frame.beforeGraph !== previous?.graph || frame.entryGraph !== frame.graph || frame.entryAt !== frame.at) {
438
+ frame.beforeGraph = previous?.graph;
439
+ frame.entryGraph = frame.graph;
440
+ frame.entryAt = frame.at;
441
+ if (!previous) {
442
+ frame.entry = frame.graph.nodes.length || frame.graph.edges.length
443
+ ? { revision: frame.revision, at: frame.at, graph: frame.graph, baseline: true } : null;
444
+ } else {
445
+ const changes = differences(previous.graph, frame.graph);
446
+ frame.entry = changes.length
447
+ ? { revision: frame.revision, at: frame.at, beforeRevision: previous.revision, changes } : null;
448
+ }
449
+ }
450
+ if (frame.entry) entries.push(frame.entry);
451
+ }
452
+ const visible = entries.slice(-LIMITS.cards).reverse();
453
+ cache.visible = visible;
454
+ const keys = new Set();
455
+ const rows = [];
456
+ for (const entry of visible) {
457
+ const key = JSON.stringify([identity, entry.revision]);
458
+ keys.add(key);
459
+ let card = cards.get(key);
460
+ const signature = card?.entry === entry ? card.signature : renderSignature(entry);
461
+ if (!card || card.signature !== signature) {
462
+ const fresh = !card;
463
+ card?.element.remove();
464
+ card = { ...historyCard(entry), signature };
465
+ cards.set(key, card);
466
+ if (fresh && !switched && !replay && !entry.baseline) card.element.classList.add('is-new-revision');
467
+ }
468
+ card.entry = entry;
469
+ for (const [button, action] of card.buttons) refreshAction(button, action);
470
+ rows.push(card.element);
471
+ }
472
+ for (const [key, card] of cards) if (!keys.has(key)) { card.element.remove(); cards.delete(key); }
473
+ reconcileChildren($('sidebar-change-list'), rows);
474
+ $('sidebar-change-count').textContent = String(visible.filter(entry => !entry.baseline).length);
475
+ $('sidebar-change-empty').hidden = visible.length > 0;
476
+ $('sidebar-change-empty').textContent = 'Shape changes will stack here as this session discovers or changes the architecture.';
477
+ }
478
+
479
+ function activate(event) {
480
+ for (let target = event.target; target && target !== $('sidebar-change-list'); target = target.parentElement) {
481
+ const action = actions.get(target);
482
+ if (!action || target.disabled) continue;
483
+ if (action.kind === 'revision') onReplay?.(action.revision);
484
+ else if (action.change.change !== 'removed' && currentItems.has(itemKey(action.change.type, action.change.item.id))
485
+ && typeof onInspect === 'function') onInspect(action.change.type, action.change.item.id);
486
+ else {
487
+ const at = action.change.change === 'removed' ? action.beforeRevision : action.revision;
488
+ if (availableRevisions.has(at)) onReplay?.(at);
489
+ }
490
+ return;
491
+ }
492
+ }
493
+ $('sidebar-change-list').addEventListener('click', activate);
494
+
495
+ return {
496
+ update(snapshot, { replay = false, theme = 'sketchbook' } = {}) {
497
+ if (destroyed || !snapshot) return;
498
+ $('sidebar-history').dataset.theme = THEMES.includes(theme) ? theme : 'sketchbook';
499
+ hooks(snapshot, replay);
500
+ history(snapshot, replay);
501
+ },
502
+ destroy() {
503
+ if (destroyed) return;
504
+ destroyed = true;
505
+ pulse?.cancel();
506
+ $('sidebar-change-list').removeEventListener('click', activate);
507
+ $('sidebar-hook-list').replaceChildren();
508
+ $('sidebar-change-list').replaceChildren();
509
+ histories.clear();
510
+ hookRows.clear();
511
+ cards.clear();
512
+ hookKeys.clear();
513
+ currentItems.clear();
514
+ availableRevisions.clear();
515
+ },
516
+ };
517
+ }
518
+
519
+ function reconcileChildren(parent, children) {
520
+ // Keep existing buttons attached so a status update cannot steal focus.
521
+ children.forEach((child, index) => {
522
+ if (parent.children[index] !== child) parent.insertBefore(child, parent.children[index] ?? null);
523
+ });
524
+ while (parent.children.length > children.length) parent.children[parent.children.length - 1].remove();
525
+ }