@andespindola/brainlink 0.1.0-beta.153 → 0.1.0-beta.155

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.
@@ -16,6 +16,7 @@ const state = {
16
16
  radius: new Float32Array(0),
17
17
  visible: new Uint8Array(0),
18
18
  highlighted: new Uint8Array(0),
19
+ focused: new Uint8Array(0),
19
20
  selected: new Uint8Array(0),
20
21
  edgeSource: new Uint32Array(0),
21
22
  edgeTarget: new Uint32Array(0),
@@ -23,6 +24,7 @@ const state = {
23
24
  }
24
25
  const nodeIndexById = new Map()
25
26
  const highlightedIds = new Set()
27
+ const focusedIds = new Set()
26
28
  let selectedNodeId = null
27
29
  let dirty = true
28
30
  let renderScheduled = false
@@ -179,6 +181,7 @@ const ensureNodeCapacity = (count) => {
179
181
  state.radius = new Float32Array(nextCapacity)
180
182
  state.visible = new Uint8Array(nextCapacity)
181
183
  state.highlighted = new Uint8Array(nextCapacity)
184
+ state.focused = new Uint8Array(nextCapacity)
182
185
  state.selected = new Uint8Array(nextCapacity)
183
186
  }
184
187
 
@@ -230,6 +233,7 @@ const loadChunk = (chunk) => {
230
233
  state.radius[index] = nodeRadius(relevance, kind)
231
234
  state.visible[index] = 0
232
235
  state.highlighted[index] = highlightedIds.has(id) ? 1 : 0
236
+ state.focused[index] = focusedIds.has(id) ? 1 : 0
233
237
  state.selected[index] = selectedNodeId === id ? 1 : 0
234
238
  nodeIndexById.set(id, index)
235
239
  }
@@ -390,6 +394,12 @@ const renderFrame = (now) => {
390
394
  1.22
391
395
  )
392
396
 
397
+ drawNodeLayer(
398
+ (index) => state.visible[index] === 1 && state.focused[index] === 1,
399
+ theme.nodeHighlight,
400
+ 1.12
401
+ )
402
+
393
403
  drawNodeLayer(
394
404
  (index) => state.visible[index] === 1 && state.selected[index] === 1,
395
405
  theme.nodeSelected,
@@ -489,6 +499,23 @@ const setHighlights = (ids) => {
489
499
  requestRender()
490
500
  }
491
501
 
502
+ const setFocus = (ids) => {
503
+ focusedIds.clear()
504
+ const list = Array.isArray(ids) ? ids : []
505
+ for (let index = 0; index < list.length; index += 1) {
506
+ const id = list[index]
507
+ if (typeof id === 'string' && id.length > 0) {
508
+ focusedIds.add(id)
509
+ }
510
+ }
511
+
512
+ for (let index = 0; index < state.nodeCount; index += 1) {
513
+ state.focused[index] = focusedIds.has(state.ids[index]) ? 1 : 0
514
+ }
515
+ dirty = true
516
+ requestRender()
517
+ }
518
+
492
519
  const setSelected = (id) => {
493
520
  selectedNodeId = typeof id === 'string' && id.length > 0 ? id : null
494
521
  for (let index = 0; index < state.nodeCount; index += 1) {
@@ -498,6 +525,22 @@ const setSelected = (id) => {
498
525
  requestRender()
499
526
  }
500
527
 
528
+ const moveNode = (id, x, y) => {
529
+ if (typeof id !== 'string' || !Number.isFinite(x) || !Number.isFinite(y)) {
530
+ return
531
+ }
532
+
533
+ const index = nodeIndexById.get(id)
534
+ if (index === undefined) {
535
+ return
536
+ }
537
+
538
+ state.x[index] = x
539
+ state.y[index] = y
540
+ dirty = true
541
+ requestRender()
542
+ }
543
+
501
544
  self.onmessage = (event) => {
502
545
  const payload = event.data
503
546
  if (!payload || typeof payload !== 'object') {
@@ -542,11 +585,21 @@ self.onmessage = (event) => {
542
585
  return
543
586
  }
544
587
 
588
+ if (payload.type === 'focus') {
589
+ setFocus(payload.ids)
590
+ return
591
+ }
592
+
545
593
  if (payload.type === 'select') {
546
594
  setSelected(payload.id)
547
595
  return
548
596
  }
549
597
 
598
+ if (payload.type === 'move-node') {
599
+ moveNode(payload.id, Number(payload.x), Number(payload.y))
600
+ return
601
+ }
602
+
550
603
  if (payload.type === 'pick') {
551
604
  const node = pickNode(
552
605
  Number.isFinite(payload.x) ? Number(payload.x) : 0,
@@ -1,10 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
+ import { addVisualContextEdges } from '../domain/graph-contexts.js';
4
5
  import { createStarGraphLayout } from '../domain/graph-layout.js';
5
6
  import { indexStoragePath } from '../infrastructure/file-index.js';
6
7
  import { getGraphSummary } from './get-graph-summary.js';
7
- const graphLayoutVersion = 4;
8
+ const graphLayoutVersion = 6;
8
9
  const graphLayoutCache = new Map();
9
10
  const safeCacheSegment = (value, fallback) => value?.replace(/[^a-zA-Z0-9_-]/g, '_') || fallback;
10
11
  const graphLayoutStoragePath = (vaultPath, options) => {
@@ -82,7 +83,7 @@ export const getGraphLayout = async (vaultPath, optionsOrAgentId) => {
82
83
  layout: persisted.layout
83
84
  };
84
85
  }
85
- const graph = await getGraphSummary(vaultPath, options.agentId);
86
+ const graph = addVisualContextEdges(await getGraphSummary(vaultPath, options.agentId));
86
87
  const scopedGraph = options.context ? filterGraphByContext(graph, options.context) : graph;
87
88
  const signature = createGraphSignature(scopedGraph);
88
89
  const layout = createLayout(scopedGraph);
@@ -0,0 +1,66 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ const stateVersion = 1;
4
+ const graphViewStatePath = (vaultPath) => join(vaultPath, '.brainlink', 'graph-view-state.json');
5
+ const stateKey = (input) => [input.signature, input.agentId ?? 'all-agents', input.context ?? 'all-contexts'].join(':');
6
+ const emptyPersistedState = () => ({
7
+ version: stateVersion,
8
+ states: {}
9
+ });
10
+ const readPersistedState = async (vaultPath) => {
11
+ try {
12
+ const parsed = JSON.parse(await readFile(graphViewStatePath(vaultPath), 'utf8'));
13
+ return parsed.version === stateVersion && parsed.states && typeof parsed.states === 'object' ? parsed : emptyPersistedState();
14
+ }
15
+ catch {
16
+ return emptyPersistedState();
17
+ }
18
+ };
19
+ const writePersistedState = async (vaultPath, state) => {
20
+ const target = graphViewStatePath(vaultPath);
21
+ const temp = `${target}.tmp`;
22
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
23
+ await writeFile(temp, `${JSON.stringify(state)}\n`, { encoding: 'utf8', mode: 0o600 });
24
+ await rename(temp, target);
25
+ };
26
+ const normalizePositions = (positions) => positions.flatMap((position) => {
27
+ const id = typeof position.id === 'string' ? position.id.trim() : '';
28
+ const x = Number(position.x);
29
+ const y = Number(position.y);
30
+ return id && Number.isFinite(x) && Number.isFinite(y) ? [{ id, x, y }] : [];
31
+ });
32
+ export const getGraphViewState = async (vaultPath, input) => {
33
+ const persisted = await readPersistedState(vaultPath);
34
+ const state = persisted.states[stateKey(input)];
35
+ return state ?? {
36
+ ...input,
37
+ positions: []
38
+ };
39
+ };
40
+ export const saveGraphViewState = async (vaultPath, input) => {
41
+ const persisted = await readPersistedState(vaultPath);
42
+ const nextState = {
43
+ ...input,
44
+ positions: normalizePositions(input.positions)
45
+ };
46
+ await writePersistedState(vaultPath, {
47
+ version: stateVersion,
48
+ states: {
49
+ ...persisted.states,
50
+ [stateKey(input)]: nextState
51
+ }
52
+ });
53
+ return nextState;
54
+ };
55
+ export const deleteGraphViewState = async (vaultPath, input) => {
56
+ const persisted = await readPersistedState(vaultPath);
57
+ const { [stateKey(input)]: _removed, ...states } = persisted.states;
58
+ await writePersistedState(vaultPath, {
59
+ version: stateVersion,
60
+ states
61
+ });
62
+ return {
63
+ ...input,
64
+ positions: []
65
+ };
66
+ };
@@ -1,6 +1,7 @@
1
1
  import { stat } from 'node:fs/promises';
2
2
  import { ensureVault } from '../infrastructure/file-system-vault.js';
3
3
  import { indexStoragePath, openFileIndex } from '../infrastructure/file-index.js';
4
+ import { getGraphLayout } from './get-graph-layout.js';
4
5
  const graphSearchCacheTtlMs = 20_000;
5
6
  const graphSearchCacheMaxEntries = 120;
6
7
  const graphSearchCache = new Map();
@@ -13,11 +14,12 @@ const readIndexSignature = async (vaultPath) => {
13
14
  return '0:0';
14
15
  }
15
16
  };
16
- const cacheKey = (vaultPath, query, limit, agentId) => JSON.stringify({
17
+ const cacheKey = (vaultPath, query, limit, agentId, context) => JSON.stringify({
17
18
  vaultPath,
18
19
  query: query.trim().toLowerCase(),
19
20
  limit,
20
- agentId: agentId?.trim().toLowerCase() ?? '*'
21
+ agentId: agentId?.trim().toLowerCase() ?? '*',
22
+ context: context?.trim().toLowerCase() ?? '*'
21
23
  });
22
24
  const readCached = (key, indexSignature) => {
23
25
  const entry = graphSearchCache.get(key);
@@ -39,17 +41,24 @@ const writeCached = (key, entry) => {
39
41
  const overflow = graphSearchCache.size - graphSearchCacheMaxEntries;
40
42
  Array.from(graphSearchCache.keys()).slice(0, overflow).forEach((cacheKey) => graphSearchCache.delete(cacheKey));
41
43
  };
42
- export const searchGraphNodeIds = async (vaultPath, query, limit, agentId) => {
44
+ export const searchGraphNodeIds = async (vaultPath, query, limit, agentId, context) => {
43
45
  const absoluteVaultPath = await ensureVault(vaultPath);
44
46
  const indexSignature = await readIndexSignature(absoluteVaultPath);
45
- const key = cacheKey(absoluteVaultPath, query, limit, agentId);
47
+ const key = cacheKey(absoluteVaultPath, query, limit, agentId, context);
46
48
  const cached = readCached(key, indexSignature);
47
49
  if (cached) {
48
50
  return cached;
49
51
  }
52
+ const contextNodeIds = context
53
+ ? new Set((await getGraphLayout(absoluteVaultPath, { agentId, context })).layout.nodes.map((node) => node.id))
54
+ : new Set();
50
55
  const index = openFileIndex(absoluteVaultPath);
51
56
  try {
52
- const nodeIds = await index.searchGraphNodeIds(query, limit, agentId);
57
+ const searchLimit = context ? Math.max(limit, 5000) : limit;
58
+ const foundNodeIds = await index.searchGraphNodeIds(query, searchLimit, agentId);
59
+ const nodeIds = context
60
+ ? foundNodeIds.filter((nodeId) => contextNodeIds.has(nodeId)).slice(0, limit)
61
+ : foundNodeIds;
53
62
  writeCached(key, {
54
63
  createdAt: Date.now(),
55
64
  indexSignature,
@@ -6,6 +6,7 @@ import { getGraphNode } from '../get-graph-node.js';
6
6
  import { getGraphLayout } from '../get-graph-layout.js';
7
7
  import { getGraphView } from '../get-graph-view.js';
8
8
  import { getGraphStreamChunk } from '../get-graph-stream-chunk.js';
9
+ import { deleteGraphViewState, getGraphViewState, saveGraphViewState } from '../graph-view-state.js';
9
10
  import { listAgents } from '../list-agents.js';
10
11
  import { listBacklinks, listLinks } from '../list-links.js';
11
12
  import { searchGraphNodeIds } from '../search-graph-node-ids.js';
@@ -64,6 +65,21 @@ const parseNumber = (value, fallback) => {
64
65
  const parsed = Number(value);
65
66
  return Number.isFinite(parsed) ? parsed : fallback;
66
67
  };
68
+ const readJsonBody = async (request, limitBytes = 1_000_000) => {
69
+ let body = '';
70
+ for await (const chunk of request) {
71
+ body += String(chunk);
72
+ if (Buffer.byteLength(body, 'utf8') > limitBytes) {
73
+ throw Object.assign(new Error('Request body too large'), { statusCode: 413 });
74
+ }
75
+ }
76
+ return body.trim().length > 0 ? JSON.parse(body) : {};
77
+ };
78
+ const readGraphViewStateInput = (url) => ({
79
+ signature: url.searchParams.get('signature')?.trim() ?? '',
80
+ agentId: readAgentQuery(url),
81
+ context: readContextQuery(url)
82
+ });
67
83
  const compactGraphLayoutThreshold = 12_000;
68
84
  const compactGraphLayoutEdgeLimit = 60_000;
69
85
  const graphLayoutBodyCacheLimit = 8;
@@ -285,6 +301,35 @@ export const route = async (request, url, vaultPath) => {
285
301
  context: readContextQuery(url)
286
302
  })), 200, contentTypes['.json']);
287
303
  }
304
+ if (isReadMethod(request) && url.pathname === '/api/graph-view-state') {
305
+ const input = readGraphViewStateInput(url);
306
+ if (!input.signature) {
307
+ return createResponse(createJsonResponse({ error: 'Missing signature query parameter' }), 400, contentTypes['.json']);
308
+ }
309
+ return createResponse(createJsonResponse(await getGraphViewState(vaultPath, input)), 200, contentTypes['.json']);
310
+ }
311
+ if (request.method === 'POST' && url.pathname === '/api/graph-view-state') {
312
+ const input = readGraphViewStateInput(url);
313
+ if (!input.signature) {
314
+ return createResponse(createJsonResponse({ error: 'Missing signature query parameter' }), 400, contentTypes['.json']);
315
+ }
316
+ const body = await readJsonBody(request);
317
+ const positions = Array.isArray(body.positions)
318
+ ? body.positions.map((position) => ({
319
+ id: String(position.id ?? ''),
320
+ x: Number(position.x),
321
+ y: Number(position.y)
322
+ }))
323
+ : [];
324
+ return createResponse(createJsonResponse(await saveGraphViewState(vaultPath, { ...input, positions })), 200, contentTypes['.json']);
325
+ }
326
+ if (request.method === 'DELETE' && url.pathname === '/api/graph-view-state') {
327
+ const input = readGraphViewStateInput(url);
328
+ if (!input.signature) {
329
+ return createResponse(createJsonResponse({ error: 'Missing signature query parameter' }), 400, contentTypes['.json']);
330
+ }
331
+ return createResponse(createJsonResponse(await deleteGraphViewState(vaultPath, input)), 200, contentTypes['.json']);
332
+ }
288
333
  if (isReadMethod(request) && url.pathname === '/api/graph-node') {
289
334
  const id = url.searchParams.get('id')?.trim() ?? '';
290
335
  if (!id) {
@@ -302,7 +347,7 @@ export const route = async (request, url, vaultPath) => {
302
347
  if (!query) {
303
348
  return createResponse(createJsonResponse({ query, nodeIds: [] }), 200, contentTypes['.json']);
304
349
  }
305
- const nodeIds = await searchGraphNodeIds(vaultPath, query, limit, readAgentQuery(url));
350
+ const nodeIds = await searchGraphNodeIds(vaultPath, query, limit, readAgentQuery(url), readContextQuery(url));
306
351
  return createResponse(createJsonResponse({ query, nodeIds }), 200, contentTypes['.json']);
307
352
  }
308
353
  if (isReadMethod(request) && url.pathname === '/api/agents') {
@@ -9,6 +9,7 @@ import { resolveDuplicateNotes, scanDuplicateNotes } from '../../application/ded
9
9
  import { importLegacySqliteDatabase } from '../../application/import-legacy-sqlite.js';
10
10
  import { indexVault, indexVaultWithOptions } from '../../application/index-vault.js';
11
11
  import { migrateContextLinks } from '../../application/migrate-context-links.js';
12
+ import { canonicalizeContextLinks } from '../../application/canonical-context-links.js';
12
13
  import { migrateVaultContent, planVaultMigration, previewVaultMigration, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
13
14
  import { createOfflinePackBackup } from '../../application/offline-pack-backup.js';
14
15
  import { startServer } from '../../application/start-server.js';
@@ -16,7 +17,7 @@ import { startVaultWatcher } from '../../application/watch-vault.js';
16
17
  import { doctorVault, getStats, validateVault } from '../../application/analyze-vault.js';
17
18
  import { defaultBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/config.js';
18
19
  import { loadBrainlinkConfig } from '../../infrastructure/config.js';
19
- import { assertVaultAllowed, ensureVault } from '../../infrastructure/file-system-vault.js';
20
+ import { assertVaultAllowed, ensureVault, isBucketVaultPath } from '../../infrastructure/file-system-vault.js';
20
21
  import { getBootstrapPolicy, getBootstrapSessionStatus, touchBootstrapSession } from '../../infrastructure/session-state.js';
21
22
  import { addVolatileMemory, clearVolatileMemory } from '../../infrastructure/volatile-memory.js';
22
23
  import { installAgentIntegration } from './agent-commands.js';
@@ -731,6 +732,37 @@ export const registerWriteCommands = (program) => {
731
732
  return `${mode} ${result.scanned} notes: changed=${result.changed}, skipped=${result.skipped}, limit=${result.limit}.${indexMessage}`;
732
733
  });
733
734
  });
735
+ program
736
+ .command('canonicalize-context-links')
737
+ .option('-v, --vault <vault>', 'vault directory')
738
+ .option('-a, --agent <agent>', 'agent memory namespace')
739
+ .option('--dry-run', 'preview canonical context links without writing files')
740
+ .option('--no-create-hubs', 'do not create missing context hub notes')
741
+ .option('--no-index', 'skip reindexing after canonicalization')
742
+ .option('--json', 'print machine-readable JSON')
743
+ .description('ensure notes have canonical Context Links to their inferred context hubs')
744
+ .action(async (options) => {
745
+ const resolved = await resolveOptions(options);
746
+ const result = await canonicalizeContextLinks(resolved.vault, {
747
+ dryRun: options.dryRun === true,
748
+ agentId: resolved.agent,
749
+ createMissingHubs: options.createHubs !== false
750
+ });
751
+ const shouldIndex = options.index !== false && !result.dryRun && result.changed > 0;
752
+ const index = shouldIndex ? await indexVault(resolved.vault, { full: true }) : undefined;
753
+ print(options.json, {
754
+ vault: resolved.vault,
755
+ agent: resolved.agent ?? 'shared',
756
+ ...result,
757
+ ...(index ? { index } : {})
758
+ }, () => {
759
+ const mode = result.dryRun ? 'Previewed' : 'Canonicalized';
760
+ const indexMessage = index
761
+ ? ` Fully reindexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} context links.`
762
+ : '';
763
+ return `${mode} ${result.scanned} notes: changed=${result.changed}, createdHubs=${result.createdHubs}, skipped=${result.skipped}.${indexMessage}`;
764
+ });
765
+ });
734
766
  program
735
767
  .command('db-import')
736
768
  .option('-v, --vault <vault>', 'vault directory')
@@ -793,6 +825,7 @@ export const registerWriteCommands = (program) => {
793
825
  .option('-v, --vault <vault>', 'vault directory')
794
826
  .option('-a, --agent <agent>', 'agent memory namespace')
795
827
  .option('--allow-sensitive', 'allow writing content that looks like a secret')
828
+ .option('--no-auto-context-links', 'skip canonical Context Links for this note')
796
829
  .option('--no-auto-index', 'skip reindexing after add')
797
830
  .option('--json', 'print machine-readable JSON')
798
831
  .description('add a markdown note to the vault')
@@ -800,7 +833,8 @@ export const registerWriteCommands = (program) => {
800
833
  const resolved = await resolveOptions(options);
801
834
  const content = resolveAddContent(options);
802
835
  const added = await addNoteWithMetadata(resolved.vault, title, content, resolved.agent, {
803
- allowSensitive: Boolean(options.allowSensitive)
836
+ allowSensitive: Boolean(options.allowSensitive),
837
+ autoContextLinks: options.autoContextLinks !== false && resolved.config.autoCanonicalContextLinks
804
838
  });
805
839
  const shouldAutoIndex = options.autoIndex !== false && resolved.config.autoIndexOnWrite;
806
840
  const index = shouldAutoIndex ? await indexVault(resolved.vault) : undefined;
@@ -824,7 +858,9 @@ export const registerWriteCommands = (program) => {
824
858
  writeConnectivity: {
825
859
  autoLinked: added.autoLinked,
826
860
  linkTarget: added.linkTarget,
827
- guaranteedEdge: false
861
+ context: added.context,
862
+ hubCreated: added.hubCreated,
863
+ guaranteedEdge: added.autoLinked
828
864
  },
829
865
  possibleDuplicates,
830
866
  ...(index ? { index } : {})
@@ -832,7 +868,8 @@ export const registerWriteCommands = (program) => {
832
868
  const duplicateMessage = possibleDuplicates.length > 0
833
869
  ? `\nPotential duplicates: ${possibleDuplicates.length}. Use "blink dedupe --json" or "blink dedupe-resolve".`
834
870
  : '';
835
- return `Created note at ${added.path}${duplicateMessage}`;
871
+ const linkMessage = added.autoLinked ? ` Linked to [[${added.linkTarget}]].` : '';
872
+ return `Created note at ${added.path}.${linkMessage}${duplicateMessage}`;
836
873
  });
837
874
  });
838
875
  program
@@ -1037,26 +1074,28 @@ export const registerWriteCommands = (program) => {
1037
1074
  .option('-p, --port <port>', 'server port', '4321')
1038
1075
  .option('--no-index', 'skip indexing before starting the server')
1039
1076
  .option('--no-open', 'do not open the graph UI automatically')
1040
- .option('-w, --watch', 'watch markdown files and reindex on changes')
1077
+ .option('-w, --watch', 'watch markdown files and reindex on changes', true)
1078
+ .option('--no-watch', 'disable markdown file watching')
1041
1079
  .option('--json', 'print machine-readable JSON')
1042
1080
  .description('start a local web UI for the knowledge graph')
1043
1081
  .action(async (options) => {
1044
1082
  const resolved = await resolveOptions(options);
1083
+ const shouldWatch = options.watch !== false && !isBucketVaultPath(resolved.vault);
1045
1084
  const server = await startServer({
1046
1085
  vaultPath: resolved.vault,
1047
1086
  host: options.host ?? resolved.config.host,
1048
1087
  port: parsePositiveInteger(options.port ?? String(resolved.config.port), resolved.config.port),
1049
1088
  shouldIndex: options.index,
1050
- shouldWatch: Boolean(options.watch)
1089
+ shouldWatch
1051
1090
  });
1052
1091
  const openResult = options.open !== false ? openUrlInUi(server.url, process.pid) : { opened: false, mode: 'none' };
1053
1092
  print(options.json, {
1054
1093
  url: server.url,
1055
- watch: Boolean(options.watch),
1094
+ watch: shouldWatch,
1056
1095
  readonly: true,
1057
1096
  openedUi: openResult.opened,
1058
1097
  openMode: openResult.mode
1059
- }, () => `Brainlink graph server running at ${server.url}${openResult.opened
1098
+ }, () => `Brainlink graph server running at ${server.url} (${shouldWatch ? 'watching for changes' : 'watch disabled'})${openResult.opened
1060
1099
  ? openResult.mode === 'native-gui'
1061
1100
  ? ' (opened in native desktop GUI)'
1062
1101
  : openResult.mode === 'app-window'
@@ -0,0 +1,159 @@
1
+ const normalize = (value) => value.trim().toLowerCase();
2
+ const includesAny = (value, patterns) => patterns.some((pattern) => pattern.test(value));
3
+ const contextId = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
4
+ const context = (title) => ({
5
+ id: contextId(title),
6
+ title
7
+ });
8
+ const byTitle = (left, right) => left.title.localeCompare(right.title);
9
+ const edgeKey = (source, target) => source < target ? `${source}|${target}` : `${target}|${source}`;
10
+ const nodeSearchText = (node) => normalize([node.title, node.path, ...node.tags].join(' '));
11
+ export const inferExplicitVisualGraphContext = (node) => {
12
+ const text = nodeSearchText(node);
13
+ const path = normalize(node.path);
14
+ if (includesAny(text, [/\bgithub repositories hub\b/]))
15
+ return context('GitHub Repositories');
16
+ if (includesAny(text, [/\bgithub organizations hub\b/]))
17
+ return context('GitHub Organizations');
18
+ if (includesAny(text, [/\bmachine configuration hub\b/]))
19
+ return context('Machine Configuration');
20
+ if (includesAny(text, [/\buser preferences hub\b/]))
21
+ return context('User Preferences');
22
+ if (includesAny(text, [/\bneovim lazyvim hub\b/]))
23
+ return context('Neovim LazyVim');
24
+ if (includesAny(text, [/\bgit workflow hub\b/]))
25
+ return context('Git Workflow');
26
+ if (includesAny(text, [/\bagent memory hub\b/]))
27
+ return context('Agent Memory');
28
+ if (path.startsWith('github-repos/'))
29
+ return context('GitHub Repositories');
30
+ if (path.startsWith('github-org-repos/'))
31
+ return context('GitHub Organizations');
32
+ if (path.startsWith('machine-config/'))
33
+ return context('Machine Configuration');
34
+ if (includesAny(text, [/\bbrainlink\b/]))
35
+ return context('Brainlink');
36
+ if (includesAny(text, [/\banonspace\b/]))
37
+ return context('AnonSpace');
38
+ if (includesAny(text, [/\bsubstructa\b/]))
39
+ return context('Substructa');
40
+ if (includesAny(text, [/\bnebula\b/]))
41
+ return context('Nebula');
42
+ if (includesAny(text, [/\bsnippets?\b/, /\bupgrader\b/, /\bversion-map\b/]))
43
+ return context('Snippets');
44
+ if (includesAny(text, [/\binkdrop\b/]))
45
+ return context('Inkdrop');
46
+ if (includesAny(text, [
47
+ /\bpreference\b/,
48
+ /\bpreferencia\b/,
49
+ /\bpreferencias\b/,
50
+ /\bplaybook\b/,
51
+ /\bdirective\b/,
52
+ /\bengineering-style\b/,
53
+ /\bglobal-engineering\b/,
54
+ /\bcoding-identity\b/,
55
+ /\bagents\.md\b/
56
+ ])) {
57
+ return context('User Preferences');
58
+ }
59
+ if (includesAny(text, [/\blazyvim\b/, /\bneovim\b/, /\bnvim\b/, /\bmason\b/, /\bwrapper\b/]))
60
+ return context('Neovim LazyVim');
61
+ if (includesAny(text, [/\bgit-flow\b/, /\borigin-sync\b/, /\bgit-identidade\b/, /\bcommit\b/, /\bpush\b/]))
62
+ return context('Git Workflow');
63
+ if (includesAny(text, [/\bdocker\b/, /\bkubernetes\b/, /\bdeploy\b/, /\bredeploy\b/]))
64
+ return context('Operations');
65
+ if (path.startsWith('agents/'))
66
+ return context('Agent Memory');
67
+ return null;
68
+ };
69
+ export const inferVisualGraphContext = (node) => {
70
+ const explicit = inferExplicitVisualGraphContext(node);
71
+ if (explicit) {
72
+ return explicit;
73
+ }
74
+ const [root] = node.path.split('/').filter(Boolean);
75
+ return context(root ? root.replace(/[-_]+/g, ' ') : 'Root');
76
+ };
77
+ export const groupNodesByVisualContext = (nodes) => {
78
+ const groups = new Map();
79
+ nodes.forEach((node) => {
80
+ const visualContext = inferVisualGraphContext(node);
81
+ const bucket = groups.get(visualContext.title);
82
+ if (bucket) {
83
+ bucket.push(node);
84
+ return;
85
+ }
86
+ groups.set(visualContext.title, [node]);
87
+ });
88
+ return new Map(Array.from(groups.entries(), ([title, groupedNodes]) => [title, [...groupedNodes].sort(byTitle)]));
89
+ };
90
+ const countDegrees = (edges) => edges.reduce((degrees, edge) => {
91
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edge.weight);
92
+ if (edge.target) {
93
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edge.weight);
94
+ }
95
+ return degrees;
96
+ }, new Map());
97
+ const selectVisualHub = (contextTitle, nodes, degrees) => {
98
+ const normalizedContext = normalize(contextTitle).replace(/\s+/g, ' ');
99
+ const ranked = [...nodes].sort((left, right) => {
100
+ const leftTitle = normalize(left.title);
101
+ const rightTitle = normalize(right.title);
102
+ const leftHubScore = leftTitle === normalizedContext || leftTitle === `${normalizedContext} hub`
103
+ ? 4
104
+ : leftTitle.includes(normalizedContext) && /\bhub\b/.test(leftTitle)
105
+ ? 3
106
+ : /\b(memory hub|knowledge root|moc|map|hub)\b/.test(leftTitle)
107
+ ? 2
108
+ : 0;
109
+ const rightHubScore = rightTitle === normalizedContext || rightTitle === `${normalizedContext} hub`
110
+ ? 4
111
+ : rightTitle.includes(normalizedContext) && /\bhub\b/.test(rightTitle)
112
+ ? 3
113
+ : /\b(memory hub|knowledge root|moc|map|hub)\b/.test(rightTitle)
114
+ ? 2
115
+ : 0;
116
+ const hubDelta = rightHubScore - leftHubScore;
117
+ if (hubDelta !== 0)
118
+ return hubDelta;
119
+ const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
120
+ return degreeDelta === 0 ? left.title.localeCompare(right.title) : degreeDelta;
121
+ });
122
+ return ranked[0] ?? null;
123
+ };
124
+ export const addVisualContextEdges = (graph) => {
125
+ const existingPairs = new Set(graph.edges
126
+ .filter((edge) => Boolean(edge.target))
127
+ .map((edge) => edgeKey(edge.source, edge.target)));
128
+ const degrees = countDegrees(graph.edges);
129
+ const derivedEdges = [];
130
+ for (const [contextTitle, nodes] of groupNodesByVisualContext(graph.nodes).entries()) {
131
+ if (nodes.length <= 1) {
132
+ continue;
133
+ }
134
+ const hub = selectVisualHub(contextTitle, nodes, degrees);
135
+ if (!hub) {
136
+ continue;
137
+ }
138
+ nodes
139
+ .filter((node) => node.id !== hub.id)
140
+ .forEach((node) => {
141
+ const key = edgeKey(hub.id, node.id);
142
+ if (existingPairs.has(key)) {
143
+ return;
144
+ }
145
+ existingPairs.add(key);
146
+ derivedEdges.push({
147
+ source: hub.id,
148
+ target: node.id,
149
+ targetTitle: node.title,
150
+ weight: 0.5,
151
+ priority: 'low'
152
+ });
153
+ });
154
+ }
155
+ return {
156
+ nodes: graph.nodes,
157
+ edges: [...graph.edges, ...derivedEdges]
158
+ };
159
+ };