@unson/brainbase-mcp 0.9.0 → 0.10.1

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.
@@ -0,0 +1,358 @@
1
+ /*
2
+ * Small, shared project graph contract.
3
+ *
4
+ * This module deliberately contains no Sigma or DOM imports. The OSS screen
5
+ * can therefore use the projection and layout in tests, while the browser
6
+ * entry loads the bundled renderer only when a graph is mounted.
7
+ */
8
+
9
+ export const PROJECT_GRAPH_CONTRACT_VERSION = 'brainbase.project-graph.v1';
10
+
11
+ const TYPE_ORDER = Object.freeze(['project', 'decision', 'person', 'org']);
12
+
13
+ /**
14
+ * One colour per semantic type keeps a graph readable when it contains more
15
+ * than the four types known by an older host. Unknown types intentionally
16
+ * remain visible and use the neutral colour.
17
+ */
18
+ export const PROJECT_GRAPH_TYPE_COLORS = Object.freeze({
19
+ project: '#4f46e5',
20
+ decision: '#b45309',
21
+ person: '#0f766e',
22
+ org: '#166534',
23
+ unknown: '#64748b',
24
+ });
25
+
26
+ const EMPTY_ARRAY = Object.freeze([]);
27
+ const MAX_SELECTED_NODE_SIZE = 18;
28
+ const MAX_NEIGHBOR_NODE_SIZE = 12;
29
+ const MAX_UNRELATED_NODE_SIZE = 10;
30
+
31
+ function isRecord(value) {
32
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
33
+ }
34
+
35
+ function stringValue(value) {
36
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
37
+ }
38
+
39
+ function finitePositive(value, fallback) {
40
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
41
+ }
42
+
43
+ function typeRank(type) {
44
+ const index = TYPE_ORDER.indexOf(type);
45
+ return index === -1 ? TYPE_ORDER.length : index;
46
+ }
47
+
48
+ function stableCompare(left, right) {
49
+ return left < right ? -1 : left > right ? 1 : 0;
50
+ }
51
+
52
+ function compareNodes(left, right) {
53
+ const type = typeRank(left.type) - typeRank(right.type);
54
+ if (type) return type;
55
+ const byType = stableCompare(left.type, right.type);
56
+ if (byType) return byType;
57
+ const label = stableCompare(left.label, right.label);
58
+ if (label) return label;
59
+ return stableCompare(left.id, right.id);
60
+ }
61
+
62
+ function compareEdges(left, right) {
63
+ const source = stableCompare(left.source, right.source);
64
+ if (source) return source;
65
+ const target = stableCompare(left.target, right.target);
66
+ if (target) return target;
67
+ return stableCompare(left.id, right.id);
68
+ }
69
+
70
+ function uniqueKey(candidate, used) {
71
+ const base = candidate || 'edge';
72
+ if (!used.has(base)) {
73
+ used.add(base);
74
+ return base;
75
+ }
76
+ let suffix = 2;
77
+ while (used.has(`${base}~${suffix}`)) suffix += 1;
78
+ const key = `${base}~${suffix}`;
79
+ used.add(key);
80
+ return key;
81
+ }
82
+
83
+ /**
84
+ * Converts host data into the small shape consumed by Sigma.
85
+ *
86
+ * Invalid records are reported under `issues` and omitted. In particular an
87
+ * edge whose source or target is not present is never used to invent a node.
88
+ * The first occurrence of a node id wins, preserving the host's order while
89
+ * keeping the result deterministic.
90
+ */
91
+ export function normalizeProjectGraph(input = {}) {
92
+ const rawNodes = Array.isArray(input?.nodes) ? input.nodes : EMPTY_ARRAY;
93
+ const rawEdges = Array.isArray(input?.edges) ? input.edges : EMPTY_ARRAY;
94
+ const nodes = [];
95
+ const nodeIds = new Set();
96
+ const issues = [];
97
+
98
+ rawNodes.forEach((raw, index) => {
99
+ const id = isRecord(raw) ? stringValue(raw.id) : null;
100
+ if (!id) {
101
+ issues.push({ kind: 'invalid_node', index, reason: 'id_required' });
102
+ return;
103
+ }
104
+ if (nodeIds.has(id)) {
105
+ issues.push({ kind: 'duplicate_node', index, id, reason: 'id_already_seen' });
106
+ return;
107
+ }
108
+ nodeIds.add(id);
109
+ const type = stringValue(raw.type) || 'unknown';
110
+ const label = stringValue(raw.label) || id;
111
+ nodes.push({
112
+ id,
113
+ label,
114
+ type,
115
+ color: stringValue(raw.color) || PROJECT_GRAPH_TYPE_COLORS[type] || PROJECT_GRAPH_TYPE_COLORS.unknown,
116
+ size: finitePositive(raw.size, type === 'project' ? 13 : 9),
117
+ });
118
+ });
119
+
120
+ const edges = [];
121
+ const edgeIds = new Set();
122
+ rawEdges.forEach((raw, index) => {
123
+ const source = isRecord(raw) ? stringValue(raw.source) : null;
124
+ const target = isRecord(raw) ? stringValue(raw.target) : null;
125
+ const requestedId = isRecord(raw) ? stringValue(raw.id) : null;
126
+ const issue = !source
127
+ ? 'source_required'
128
+ : !target
129
+ ? 'target_required'
130
+ : !nodeIds.has(source)
131
+ ? 'source_not_found'
132
+ : !nodeIds.has(target)
133
+ ? 'target_not_found'
134
+ : null;
135
+ if (issue) {
136
+ issues.push({ kind: 'invalid_edge', index, id: requestedId, source, target, reason: issue });
137
+ return;
138
+ }
139
+ const id = uniqueKey(requestedId || `edge-${index + 1}`, edgeIds);
140
+ edges.push({
141
+ id,
142
+ originalId: requestedId || id,
143
+ source,
144
+ target,
145
+ label: stringValue(raw.label) || '',
146
+ color: stringValue(raw.color) || '#cbd5e1',
147
+ });
148
+ });
149
+
150
+ const sortedNodes = nodes.slice().sort(compareNodes);
151
+ const sortedEdges = edges.slice().sort(compareEdges);
152
+ return {
153
+ nodes: sortedNodes,
154
+ edges: sortedEdges,
155
+ issues,
156
+ invalidNodeCount: issues.filter((issue) => issue.kind === 'invalid_node' || issue.kind === 'duplicate_node').length,
157
+ invalidEdgeCount: issues.filter((issue) => issue.kind === 'invalid_edge').length,
158
+ };
159
+ }
160
+
161
+ /** Returns the ids attached to `selectedId`, including only real endpoints. */
162
+ export function projectGraphNeighborIds(nodes, edges, selectedId) {
163
+ const ids = new Set((Array.isArray(nodes) ? nodes : EMPTY_ARRAY).map((node) => node?.id).filter(Boolean));
164
+ if (!selectedId || !ids.has(selectedId)) return new Set();
165
+ const neighbors = new Set();
166
+ for (const edge of Array.isArray(edges) ? edges : EMPTY_ARRAY) {
167
+ if (!ids.has(edge?.source) || !ids.has(edge?.target)) continue;
168
+ if (edge.source === selectedId) neighbors.add(edge.target);
169
+ if (edge.target === selectedId) neighbors.add(edge.source);
170
+ }
171
+ return neighbors;
172
+ }
173
+
174
+ function toIdSet(value) {
175
+ return value instanceof Set
176
+ ? value
177
+ : new Set(Array.isArray(value) ? value.filter(Boolean) : EMPTY_ARRAY);
178
+ }
179
+
180
+ /**
181
+ * Returns the visual state for one node in a selected graph neighborhood.
182
+ *
183
+ * A selected node is the only node that bypasses Sigma's label collision
184
+ * avoidance. Neighbors keep their labels available to Sigma's normal label
185
+ * grid, while their size and hover state stay bounded in dense neighborhoods.
186
+ */
187
+ export function projectGraphNodePresentation(node, { selectedId = null, neighborIds = EMPTY_ARRAY } = {}) {
188
+ const id = stringValue(node?.id);
189
+ const neighbors = toIdSet(neighborIds);
190
+ const state = id && id === selectedId
191
+ ? 'selected'
192
+ : id && neighbors.has(id)
193
+ ? 'neighbor'
194
+ : 'unrelated';
195
+ const baseSize = Math.min(finitePositive(node?.size, 9), MAX_SELECTED_NODE_SIZE);
196
+
197
+ if (state === 'selected') {
198
+ return {
199
+ state,
200
+ size: Math.min(MAX_SELECTED_NODE_SIZE, Math.max(10, baseSize + 3)),
201
+ forceLabel: true,
202
+ highlighted: true,
203
+ };
204
+ }
205
+
206
+ if (state === 'neighbor') {
207
+ return {
208
+ state,
209
+ size: Math.min(MAX_NEIGHBOR_NODE_SIZE, Math.max(6, baseSize)),
210
+ forceLabel: false,
211
+ highlighted: false,
212
+ };
213
+ }
214
+
215
+ return {
216
+ state,
217
+ size: Math.min(MAX_UNRELATED_NODE_SIZE, Math.max(4, baseSize * 0.72)),
218
+ label: null,
219
+ forceLabel: false,
220
+ highlighted: false,
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Returns the label policy for one edge in a selected graph neighborhood.
226
+ * Dense neighborhoods omit edge labels; relation details remain available
227
+ * in the selected record panel. Small neighborhoods show relevant labels.
228
+ */
229
+ export function projectGraphEdgePresentation(edge, { selectedId = null, neighborIds = EMPTY_ARRAY } = {}) {
230
+ const neighbors = toIdSet(neighborIds);
231
+ const relevant = Boolean(
232
+ selectedId && edge && (
233
+ edge.source === selectedId ||
234
+ edge.target === selectedId ||
235
+ (neighbors.has(edge.source) && neighbors.has(edge.target))
236
+ ),
237
+ );
238
+ // Sigma's edge-label renderer does not apply the same collision grid as
239
+ // node labels. Hide labels for a dense neighborhood and leave the relation
240
+ // available through selection/details instead of painting a radial stack.
241
+ const denseNeighborhood = neighbors.size > 12;
242
+ return {
243
+ relevant,
244
+ showLabel: relevant && !denseNeighborhood && Boolean(stringValue(edge?.label)),
245
+ forceLabel: false,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Computes a stable clustered layout. The selected node is the anchor when
251
+ * it exists; its direct neighbours form the first ring and the remaining
252
+ * nodes are grouped by their declared type. No random seed or force solver
253
+ * is involved, so the same Graph snapshot produces the same positions.
254
+ */
255
+ export function computeProjectGraphLayout(nodes, edges = EMPTY_ARRAY, { selectedId = null } = {}) {
256
+ const list = (Array.isArray(nodes) ? nodes : EMPTY_ARRAY).filter((node) => stringValue(node?.id));
257
+ if (!list.length) return {};
258
+ const byId = new Map(list.map((node) => [node.id, node]));
259
+ const anchorId = selectedId && byId.has(selectedId) ? selectedId : list.slice().sort(compareNodes)[0].id;
260
+ const neighborIds = projectGraphNeighborIds(list, edges, anchorId);
261
+ // A null-prototype map keeps a host supplied id such as "__proto__" from
262
+ // changing the layout object itself.
263
+ const positions = Object.create(null);
264
+ positions[anchorId] = { x: 0, y: 0 };
265
+
266
+ const neighbors = list
267
+ .filter((node) => neighborIds.has(node.id))
268
+ .sort(compareNodes);
269
+ // Keep high-degree selections readable by spreading neighbors over stable
270
+ // concentric rings. A single ring turns a person with dozens of relations
271
+ // into a dense necklace even when Sigma's label grid is working correctly.
272
+ const neighborRingCount = neighbors.length ? Math.ceil(neighbors.length / 24) : 0;
273
+ const neighborRingRadius = 2.6;
274
+ const neighborRingGap = 1.8;
275
+ let neighborOffset = 0;
276
+ for (let ringIndex = 0; ringIndex < neighborRingCount; ringIndex += 1) {
277
+ const remaining = neighbors.length - neighborOffset;
278
+ const ringsLeft = neighborRingCount - ringIndex;
279
+ const ringSize = Math.ceil(remaining / ringsLeft);
280
+ const radius = neighborRingRadius + ringIndex * neighborRingGap;
281
+ for (let index = 0; index < ringSize; index += 1) {
282
+ const angle = -Math.PI / 2 + (2 * Math.PI * index) / Math.max(1, ringSize) + ringIndex * 0.19;
283
+ const node = neighbors[neighborOffset + index];
284
+ positions[node.id] = { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };
285
+ }
286
+ neighborOffset += ringSize;
287
+ }
288
+ const neighborRadius = neighborRingCount
289
+ ? neighborRingRadius + (neighborRingCount - 1) * neighborRingGap
290
+ : 1.5;
291
+
292
+ const remaining = list.filter((node) => !positions[node.id]);
293
+ const groups = new Map();
294
+ for (const node of remaining) {
295
+ const group = groups.get(node.type) || [];
296
+ group.push(node);
297
+ groups.set(node.type, group);
298
+ }
299
+ const groupTypes = Array.from(groups.keys()).sort((left, right) => {
300
+ const rank = typeRank(left) - typeRank(right);
301
+ return rank || stableCompare(left, right);
302
+ });
303
+ const groupRadius = Math.max(neighborRadius + 1.15, 2.25);
304
+ groupTypes.forEach((type, groupIndex) => {
305
+ const group = groups.get(type).sort(compareNodes);
306
+ const angle = -Math.PI / 2 + (2 * Math.PI * groupIndex) / Math.max(1, groupTypes.length);
307
+ const centerX = Math.cos(angle) * groupRadius;
308
+ const centerY = Math.sin(angle) * groupRadius;
309
+ const innerRadius = Math.max(0.35, 0.28 + group.length * 0.1);
310
+ group.forEach((node, index) => {
311
+ const nodeAngle = (2 * Math.PI * index) / Math.max(1, group.length) + angle;
312
+ positions[node.id] = {
313
+ x: centerX + Math.cos(nodeAngle) * innerRadius,
314
+ y: centerY + Math.sin(nodeAngle) * innerRadius,
315
+ };
316
+ });
317
+ });
318
+ return positions;
319
+ }
320
+
321
+ /** Creates the fully positioned graph model used by the browser entry. */
322
+ export function createProjectGraphModel(input = {}, options = {}) {
323
+ const normalized = normalizeProjectGraph(input);
324
+ const positions = computeProjectGraphLayout(normalized.nodes, normalized.edges, options);
325
+ return {
326
+ ...normalized,
327
+ nodes: normalized.nodes.map((node) => ({ ...node, ...positions[node.id] })),
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Lazy browser entry. Keeping the renderer behind this boundary means the
333
+ * local Graph screen can import this module in Node-based UI tests without
334
+ * evaluating WebGL or browser globals.
335
+ */
336
+ export async function mountProjectGraph(container, options = {}) {
337
+ if (options?.signal?.aborted) throw abortError();
338
+ let entry;
339
+ try {
340
+ entry = await import('./project-graph-entry.js');
341
+ } catch (error) {
342
+ // This includes browsers that expose no WebGL2/WebGL globals while the
343
+ // Sigma vendor module is evaluated. Hosts can use the code to switch to
344
+ // their accessible list fallback without swallowing the cause.
345
+ if (error && typeof error === 'object' && !error.code) error.code = 'project_graph_renderer_unavailable';
346
+ throw error;
347
+ }
348
+ if (options?.signal?.aborted) throw abortError();
349
+ return entry.mountProjectGraph(container, options);
350
+ }
351
+
352
+ function abortError() {
353
+ const error = new Error('project_graph_mount_aborted');
354
+ error.name = 'AbortError';
355
+ return error;
356
+ }
357
+
358
+ export default createProjectGraphModel;