graphlin 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +12 -3
  4. package/docs/decision-service.md +393 -0
  5. package/docs/extension-authoring.md +553 -0
  6. package/docs/model-api.md +293 -0
  7. package/docs/usage.md +465 -0
  8. package/docs/visualizer-views.md +199 -0
  9. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  10. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  11. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  12. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  13. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  14. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  15. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  16. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  17. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  18. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  19. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  20. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  21. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  22. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  23. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  24. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  25. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  26. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  27. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  28. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  29. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  30. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  31. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  32. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  33. package/package.json +74 -9
  34. package/plugin.json +4 -2
  35. package/runtime/core/evidence.mjs +43 -9
  36. package/runtime/core/graph.mjs +11 -6
  37. package/runtime/core/privacy.mjs +1 -0
  38. package/runtime/daemon/auth.mjs +7 -3
  39. package/runtime/daemon/diagnostics.mjs +1 -1
  40. package/runtime/daemon/extension-api.mjs +203 -0
  41. package/runtime/daemon/lineage.mjs +70 -0
  42. package/runtime/daemon/manager.mjs +9 -6
  43. package/runtime/daemon/model-api.mjs +728 -0
  44. package/runtime/daemon/model-persistence.mjs +220 -0
  45. package/runtime/daemon/server.mjs +70 -12
  46. package/runtime/daemon/settings.mjs +11 -3
  47. package/runtime/decisions/broker.mjs +349 -0
  48. package/runtime/decisions/contracts.mjs +179 -0
  49. package/runtime/decisions/evaluation.mjs +305 -0
  50. package/runtime/decisions/faults.mjs +32 -0
  51. package/runtime/decisions/index.mjs +818 -0
  52. package/runtime/decisions/profiles.mjs +93 -0
  53. package/runtime/decisions/questions.mjs +268 -0
  54. package/runtime/discovery/index.mjs +2 -0
  55. package/runtime/discovery/inventory.mjs +160 -0
  56. package/runtime/discovery/parser.mjs +40 -0
  57. package/runtime/discovery/structure.mjs +232 -0
  58. package/runtime/extensions/contracts.mjs +59 -0
  59. package/runtime/extensions/frame.mjs +64 -0
  60. package/runtime/extensions/index.mjs +9 -0
  61. package/runtime/extensions/manifest.mjs +95 -0
  62. package/runtime/extensions/packages.mjs +222 -0
  63. package/runtime/extensions/profiles.mjs +36 -0
  64. package/runtime/extensions/projection.mjs +130 -0
  65. package/runtime/extensions/registry.mjs +285 -0
  66. package/runtime/extensions/scene.mjs +105 -0
  67. package/runtime/extensions/sdk.d.ts +205 -0
  68. package/runtime/extensions/sdk.mjs +88 -0
  69. package/runtime/jev/index.mjs +13 -777
  70. package/runtime/jev/provider.mjs +101 -0
  71. package/runtime/jev/questions.mjs +16 -258
  72. package/runtime/jev/wire.mjs +17 -25
  73. package/runtime/model/changes.mjs +42 -0
  74. package/runtime/model/history.mjs +124 -0
  75. package/runtime/model/index.mjs +2 -0
  76. package/runtime/model/project-model.mjs +889 -0
  77. package/runtime/model/records.mjs +239 -0
  78. package/runtime/pipeline.mjs +127 -48
  79. package/runtime/platform.mjs +254 -0
  80. package/runtime/visualizers/blocks.mjs +5 -0
  81. package/runtime/visualizers/c4.mjs +52 -0
  82. package/runtime/visualizers/changes.mjs +24 -0
  83. package/runtime/visualizers/code.mjs +5 -0
  84. package/runtime/visualizers/index.mjs +23 -0
  85. package/runtime/visualizers/structure.mjs +120 -0
  86. package/runtime/visualizers/timeline.mjs +66 -0
  87. package/runtime/web/app.js +225 -63
  88. package/runtime/web/extension-frame.js +128 -0
  89. package/runtime/web/index.html +36 -1
  90. package/runtime/web/model-client.js +162 -0
  91. package/runtime/web/platform.js +337 -0
  92. package/runtime/web/scene.js +111 -0
  93. package/runtime/web/style.css +49 -0
  94. package/schemas/graph.schema.json +4 -1
  95. package/scripts/arguments.mjs +5 -1
  96. package/scripts/build-packages.mjs +6 -2
  97. package/scripts/control.mjs +1 -1
  98. package/scripts/daemon.mjs +2 -1
  99. package/scripts/extensions.mjs +44 -0
  100. package/scripts/graphlin.mjs +23 -3
  101. package/scripts/onboarding.mjs +10 -3
  102. package/scripts/validate-packages.mjs +54 -8
@@ -0,0 +1,254 @@
1
+ import path from 'node:path';
2
+ import { createInventory, extractStructure } from './discovery/index.mjs';
3
+ import { createProjectModel } from './model/index.mjs';
4
+ import { integer, isHash, isId } from './core/common.mjs';
5
+ import { relativePath, currentPolicy } from './model/records.mjs';
6
+
7
+ const SOURCE = /\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|rb|php|cs|swift|sql|ya?ml|json|toml|tf)$/i;
8
+ const QUEUE_LIMIT = 32, TRACKED_LIMIT = 10000, FILE_BYTES = 256 * 1024;
9
+ const version = artifact => `${artifact.hash}:${artifact.generation}`;
10
+ const eligiblePath = value => SOURCE.test(value) && !/(?:package-lock|pnpm-lock|yarn\.lock)/.test(value);
11
+
12
+ /** Coordinates bounded local exploration without coupling model facts to a view. */
13
+ export function createPlatform({
14
+ projectRoot, projectId, policy = {}, restoredState, now = Date.now,
15
+ accept = operation => operation(), revalidate = async () => true, onChange = () => {},
16
+ extract = extractStructure,
17
+ }) {
18
+ const model = createProjectModel({ projectId, policy, restoredState, now });
19
+ let inventory = createInventory({ projectRoot, excludePaths: currentPolicy(policy).excludePaths });
20
+ const parsed = new Map(), queue = new Map(), latest = new Map(), deferred = new Map(), undispatched = new Set();
21
+ const errors = { failed: 0, stale: 0, omitted: 0 };
22
+ let lineageId = model.snapshot().coverage.lineage?.id ?? null;
23
+ let lineageEpoch = 0;
24
+ let lastError = null;
25
+ let active = null, processing = null, closed = false, finishedScanAt = null;
26
+
27
+ function notify() {
28
+ try {
29
+ Promise.resolve(onChange()).catch(() => { lastError = 'observer_failed'; });
30
+ } catch { lastError = 'observer_failed'; }
31
+ }
32
+ function rememberDeferred(artifact, reason) {
33
+ if (!artifact.relativePath || !currentPolicy(policy).readSource) return;
34
+ if (!deferred.has(artifact.id) && deferred.size >= TRACKED_LIMIT) { errors.omitted++; return; }
35
+ // Deferred work retains names/versions only. Normal discovery/capture must
36
+ // reacquire authorized source; overflow never becomes a hidden source cache.
37
+ deferred.set(artifact.id, {
38
+ id: artifact.id, relativePath: artifact.relativePath, hash: artifact.hash,
39
+ generation: artifact.generation, reason,
40
+ });
41
+ }
42
+ function failure(reason, artifact) {
43
+ errors.failed++;
44
+ lastError = reason;
45
+ if (artifact && isCurrent(artifact)) rememberDeferred(artifact, reason);
46
+ model.recordActivity({ kind: reason, outcome: 'unresolved', artifactIds: artifact ? [artifact.id] : [] });
47
+ notify();
48
+ }
49
+ function isCurrent(artifact) {
50
+ const observed = latest.get(artifact.id), effective = currentPolicy(policy);
51
+ return !closed && effective.readSource && observed?.status === 'present' &&
52
+ artifact.lineageId === lineageId && artifact.lineageEpoch === lineageEpoch && version(observed) === version(artifact) &&
53
+ !!relativePath(artifact.relativePath, effective);
54
+ }
55
+
56
+ function pump() {
57
+ if (active || closed || !queue.size) return;
58
+ const [id, item] = queue.entries().next().value;
59
+ queue.delete(id);
60
+ const controller = new AbortController();
61
+ processing = { id, artifact: item.artifact, version: version(item.artifact), controller };
62
+ active = (async () => {
63
+ const { artifact, event } = item;
64
+ if (!isCurrent(artifact)) { errors.stale++; return; }
65
+ const structure = await extract({
66
+ artifactId: artifact.id, relativePath: artifact.relativePath, text: artifact.text,
67
+ hash: artifact.hash, generation: artifact.generation, complete: artifact.complete, signal: controller.signal,
68
+ });
69
+ if (!isCurrent(artifact)) { errors.stale++; return; }
70
+ let current;
71
+ try { current = await revalidate([{ artifactId: id, hash: artifact.hash, generation: artifact.generation }]); }
72
+ catch { failure('parse.revalidation_failed', artifact); return; }
73
+ if (!current || !isCurrent(artifact)) {
74
+ errors.stale++;
75
+ if (isCurrent(artifact)) rememberDeferred(artifact, 'revalidation_required');
76
+ return;
77
+ }
78
+ if (!structure?.enumeration || structure.enumeration.artifactId !== id ||
79
+ version(structure.enumeration) !== version(artifact)) {
80
+ failure('parse.invalid_result', artifact);
81
+ return;
82
+ }
83
+ try { await accept(() => {
84
+ // The worktree may advance between async revalidation and serialized
85
+ // acceptance. Recheck here before either admission or cache completion.
86
+ if (!isCurrent(artifact)) { errors.stale++; return; }
87
+ const before = model.stats();
88
+ const observation = model.observeStructure(structure, { event });
89
+ const after = model.stats();
90
+ if (!observation.accepted || after.deferred.entities > before.deferred.entities) {
91
+ rememberDeferred(artifact, 'model_capacity');
92
+ return;
93
+ }
94
+ if (structure.enumeration.omissions?.includes('parser_unavailable')) {
95
+ failure('parse.unavailable', artifact);
96
+ return;
97
+ }
98
+ parsed.set(id, version(artifact));
99
+ deferred.delete(id);
100
+ notify();
101
+ }); } catch { failure('parse.accept_failed', artifact); }
102
+ })().catch(() => {
103
+ if (isCurrent(item.artifact) && !controller.signal.aborted) failure('parse.failed', item.artifact);
104
+ else errors.stale++;
105
+ }).finally(() => { active = null; processing = null; pump(); });
106
+ }
107
+
108
+ function retryPaths() {
109
+ if (!currentPolicy(policy).readSource) return [];
110
+ return [...deferred.values()].slice(0, QUEUE_LIMIT).flatMap(item => {
111
+ const name = relativePath(item.relativePath, currentPolicy(policy));
112
+ return name ? [path.join(projectRoot, name)] : [];
113
+ });
114
+ }
115
+ function dispatch(limit) {
116
+ const pending = [...new Set([...retryPaths(), ...undispatched])].slice(0, limit);
117
+ for (const name of pending) undispatched.delete(name);
118
+ // Sort the selected bounded batch, not the filesystem traversal. Metadata
119
+ // displaced by retries remains pending for the next discovery slice.
120
+ return pending.sort();
121
+ }
122
+ function stats() {
123
+ return {
124
+ queued: queue.size, active: active ? 1 : 0, deferred: deferred.size + undispatched.size, parsed: parsed.size,
125
+ ...errors, lastError,
126
+ };
127
+ }
128
+ return {
129
+ model,
130
+ async discover({ limit = 64 } = {}) {
131
+ if (closed) return [];
132
+ const batchLimit = integer(limit, 1) ? Math.min(limit, 64) : 64;
133
+ try {
134
+ if (finishedScanAt !== null) {
135
+ if (now() - finishedScanAt < 5000) return dispatch(batchLimit);
136
+ await inventory.close();
137
+ inventory = createInventory({ projectRoot, excludePaths: currentPolicy(policy).excludePaths });
138
+ finishedScanAt = null;
139
+ }
140
+ const result = await inventory.next({ limit: batchLimit });
141
+ model.observeInventory(result);
142
+ if (!result.continuation) finishedScanAt = now();
143
+ for (const file of result.paths.filter(eligiblePath)) {
144
+ if (undispatched.size < TRACKED_LIMIT) undispatched.add(path.join(projectRoot, file));
145
+ else errors.omitted++;
146
+ }
147
+ return dispatch(batchLimit);
148
+ } catch {
149
+ failure('inventory.failed');
150
+ return dispatch(batchLimit);
151
+ }
152
+ },
153
+ observeArtifacts(artifacts, event) {
154
+ if (closed || !Array.isArray(artifacts)) return;
155
+ model.invalidateArtifacts(artifacts);
156
+ const overflow = [];
157
+ for (const input of artifacts.slice(0, TRACKED_LIMIT)) {
158
+ if (!isId(input?.id) || !integer(input.generation, 1)) continue;
159
+ const old = latest.get(input.id);
160
+ if (old && (input.generation < old.generation || input.generation === old.generation &&
161
+ (old.hash && input.hash && old.hash !== input.hash || old.status !== 'present' && input.status === 'present'))) {
162
+ errors.stale++;
163
+ continue;
164
+ }
165
+ if (!old && latest.size >= TRACKED_LIMIT) { errors.omitted++; continue; }
166
+ const effective = currentPolicy(policy);
167
+ const name = relativePath(input.relativePath, effective);
168
+ const artifact = { id: input.id, relativePath: name, hash: input.hash,
169
+ generation: input.generation, status: input.status, complete: input.complete === true, lineageId, lineageEpoch };
170
+ latest.set(input.id, artifact);
171
+ if (processing?.id === input.id && (processing.version !== version(artifact) || artifact.status !== 'present')) {
172
+ processing.controller.abort();
173
+ }
174
+ if (!effective.readSource || !name || !eligiblePath(name) || !isHash(input.hash) ||
175
+ typeof input.text !== 'string' || Buffer.byteLength(input.text) > FILE_BYTES || input.status !== 'present') {
176
+ queue.delete(input.id);
177
+ deferred.delete(input.id);
178
+ parsed.delete(input.id);
179
+ continue;
180
+ }
181
+ if (parsed.get(input.id) === version(artifact)) {
182
+ deferred.delete(input.id);
183
+ continue;
184
+ }
185
+ if (processing?.id === input.id && processing.version === version(artifact) &&
186
+ processing.artifact.lineageEpoch === lineageEpoch) continue;
187
+ if (!queue.has(input.id) && queue.size >= QUEUE_LIMIT) {
188
+ if (!deferred.has(input.id)) overflow.push(input.id);
189
+ rememberDeferred(artifact, 'queue_capacity');
190
+ continue;
191
+ }
192
+ const correlation = event ? { id: event.id, kind: event.kind, sessionId: event.sessionId } : undefined;
193
+ // A pre-tool event may accompany a real capture, but is not its source
194
+ // attribution. Only the separately captured, revalidated bytes are parsed.
195
+ queue.set(input.id, {
196
+ artifact: { ...artifact, text: input.text },
197
+ event: event?.kind === 'tool.requested' ? undefined : correlation,
198
+ });
199
+ deferred.delete(input.id);
200
+ }
201
+ if (overflow.length) model.recordActivity({ kind: 'parse.deferred', outcome: 'unresolved', artifactIds: overflow.slice(0, 256) });
202
+ pump();
203
+ },
204
+ observeLineage(value) {
205
+ const before = model.stats().revision;
206
+ const result = model.observeLineage(value);
207
+ const nextId = result.lineage?.id ?? null;
208
+ if (result.changed) {
209
+ lineageEpoch++;
210
+ parsed.clear();
211
+ queue.clear();
212
+ deferred.clear();
213
+ processing?.controller.abort();
214
+ for (const artifact of latest.values()) rememberDeferred(artifact, 'lineage_changed');
215
+ } else if (lineageId === null && nextId !== null) {
216
+ // Initial metadata names the current work; it is not a branch switch.
217
+ for (const item of queue.values()) item.artifact.lineageId = nextId;
218
+ if (processing) processing.artifact.lineageId = nextId;
219
+ for (const artifact of latest.values()) artifact.lineageId = nextId;
220
+ }
221
+ lineageId = nextId;
222
+ if (model.stats().revision !== before) notify();
223
+ return result;
224
+ },
225
+ observeLegacy(graph, options) { model.observeLegacy(graph, options); },
226
+ recordActivity(event) { model.recordActivity(event); },
227
+ setSessions(sessions) { model.setSessions(sessions); },
228
+ snapshot(options) {
229
+ const snapshot = model.snapshot(options);
230
+ // Current queue status must not be mixed into a historical checkpoint.
231
+ if (!options?.checkpointId) {
232
+ snapshot.coverage.parsing = stats();
233
+ snapshot.coverage.deferred.artifacts += deferred.size + undispatched.size + queue.size + (active ? 1 : 0);
234
+ snapshot.coverage.unavailable += [...deferred.values()].filter(item => item.reason.startsWith('parse.')).length;
235
+ snapshot.coverage.truncated ||= deferred.size > 0 || undispatched.size > 0 || errors.omitted > 0;
236
+ }
237
+ return snapshot;
238
+ },
239
+ stats,
240
+ checkpoint(options) { return model.checkpoint(options); },
241
+ async whenIdle() {
242
+ while (active || queue.size) { pump(); if (active) await active; }
243
+ },
244
+ async close() {
245
+ closed = true;
246
+ queue.clear();
247
+ deferred.clear();
248
+ undispatched.clear();
249
+ processing?.controller.abort();
250
+ try { await inventory.close(); } catch { failure('inventory.close_failed'); }
251
+ await active;
252
+ },
253
+ };
254
+ }
@@ -0,0 +1,5 @@
1
+ import { structureScene } from './structure.mjs';
2
+ export const blocks = {
3
+ id: 'graphlin.blocks', name: 'Blocks', renderer: 'graphlin-scene',
4
+ project: ({ model, settings }) => structureScene(model, settings),
5
+ };
@@ -0,0 +1,52 @@
1
+ import { structureScene, aggregateEdges, sceneKind, emptyScene } from './structure.mjs';
2
+
3
+ const LEVELS = {
4
+ context: new Set(['system', 'external_system', 'actor', 'person', 'context']),
5
+ applications: new Set(['application', 'datastore', 'container']),
6
+ components: new Set(['component']),
7
+ };
8
+ const KINDS = { application: 'service', container: 'service', datastore: 'datastore',
9
+ component: 'service', actor: 'client', person: 'client', external_system: 'external', system: 'group' };
10
+
11
+ export function c4Scene(model, settings = {}) {
12
+ if (settings.level === 'code') return structureScene(model, settings, { flat: true });
13
+ const level = LEVELS[settings.level] || LEVELS.applications;
14
+ const byId = new Map(model.entities.map(entity => [entity.id, entity]));
15
+ const boundaries = model.interpretations.filter(value =>
16
+ level.has(value.kind) && value.validity === 'current' && value.support === 'supported' &&
17
+ value.classification === 'accepted' && value.sourceRefs?.length &&
18
+ value.entityIds?.some(id => byId.has(id)));
19
+ if (!boundaries.length) {
20
+ const scene = structureScene(model, { ...settings, depth: 0 });
21
+ scene.coverage.label = 'Source scopes; application and responsibility boundaries unknown';
22
+ scene.groups.forEach(group => { group.style = 'unknown'; });
23
+ scene.nodes.forEach(node => { node.style = 'unknown'; });
24
+ return scene;
25
+ }
26
+ const scene = emptyScene(), represented = new Set();
27
+ for (const boundary of boundaries.slice(0, 63)) {
28
+ const members = boundary.entityIds.filter(id => byId.has(id)).slice(0, 256);
29
+ members.forEach(id => represented.add(id));
30
+ const expanded = settings.expanded?.includes(members[0]);
31
+ scene.groups.push({
32
+ id: `c4.${boundary.id}`, entityIds: members, label: boundary.label, collapsed: !expanded,
33
+ kind: sceneKind(KINDS[boundary.kind] || 'group'), membershipId: boundary.id,
34
+ });
35
+ if (expanded) for (const id of members) {
36
+ if (scene.nodes.length + scene.groups.length >= 255 || scene.nodes.some(node => node.entityId === id)) continue;
37
+ const entity = byId.get(id);
38
+ scene.nodes.push({ id, entityId: id, label: entity.label, kind: sceneKind(entity.kind), parentId: `c4.${boundary.id}` });
39
+ }
40
+ }
41
+ const unknown = model.entities.filter(entity => !represented.has(entity.id)).slice(0, 256);
42
+ if (unknown.length) scene.groups.push({
43
+ id: 'c4.unknown', entityIds: unknown.map(entity => entity.id), label: 'Responsibility unknown',
44
+ kind: 'unknown', collapsed: true, style: 'unknown',
45
+ });
46
+ scene.coverage = { shown: scene.groups.length, total: model.entities.length,
47
+ truncated: represented.size + unknown.length < model.entities.length || boundaries.length > 63,
48
+ label: 'Supported interpretations; source does not establish runtime hosting' };
49
+ return aggregateEdges(model, scene);
50
+ }
51
+ export const c4 = { id: 'graphlin.c4', name: 'C4', renderer: 'graphlin-scene',
52
+ project: ({ model, settings }) => c4Scene(model, settings) };
@@ -0,0 +1,24 @@
1
+ import { compareCheckpoint } from '../model/changes.mjs';
2
+ import { structureScene } from './structure.mjs';
3
+
4
+ export function changesScene(model, settings = {}, baseline) {
5
+ if (!baseline || baseline.projectId !== model.projectId || baseline.sequence > model.sequence) {
6
+ const scene = structureScene(model, settings);
7
+ scene.coverage.label = 'Choose a retained checkpoint as the task baseline';
8
+ return scene;
9
+ }
10
+ const completeCoverage = value => ({ ...value,
11
+ coverage: { ...value.coverage, enumerations: value.coverage?.enumerations || [] } });
12
+ const changes = compareCheckpoint(completeCoverage(baseline), completeCoverage(model), settings.baseline);
13
+ const styles = new Map();
14
+ for (const [key, style] of Object.entries({
15
+ discoveries: 'discovered', creations: 'added', modifications: 'modified', removals: 'removed', invalidations: 'stale',
16
+ })) for (const value of changes[key]) styles.set((value.after || value).id, style);
17
+ const scene = structureScene(model, settings, { styles });
18
+ scene.coverage.label = `Since revision ${baseline.revision}: ${changes.discoveries.length} discovered, ` +
19
+ `${changes.creations.length} created, ${changes.modifications.length} modified, ` +
20
+ `${changes.removals.length} removed, ${changes.invalidations.length} invalidated`;
21
+ return scene;
22
+ }
23
+ export const changes = { id: 'graphlin.changes', name: 'Changes', renderer: 'graphlin-scene',
24
+ project: ({ model, settings, baseline }) => changesScene(model, settings, baseline) };
@@ -0,0 +1,5 @@
1
+ import { structureScene } from './structure.mjs';
2
+ export const code = {
3
+ id: 'graphlin.code', name: 'Code', renderer: 'graphlin-scene',
4
+ project: ({ model, settings }) => structureScene(model, settings, { flat: true }),
5
+ };
@@ -0,0 +1,23 @@
1
+ import { code } from './code.mjs';
2
+ import { blocks } from './blocks.mjs';
3
+ import { c4 } from './c4.mjs';
4
+ import { changes } from './changes.mjs';
5
+ import { timeline } from './timeline.mjs';
6
+ import { validateScene } from '../extensions/scene.mjs';
7
+
8
+ export const BUILTIN_VIEWS = Object.freeze([code, blocks, c4, changes, timeline]);
9
+
10
+ export function createBuiltin(id, context) {
11
+ const definition = BUILTIN_VIEWS.find(view => view.id === id);
12
+ if (!definition) throw new Error('unknown_visualizer');
13
+ const custom = definition.create?.(context);
14
+ let disposed = false;
15
+ return {
16
+ update(input) {
17
+ if (disposed) throw new Error('extension_disposed');
18
+ if (custom) return custom.update(input);
19
+ return { kind: 'scene', scene: validateScene(definition.project(input), { model: input.model }) };
20
+ },
21
+ dispose() { disposed = true; custom?.dispose(); },
22
+ };
23
+ }
@@ -0,0 +1,120 @@
1
+ import { SCENE_KINDS, EDGE_KINDS } from '../extensions/scene.mjs';
2
+
3
+ export const emptyScene = () => ({ sceneVersion: 1, nodes: [], groups: [], edges: [] });
4
+ export const sceneKind = kind => SCENE_KINDS.includes(kind) ? kind :
5
+ ({ method: 'function', namespace: 'module', enum: 'class', type_alias: 'interface',
6
+ variable: 'module', directory: 'package', file: 'module', project: 'package' }[kind] || 'unknown');
7
+
8
+ export function entityIndex(model) {
9
+ const byId = new Map(model.entities.map(entity => [entity.id, entity]));
10
+ const children = new Map();
11
+ for (const entity of byId.values()) {
12
+ const parent = byId.has(entity.parentId) ? entity.parentId : null;
13
+ if (!children.has(parent)) children.set(parent, []);
14
+ children.get(parent).push(entity.id);
15
+ }
16
+ return { byId, children };
17
+ }
18
+
19
+ export function ancestors(id, byId) {
20
+ const result = [], seen = new Set([id]);
21
+ let parent = byId.get(id)?.parentId;
22
+ while (byId.has(parent) && !seen.has(parent)) {
23
+ seen.add(parent); result.push(parent); parent = byId.get(parent).parentId;
24
+ }
25
+ return result;
26
+ }
27
+
28
+ function descendants(id, children) {
29
+ const result = [], pending = [id], seen = new Set();
30
+ for (let cursor = 0; cursor < pending.length; cursor++) {
31
+ const next = pending[cursor];
32
+ if (seen.has(next)) continue;
33
+ seen.add(next); result.push(next);
34
+ pending.push(...(children.get(next) || []));
35
+ }
36
+ return result;
37
+ }
38
+
39
+ export function aggregateEdges(model, scene) {
40
+ const owner = new Map();
41
+ // The most specific visible item owns an endpoint. Expanded group membership
42
+ // includes descendants, but must not hide a visible child's own relationships.
43
+ for (const group of scene.groups) for (const id of group.entityIds) owner.set(id, group.id);
44
+ for (const node of scene.nodes) owner.set(node.entityId, node.id);
45
+ const combined = new Map();
46
+ for (const relation of model.relations) {
47
+ const source = owner.get(relation.source), target = owner.get(relation.target);
48
+ if (!source || !target || source === target || relation.kind === 'contains' ||
49
+ !EDGE_KINDS.includes(relation.kind)) continue;
50
+ const key = JSON.stringify([source, target, relation.kind, relation.validity]);
51
+ if (!combined.has(key)) {
52
+ if (combined.size >= 768) continue;
53
+ combined.set(key, {
54
+ id: `link.${combined.size}`, source, target, kind: relation.kind, relationIds: [],
55
+ style: relation.validity === 'current' ? 'default' : 'stale',
56
+ });
57
+ }
58
+ const edge = combined.get(key);
59
+ if (edge.relationIds.length < 256) edge.relationIds.push(relation.id);
60
+ }
61
+ scene.edges = [...combined.values()].map(edge => ({
62
+ ...edge, count: edge.relationIds.length,
63
+ label: `${edge.kind.replaceAll('_', ' ')}${edge.relationIds.length > 1 ? ` (${edge.relationIds.length})` : ''}`,
64
+ }));
65
+ return scene;
66
+ }
67
+
68
+ export function structureScene(model, settings = {}, { flat = false, styles = new Map() } = {}) {
69
+ const scene = emptyScene();
70
+ const { byId, children } = entityIndex(model);
71
+ const expanded = new Set(settings.expanded || []);
72
+ const needle = (settings.query || '').toLowerCase();
73
+ const types = settings.kinds ? new Set(settings.kinds) : null;
74
+ const filtering = Boolean(needle || types);
75
+ const matches = new Set(model.entities.filter(entity =>
76
+ (!needle || `${entity.label} ${entity.qualifiedName || ''}`.toLowerCase().includes(needle)) &&
77
+ (!types || types.has(entity.kind))).map(entity => entity.id));
78
+ const relevant = new Set(matches);
79
+ if (filtering) for (const id of matches) for (const parent of ancestors(id, byId)) {
80
+ relevant.add(parent); expanded.add(parent);
81
+ }
82
+ const roots = settings.scope && byId.has(settings.scope) ? [settings.scope] : (children.get(null) || []);
83
+ const pending = roots.map(id => ({ id, parentId: null, depth: 0 }));
84
+ const visited = new Set();
85
+ while (pending.length && scene.nodes.length + scene.groups.length < 256) {
86
+ const { id, parentId, depth } = pending.shift();
87
+ if (visited.has(id)) continue;
88
+ visited.add(id);
89
+ const entity = byId.get(id);
90
+ if (!entity || (filtering && !relevant.has(id))) continue;
91
+ const childIds = children.get(id) || [];
92
+ const style = styles.get(id) || (entity.validity !== 'current' ? 'stale' :
93
+ entity.classification === 'tentative' ? 'tentative' : 'default');
94
+ if (!flat && childIds.length && scene.groups.length < 64) {
95
+ const members = descendants(id, children);
96
+ const collapsed = !(expanded.has(id) || (!settings.collapsed?.includes(id) && depth < (settings.depth ?? 1)));
97
+ const changed = members.filter(member => styles.has(member));
98
+ scene.groups.push({
99
+ id, entityIds: members.slice(0, 256),
100
+ label: changed.length ? `${entity.label.slice(0, 200)} (${changed.length} changed)` : entity.label,
101
+ kind: sceneKind(entity.kind), ...(parentId ? { parentId } : {}), collapsed,
102
+ style: styles.get(id) || (changed.length ? styles.get(changed[0]) : style),
103
+ });
104
+ if (!collapsed) pending.push(...childIds.map(child => ({ id: child, parentId: id, depth: depth + 1 })));
105
+ } else {
106
+ if (!filtering || matches.has(id)) scene.nodes.push({
107
+ id, entityId: id, label: entity.label, kind: sceneKind(entity.kind), style,
108
+ ...(parentId ? { parentId } : {}),
109
+ });
110
+ if (flat || childIds.length) pending.push(...childIds.map(child => ({ id: child, parentId, depth: depth + 1 })));
111
+ }
112
+ }
113
+ scene.coverage = {
114
+ shown: scene.nodes.length + scene.groups.length, total: model.entities.length,
115
+ truncated: pending.length > 0 || scene.groups.some(group =>
116
+ (children.get(group.id) || []).length > 0 && group.entityIds.length === 256),
117
+ label: 'Source structure; discovery may be incomplete',
118
+ };
119
+ return aggregateEdges(model, scene);
120
+ }
@@ -0,0 +1,66 @@
1
+ // First-party custom renderer. It consumes the same update/dispose lifecycle as
2
+ // scene projectors and works when entities and relations are both empty.
3
+ export function timelineRows(model, settings = {}) {
4
+ const query = (settings.query || '').toLowerCase();
5
+ const rows = model.activity.filter(event => !settings.session || event.sessionId === settings.session)
6
+ .filter(event => !query || `${event.kind} ${event.toolCategory} ${event.agentId || ''}`.toLowerCase().includes(query))
7
+ .slice(-200).map(event => ({
8
+ id: event.id, sequence: event.sequence, at: event.at ?? event.timestamp,
9
+ lane: event.agentId || event.sessionId || 'Unattributed',
10
+ label: `${event.toolCategory || 'Activity'} · ${(event.kind || 'observation').replaceAll('.', ' ')}`,
11
+ outcome: event.outcome || 'unresolved', attribution: event.attribution || 'unknown',
12
+ toolCallId: event.toolCallId, entityIds: event.entityIds || [],
13
+ })).sort((a, b) => a.sequence - b.sequence || a.id.localeCompare(b.id));
14
+ // Pair only explicitly correlated observations. A pending attempt without a
15
+ // terminal event remains unresolved; parallel tool IDs remain separate.
16
+ const outcomes = new Map();
17
+ for (const row of rows) if (row.toolCallId && !['pending', 'unresolved', 'observed'].includes(row.outcome))
18
+ outcomes.set(`${row.lane}:${row.toolCallId}`, row.outcome);
19
+ return rows.map(row => ({ ...row, outcome: row.outcome === 'pending'
20
+ ? outcomes.get(`${row.lane}:${row.toolCallId}`) || 'unresolved' : row.outcome }));
21
+ }
22
+
23
+ export function createTimeline({ root, select = () => {} }) {
24
+ const document = root.ownerDocument;
25
+ const element = (tag, text, className) => {
26
+ const node = document.createElement(tag);
27
+ if (text !== undefined) node.textContent = text;
28
+ if (className) node.className = className;
29
+ return node;
30
+ };
31
+ let disposed = false, lastSequence = null;
32
+ return {
33
+ update({ model, settings = {} }) {
34
+ if (disposed) throw new Error('extension_disposed');
35
+ const focusedId = document.activeElement?.dataset?.activityId;
36
+ const rows = timelineRows(model, settings);
37
+ const heading = element('p', `${rows.length} ordered observations. Parallel lanes use recorded agent or session attribution.`, 'timeline-note');
38
+ const list = element('ol', undefined, 'timeline-events');
39
+ list.setAttribute('aria-label', 'Agent and tool observations in sequence order');
40
+ let focus;
41
+ for (const row of rows) {
42
+ const item = element('li', undefined, 'timeline-event');
43
+ item.dataset.outcome = row.outcome;
44
+ const time = element('time', Number.isFinite(Date.parse(row.at)) ? new Date(row.at).toLocaleTimeString() : 'Time unknown');
45
+ const button = element('button', row.label);
46
+ button.setAttribute('type', 'button');
47
+ button.dataset.activityId = row.id;
48
+ button.setAttribute('aria-label', `${row.label}. ${row.outcome}. ${row.entityIds.length ? 'Inspect linked evidence' : 'No linked entity'}.`);
49
+ button.addEventListener('click', () => select({ activityId: row.id, entityId: row.entityIds[0] || null }));
50
+ if (focusedId === row.id) focus = button;
51
+ item.append(time, element('span', row.lane, 'timeline-lane'), button,
52
+ element('span', row.outcome, 'timeline-outcome'),
53
+ element('span', `${row.attribution} attribution`, 'timeline-attribution'));
54
+ list.append(item);
55
+ }
56
+ if (!rows.length) list.append(element('li', 'No observations at this position. Select Live to follow captured work.'));
57
+ root.replaceChildren(heading, list);
58
+ focus?.focus({ preventScroll: true });
59
+ if (settings.follow && lastSequence !== null && model.sequence > lastSequence) list.children[list.children.length - 1]?.scrollIntoView({ block: 'nearest' });
60
+ lastSequence = model.sequence;
61
+ return { kind: 'custom', status: 'ready', itemCount: rows.length };
62
+ },
63
+ dispose() { disposed = true; root.replaceChildren(); },
64
+ };
65
+ }
66
+ export const timeline = { id: 'graphlin.timeline', name: 'Activity timeline', renderer: 'custom', create: createTimeline };