@andespindola/brainlink 1.0.5 → 1.0.6

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 (51) hide show
  1. package/README.md +8 -0
  2. package/dist/application/add-note.js +2 -2
  3. package/dist/application/build-context.js +16 -10
  4. package/dist/application/canonical-context-links.js +44 -5
  5. package/dist/application/check-package-update.js +105 -0
  6. package/dist/application/frontend/client/chunk-fetch.js +236 -0
  7. package/dist/application/frontend/client/controls.js +178 -0
  8. package/dist/application/frontend/client/elements.js +122 -0
  9. package/dist/application/frontend/client/input.js +202 -0
  10. package/dist/application/frontend/client/node-details.js +191 -0
  11. package/dist/application/frontend/client/rendering.js +296 -0
  12. package/dist/application/frontend/client/scope-theme.js +114 -0
  13. package/dist/application/frontend/client/spatial.js +98 -0
  14. package/dist/application/frontend/client/storage.js +215 -0
  15. package/dist/application/frontend/client/upload.js +90 -0
  16. package/dist/application/frontend/client/worker-bootstrap.js +147 -0
  17. package/dist/application/frontend/client-js.js +24 -1837
  18. package/dist/application/frontend/client-render-worker-js.js +1 -1
  19. package/dist/application/index-vault-phases.js +189 -0
  20. package/dist/application/index-vault.js +44 -165
  21. package/dist/cli/commands/write/dedupe-commands.js +59 -0
  22. package/dist/cli/commands/write/index-commands.js +205 -0
  23. package/dist/cli/commands/write/link-commands.js +68 -0
  24. package/dist/cli/commands/write/note-commands.js +146 -0
  25. package/dist/cli/commands/write/server-commands.js +553 -0
  26. package/dist/cli/commands/write/shared.js +35 -0
  27. package/dist/cli/commands/write/vault-lifecycle-commands.js +270 -0
  28. package/dist/cli/commands/write-commands.js +12 -1303
  29. package/dist/cli/main.js +39 -3
  30. package/dist/domain/context.js +39 -3
  31. package/dist/domain/embeddings.js +31 -5
  32. package/dist/domain/graph-contexts.js +62 -57
  33. package/dist/domain/graph-layout/cauliflower-layout.js +116 -0
  34. package/dist/domain/graph-layout/collisions.js +100 -0
  35. package/dist/domain/graph-layout/hierarchy.js +135 -0
  36. package/dist/domain/graph-layout/metrics.js +111 -0
  37. package/dist/domain/graph-layout/segments.js +76 -0
  38. package/dist/domain/graph-layout/star-layout.js +110 -0
  39. package/dist/domain/graph-layout.js +4 -625
  40. package/dist/infrastructure/config.js +6 -0
  41. package/dist/infrastructure/file-index.js +13 -4
  42. package/dist/infrastructure/semantic-prefilter.js +24 -0
  43. package/dist/mcp/server.js +7 -0
  44. package/dist/mcp/tool-guard.js +29 -0
  45. package/dist/mcp/tools/maintenance-tools.js +409 -0
  46. package/dist/mcp/tools/read-tools.js +504 -0
  47. package/dist/mcp/tools/shared.js +216 -0
  48. package/dist/mcp/tools/write-tools.js +247 -0
  49. package/dist/mcp/tools.js +3 -1357
  50. package/docs/QUICKSTART.md +4 -0
  51. package/package.json +2 -2
package/dist/cli/main.js CHANGED
@@ -3,17 +3,52 @@ import { Command } from 'commander';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { basename, dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { checkPackageUpdate } from '../application/check-package-update.js';
7
+ import { loadBrainlinkConfig } from '../infrastructure/config.js';
6
8
  import { registerAgentCommands } from './commands/agent-commands.js';
7
9
  import { registerConfigCommands } from './commands/config-commands.js';
8
10
  import { registerPracticalCommands } from './commands/practical-commands.js';
9
11
  import { registerReadCommands } from './commands/read-commands.js';
10
12
  import { registerVaultCommands } from './commands/vault-commands.js';
11
13
  import { registerWriteCommands } from './commands/write-commands.js';
12
- const readPackageVersion = () => {
14
+ const readPackageMetadata = () => {
13
15
  const packagePath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
14
16
  const metadata = JSON.parse(readFileSync(packagePath, 'utf8'));
15
- return metadata.version ?? '0.0.0';
17
+ return {
18
+ name: metadata.name ?? 'brainlink',
19
+ version: metadata.version ?? '0.0.0'
20
+ };
16
21
  };
22
+ const shouldSkipUpdateCheck = () => process.env.BRAINLINK_NO_UPDATE_CHECK === '1' ||
23
+ process.env.BRAINLINK_NO_UPDATE_CHECK === 'true' ||
24
+ process.env.CI === 'true' ||
25
+ process.env.VITEST === 'true' ||
26
+ process.env.NODE_ENV === 'test' ||
27
+ process.argv.includes('--version') ||
28
+ process.argv.includes('-V') ||
29
+ process.argv.includes('--help') ||
30
+ process.argv.includes('-h');
31
+ const maybePrintUpdateNotice = async (metadata) => {
32
+ if (shouldSkipUpdateCheck()) {
33
+ return;
34
+ }
35
+ try {
36
+ const config = await loadBrainlinkConfig();
37
+ const status = await checkPackageUpdate({
38
+ packageName: metadata.name,
39
+ currentVersion: metadata.version,
40
+ enabled: config.autoUpdateCheck,
41
+ intervalMs: config.updateCheckIntervalMs
42
+ });
43
+ if (!status.skipped && status.updateAvailable && status.latestVersion) {
44
+ console.error(`Brainlink ${status.latestVersion} is available. Current version: ${status.currentVersion}. Update with: ${status.installCommand}`);
45
+ }
46
+ }
47
+ catch {
48
+ // Update checks must never block normal CLI execution.
49
+ }
50
+ };
51
+ const packageMetadata = readPackageMetadata();
17
52
  const program = new Command();
18
53
  const cliName = basename(process.argv[1] ?? 'brainlink');
19
54
  const displayName = cliName === 'blink' ? 'blink' : 'brainlink';
@@ -22,13 +57,14 @@ program
22
57
  .name(displayName)
23
58
  .alias(aliasName)
24
59
  .description('Local-first knowledge memory for agents')
25
- .version(readPackageVersion());
60
+ .version(packageMetadata.version);
26
61
  registerWriteCommands(program);
27
62
  registerReadCommands(program);
28
63
  registerPracticalCommands(program);
29
64
  registerConfigCommands(program);
30
65
  registerVaultCommands(program);
31
66
  registerAgentCommands(program);
67
+ await maybePrintUpdateNotice(packageMetadata);
32
68
  program.parseAsync().catch((error) => {
33
69
  const message = error instanceof Error ? error.message : String(error);
34
70
  console.error(message);
@@ -1,5 +1,21 @@
1
1
  import { middleOutIndices } from './middle-out.js';
2
2
  const maxSectionsPerDocument = 3;
3
+ const charsPerToken = 4;
4
+ const sectionSeparatorTokens = 1;
5
+ const headerReserveTokens = 8;
6
+ const estimateTokens = (text) => Math.ceil(text.length / charsPerToken);
7
+ const truncateToTokens = (text, maxTokens) => {
8
+ const maxChars = Math.max(0, maxTokens) * charsPerToken;
9
+ return text.length <= maxChars ? text : text.slice(0, maxChars);
10
+ };
11
+ // Mirror the per-section framing that formatContextPackage renders (heading,
12
+ // Source/Tags/Score/Mode lines and the trailing separator) so the budget
13
+ // reflects the package the agent actually receives, not just chunk content.
14
+ const estimateSectionTokens = (result) => {
15
+ const tagsLine = result.tags.length > 0 ? `Tags: ${result.tags.map((tag) => `#${tag}`).join(' ')}\n` : '';
16
+ const framing = `## . ${result.title}\nSource: ${result.path}\n${tagsLine}Score: 0.000\nMode: ${result.searchMode}\n\n`;
17
+ return estimateTokens(result.content) + estimateTokens(framing) + sectionSeparatorTokens;
18
+ };
3
19
  const byScore = (left, right) => right.score - left.score || left.title.localeCompare(right.title);
4
20
  const byOrdinal = (left, right) => (left.chunkOrdinal ?? Number.MAX_SAFE_INTEGER) - (right.chunkOrdinal ?? Number.MAX_SAFE_INTEGER);
5
21
  const middleOutDocumentResults = (results) => {
@@ -38,7 +54,7 @@ export const selectContextSections = (results, maxTokens) => {
38
54
  if (seenChunks.has(result.chunkId)) {
39
55
  continue;
40
56
  }
41
- const tokenCost = Math.ceil(result.content.length / 4);
57
+ const tokenCost = estimateSectionTokens(result);
42
58
  if (usedTokens + tokenCost > maxTokens) {
43
59
  break;
44
60
  }
@@ -62,11 +78,31 @@ export const selectContextSections = (results, maxTokens) => {
62
78
  seenChunks
63
79
  };
64
80
  }, {
65
- usedTokens: 0,
81
+ usedTokens: headerReserveTokens,
66
82
  sections: [],
67
83
  seenChunks: new Set()
68
84
  });
69
- return selected.sections;
85
+ if (selected.sections.length > 0 || results.length === 0) {
86
+ return selected.sections;
87
+ }
88
+ // Retrieval found matches but every chunk overflowed the token budget. Never
89
+ // surface an empty context in that case: include the highest-scored chunk,
90
+ // truncated so the rendered package still respects the budget. This keeps
91
+ // brainlink_context consistent with brainlink_search instead of reporting
92
+ // "No relevant context found." whenever the strongest chunk is large.
93
+ const topResult = [...results].sort(byScore)[0];
94
+ const framingTokens = estimateSectionTokens({ ...topResult, content: '' });
95
+ const contentTokenBudget = maxTokens - headerReserveTokens - framingTokens;
96
+ return [
97
+ {
98
+ title: topResult.title,
99
+ path: topResult.path,
100
+ content: truncateToTokens(topResult.content, contentTokenBudget),
101
+ score: topResult.score,
102
+ searchMode: topResult.searchMode,
103
+ tags: topResult.tags
104
+ }
105
+ ];
70
106
  };
71
107
  export const formatContextPackage = (query, sections) => {
72
108
  const body = sections
@@ -67,8 +67,16 @@ const featureHash = (feature) => {
67
67
  const sign = value & 1 ? 1 : -1;
68
68
  return [index, sign];
69
69
  };
70
+ const vectorMagnitude = (vector) => {
71
+ let sumSquares = 0;
72
+ for (let index = 0; index < vector.length; index += 1) {
73
+ const value = vector[index];
74
+ sumSquares += value * value;
75
+ }
76
+ return Math.sqrt(sumSquares);
77
+ };
70
78
  const normalizeVector = (vector) => {
71
- const magnitude = Math.hypot(...vector);
79
+ const magnitude = vectorMagnitude(vector);
72
80
  return magnitude === 0 ? vector : vector.map((value) => value / magnitude);
73
81
  };
74
82
  const applyFeature = (vector, feature, weight) => {
@@ -86,15 +94,33 @@ export const createLocalEmbedding = (input) => {
86
94
  const weighted = tokenFeatures(tokens).reduce((vector, feature) => applyFeature(vector, feature, feature.startsWith('b:') ? 0.65 : 1), initial);
87
95
  return normalizeVector(weighted);
88
96
  };
97
+ // Dot product over the shared prefix. For L2-normalized vectors (every vector
98
+ // from createLocalEmbedding is normalized) this equals cosine similarity, so
99
+ // the hot retrieval loop can skip the per-comparison magnitude recompute.
100
+ export const dotProduct = (left, right) => {
101
+ const length = Math.min(left.length, right.length);
102
+ let total = 0;
103
+ for (let index = 0; index < length; index += 1) {
104
+ total += left[index] * right[index];
105
+ }
106
+ return total;
107
+ };
89
108
  export const cosineSimilarity = (left, right) => {
90
109
  const length = Math.min(left.length, right.length);
91
110
  if (length === 0) {
92
111
  return 0;
93
112
  }
94
- const dot = left.slice(0, length).reduce((total, value, index) => total + value * (right[index] ?? 0), 0);
95
- const leftMagnitude = Math.hypot(...left.slice(0, length));
96
- const rightMagnitude = Math.hypot(...right.slice(0, length));
97
- return leftMagnitude === 0 || rightMagnitude === 0 ? 0 : dot / (leftMagnitude * rightMagnitude);
113
+ let dot = 0;
114
+ let leftSumSquares = 0;
115
+ let rightSumSquares = 0;
116
+ for (let index = 0; index < length; index += 1) {
117
+ const leftValue = left[index];
118
+ const rightValue = right[index];
119
+ dot += leftValue * rightValue;
120
+ leftSumSquares += leftValue * leftValue;
121
+ rightSumSquares += rightValue * rightValue;
122
+ }
123
+ return leftSumSquares === 0 || rightSumSquares === 0 ? 0 : dot / Math.sqrt(leftSumSquares * rightSumSquares);
98
124
  };
99
125
  const bucketKey = (index, value) => `${value >= 0 ? 'p' : 'n'}:${index}`;
100
126
  export const createEmbeddingBuckets = (vector, bucketCount = defaultEmbeddingBucketCount) => vector
@@ -8,7 +8,59 @@ const context = (title) => ({
8
8
  const byTitle = (left, right) => left.title.localeCompare(right.title);
9
9
  const edgeKey = (source, target) => source < target ? `${source}|${target}` : `${target}|${source}`;
10
10
  const nodeSearchText = (node) => normalize([node.title, node.path, ...node.tags].join(' '));
11
- export const inferExplicitVisualGraphContext = (node) => {
11
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12
+ // Hub notes name their context as "<Context> Hub". These reserved hubs are
13
+ // structural anchors of the graph, not visual contexts, so they never become
14
+ // keyword rules.
15
+ const reservedHubTitles = new Set(['memory hub', 'knowledge root']);
16
+ const hubContextPattern = /^(.+?)\s+hub$/i;
17
+ // Extra keywords can be declared in a hub note body via a "Keywords: a, b, c"
18
+ // line so a context can match terms beyond its own name.
19
+ const parseHubKeywords = (content) => {
20
+ const match = content.match(/^\s*keywords?\s*:\s*(.+)$/im);
21
+ if (!match) {
22
+ return [];
23
+ }
24
+ return match[1]
25
+ .split(',')
26
+ .map((keyword) => normalize(keyword))
27
+ .filter((keyword) => keyword.length > 0);
28
+ };
29
+ // Derive content-classification rules from the hub notes present in the graph.
30
+ // A "<Context> Hub" note contributes the context, keyed by its normalized name
31
+ // plus any declared extra keywords. This keeps vault-specific taxonomy in the
32
+ // vault instead of hardcoding it in the engine.
33
+ export const deriveVisualContextRules = (nodes) => {
34
+ const rules = new Map();
35
+ nodes.forEach((node) => {
36
+ const match = node.title.trim().match(hubContextPattern);
37
+ if (!match) {
38
+ return;
39
+ }
40
+ if (reservedHubTitles.has(normalize(node.title))) {
41
+ return;
42
+ }
43
+ const contextTitle = match[1].trim();
44
+ if (contextTitle.length === 0) {
45
+ return;
46
+ }
47
+ const keywords = rules.get(contextTitle) ?? new Set();
48
+ keywords.add(normalize(contextTitle));
49
+ parseHubKeywords(node.content).forEach((keyword) => keywords.add(keyword));
50
+ rules.set(contextTitle, keywords);
51
+ });
52
+ return Array.from(rules.entries(), ([contextTitle, keywords]) => ({
53
+ context: contextTitle,
54
+ keywords: Array.from(keywords)
55
+ }));
56
+ };
57
+ const matchRule = (text, rules) => {
58
+ const matched = rules.find((rule) => rule.keywords.some((keyword) => new RegExp(`\\b${escapeRegExp(keyword)}\\b`).test(text)));
59
+ return matched ? context(matched.context) : null;
60
+ };
61
+ // Structural, vault-agnostic classification. Named-hub phrases and path layout
62
+ // are generic conventions; vault-specific contexts come from `rules`.
63
+ export const inferExplicitVisualGraphContext = (node, rules = []) => {
12
64
  const text = nodeSearchText(node);
13
65
  const path = normalize(node.path);
14
66
  if (includesAny(text, [/\bgithub repositories hub\b/]))
@@ -25,80 +77,32 @@ export const inferExplicitVisualGraphContext = (node) => {
25
77
  return context('Git Workflow');
26
78
  if (includesAny(text, [/\bagent memory hub\b/]))
27
79
  return context('Agent Memory');
28
- if (includesAny(text, [/pingu_ai_codding_pair_programming/, /\bpingu\b/]))
29
- return context('Pingu');
30
80
  if (path.startsWith('github-repos/'))
31
81
  return context('GitHub Repositories');
32
82
  if (path.startsWith('github-org-repos/'))
33
83
  return context('GitHub Organizations');
34
84
  if (path.startsWith('machine-config/'))
35
85
  return context('Machine Configuration');
36
- if (includesAny(text, [/\bbrainlink\b/]))
37
- return context('Brainlink');
38
- if (includesAny(text, [/\banonspace\b/]))
39
- return context('AnonSpace');
40
- if (includesAny(text, [/\bsubstructa\b/]))
41
- return context('Substructa');
42
- if (includesAny(text, [/\bnebula\b/]))
43
- return context('Nebula');
44
- if (includesAny(text, [/\bsnippets?\b/, /\bupgrader\b/, /\bversion-map\b/]))
45
- return context('Snippets');
46
- if (includesAny(text, [
47
- /\bpreference\b/,
48
- /\bpreferences\b/,
49
- /\bpreferencia\b/,
50
- /\bpreferencias\b/,
51
- /\bpreferência\b/,
52
- /\bpreferências\b/,
53
- /\bplaybook\b/,
54
- /\bdirective\b/,
55
- /\bdirectives\b/,
56
- /\bdiretiva\b/,
57
- /\bdiretivas\b/,
58
- /\bengineering-style\b/,
59
- /\bglobal-engineering\b/,
60
- /\bcoding-identity\b/,
61
- /\bagents\.md\b/,
62
- /\bagents-md\b/,
63
- /\bordem direta\b/,
64
- /\bordem-direta\b/,
65
- /\bman-in-the-loop\b/,
66
- /\bconfig geral\b/,
67
- /\bconfig-geral\b/,
68
- /\bsync config_files\b/,
69
- /\bsync-config-files\b/,
70
- /\bregra operacional\b/,
71
- /\bregras operacionais\b/,
72
- /\boperational rule\b/,
73
- /\boperational rules\b/,
74
- /\boperational policy\b/
75
- ])) {
76
- return context('User Preferences');
86
+ const ruleMatch = matchRule(text, rules);
87
+ if (ruleMatch) {
88
+ return ruleMatch;
77
89
  }
78
- if (includesAny(text, [/\binkdrop\b/]))
79
- return context('Inkdrop');
80
- if (includesAny(text, [/\blazyvim\b/, /\bneovim\b/, /\bnvim\b/, /\bmason\b/, /\bwrapper\b/]))
81
- return context('Neovim LazyVim');
82
- if (includesAny(text, [/\bgit-flow\b/, /\borigin-sync\b/, /\bgit-identidade\b/, /\bcommit\b/, /\bpush\b/]))
83
- return context('Git Workflow');
84
- if (includesAny(text, [/\bdocker\b/, /\bkubernetes\b/, /\bdeploy\b/, /\bredeploy\b/]))
85
- return context('Operations');
86
90
  if (path.startsWith('agents/'))
87
91
  return context('Agent Memory');
88
92
  return null;
89
93
  };
90
- export const inferVisualGraphContext = (node) => {
91
- const explicit = inferExplicitVisualGraphContext(node);
94
+ export const inferVisualGraphContext = (node, rules = []) => {
95
+ const explicit = inferExplicitVisualGraphContext(node, rules);
92
96
  if (explicit) {
93
97
  return explicit;
94
98
  }
95
99
  const [root] = node.path.split('/').filter(Boolean);
96
100
  return context(root ? root.replace(/[-_]+/g, ' ') : 'Root');
97
101
  };
98
- export const groupNodesByVisualContext = (nodes) => {
102
+ export const groupNodesByVisualContext = (nodes, rules = deriveVisualContextRules(nodes)) => {
99
103
  const groups = new Map();
100
104
  nodes.forEach((node) => {
101
- const visualContext = inferVisualGraphContext(node);
105
+ const visualContext = inferVisualGraphContext(node, rules);
102
106
  const bucket = groups.get(visualContext.title);
103
107
  if (bucket) {
104
108
  bucket.push(node);
@@ -147,8 +151,9 @@ export const addVisualContextEdges = (graph) => {
147
151
  .filter((edge) => Boolean(edge.target))
148
152
  .map((edge) => edgeKey(edge.source, edge.target)));
149
153
  const degrees = countDegrees(graph.edges);
154
+ const rules = deriveVisualContextRules(graph.nodes);
150
155
  const derivedEdges = [];
151
- for (const [contextTitle, nodes] of groupNodesByVisualContext(graph.nodes).entries()) {
156
+ for (const [contextTitle, nodes] of groupNodesByVisualContext(graph.nodes, rules).entries()) {
152
157
  if (nodes.length <= 1) {
153
158
  continue;
154
159
  }
@@ -0,0 +1,116 @@
1
+ import { byDegreeThenTitle, centerLayoutByNode, countDegrees, groupKey, groupLabel, hubScore, jitter, segmentAngle, selectHighestDegreeNodeId, selectPrimaryHubId } from './metrics.js';
2
+ import { assignSegments, groupNodesBySegment } from './segments.js';
3
+ import { relaxCollisions } from './collisions.js';
4
+ import { createGraphLayoutHierarchy } from './hierarchy.js';
5
+ const petalRadiusForSegmentSize = (size) => {
6
+ const safeSize = Math.max(size, 1);
7
+ return Math.max(260, Math.sqrt(safeSize) * 96);
8
+ };
9
+ const selectSegmentHub = (nodes, degrees, primaryHubId) => {
10
+ const primary = nodes.find((node) => node.id === primaryHubId);
11
+ if (primary) {
12
+ return primary;
13
+ }
14
+ return [...nodes].sort((left, right) => {
15
+ const hubDelta = hubScore(right) - hubScore(left);
16
+ if (hubDelta !== 0)
17
+ return hubDelta;
18
+ const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
19
+ if (degreeDelta !== 0)
20
+ return degreeDelta;
21
+ return left.title.localeCompare(right.title);
22
+ })[0] ?? null;
23
+ };
24
+ const segmentCenterRadius = (segments) => {
25
+ if (segments.length <= 1) {
26
+ return 0;
27
+ }
28
+ const circumference = segments.reduce((total, [, nodes]) => total + petalRadiusForSegmentSize(nodes.length) * 2 + 180, 0);
29
+ return Math.max(520, circumference / (Math.PI * 2));
30
+ };
31
+ const createCauliflowerSegmentNodes = (segments, degrees, rootHubId, segmentGroups) => ([segment, nodes], segmentIndex) => {
32
+ const sortedNodes = [...nodes].sort(byDegreeThenTitle(degrees));
33
+ const segmentHub = selectSegmentHub(sortedNodes, degrees, rootHubId);
34
+ const angle = segmentAngle(segment, segmentIndex, segmentGroups.length);
35
+ const globalRadius = segmentCenterRadius(segmentGroups);
36
+ const petalRadius = petalRadiusForSegmentSize(sortedNodes.length);
37
+ const isPrimarySegment = Boolean(segmentHub && segmentHub.id === rootHubId);
38
+ const centerX = isPrimarySegment || globalRadius === 0 ? 0 : Math.cos(angle) * globalRadius;
39
+ const centerY = isPrimarySegment || globalRadius === 0 ? 0 : Math.sin(angle) * (globalRadius * 0.86);
40
+ const nonHubNodes = sortedNodes.filter((node) => node.id !== segmentHub?.id);
41
+ const hubNode = segmentHub
42
+ ? [{
43
+ ...segmentHub,
44
+ group: groupLabel(groupKey(segmentHub)),
45
+ segment: segments.get(segmentHub.id) ?? segment,
46
+ x: centerX,
47
+ y: centerY
48
+ }]
49
+ : [];
50
+ const petalNodes = nonHubNodes.map((node, index) => {
51
+ const localAngle = index * 2.399963 + jitter(node.title, 0.5);
52
+ const radialLayer = Math.sqrt(index + 1) / Math.sqrt(Math.max(nonHubNodes.length, 1));
53
+ const localRadius = 150 + radialLayer * petalRadius + jitter(node.id, 34);
54
+ const degreePull = Math.min(degrees.get(node.id) ?? 0, 16) * 8;
55
+ const radius = Math.max(126, localRadius - degreePull);
56
+ return {
57
+ ...node,
58
+ group: groupLabel(groupKey(node)),
59
+ segment: segments.get(node.id) ?? segment,
60
+ x: centerX + Math.cos(localAngle) * radius + jitter(node.title, 20),
61
+ y: centerY + Math.sin(localAngle) * radius * 0.84 + jitter(node.path, 20)
62
+ };
63
+ });
64
+ return [...hubNode, ...petalNodes];
65
+ };
66
+ const createVisualEdge = (source, target, weight, priority) => ({
67
+ source: source.id,
68
+ target: target.id,
69
+ targetTitle: target.title,
70
+ weight,
71
+ priority
72
+ });
73
+ const createCauliflowerVisualEdges = (segmentGroups, degrees, rootHubId) => {
74
+ const nodeById = new Map(segmentGroups.flatMap(([, nodes]) => nodes.map((node) => [node.id, node])));
75
+ const rootHub = rootHubId ? nodeById.get(rootHubId) ?? null : null;
76
+ const edges = new Map();
77
+ const addEdge = (edge) => {
78
+ if (!edge.target || edge.source === edge.target) {
79
+ return;
80
+ }
81
+ edges.set(`${edge.source}|${edge.target}`, edge);
82
+ };
83
+ segmentGroups.forEach(([, nodes]) => {
84
+ const segmentHub = selectSegmentHub(nodes, degrees, rootHubId);
85
+ const parent = segmentHub ?? rootHub;
86
+ if (!parent) {
87
+ return;
88
+ }
89
+ if (rootHub && parent.id !== rootHub.id) {
90
+ addEdge(createVisualEdge(rootHub, parent, 6, 'high'));
91
+ }
92
+ nodes.forEach((node) => {
93
+ if (node.id === parent.id || node.id === rootHub?.id) {
94
+ return;
95
+ }
96
+ addEdge(createVisualEdge(parent, node, 1, 'low'));
97
+ });
98
+ });
99
+ return Array.from(edges.values());
100
+ };
101
+ export const createCauliflowerGraphLayout = (graph) => {
102
+ const degrees = countDegrees(graph.edges);
103
+ const segments = assignSegments(graph.nodes, graph.edges, degrees);
104
+ const segmentGroups = Array.from(groupNodesBySegment(graph.nodes, segments).entries())
105
+ .sort(([left], [right]) => left.localeCompare(right));
106
+ const rootHubId = selectPrimaryHubId(graph.nodes, degrees) ?? selectHighestDegreeNodeId(graph.nodes, degrees);
107
+ const nodes = relaxCollisions(segmentGroups.flatMap(createCauliflowerSegmentNodes(segments, degrees, rootHubId, segmentGroups)), 156, 28);
108
+ const centeredNodes = centerLayoutByNode(nodes, rootHubId);
109
+ const visualEdges = createCauliflowerVisualEdges(segmentGroups, degrees, rootHubId);
110
+ const groups = createGraphLayoutHierarchy(centeredNodes, visualEdges, degrees);
111
+ return {
112
+ nodes: centeredNodes,
113
+ edges: visualEdges,
114
+ ...(groups.length > 0 ? { groups } : {})
115
+ };
116
+ };
@@ -0,0 +1,100 @@
1
+ import { hashText } from './metrics.js';
2
+ const resolveCollisionPair = (left, right, minDistance) => {
3
+ const dx = right.x - left.x;
4
+ const dy = right.y - left.y;
5
+ const distance = Math.max(Math.hypot(dx, dy), 0.001);
6
+ if (distance >= minDistance) {
7
+ return false;
8
+ }
9
+ const push = (minDistance - distance) / 2;
10
+ const fallbackAngle = Math.PI * 2 * (Math.abs(hashText(`${left.id}:${right.id}`) % 1000) / 1000);
11
+ const ux = Math.abs(dx) + Math.abs(dy) < 0.001 ? Math.cos(fallbackAngle) : dx / distance;
12
+ const uy = Math.abs(dx) + Math.abs(dy) < 0.001 ? Math.sin(fallbackAngle) : dy / distance;
13
+ left.x -= ux * push;
14
+ left.y -= uy * push;
15
+ right.x += ux * push;
16
+ right.y += uy * push;
17
+ return true;
18
+ };
19
+ const buildCollisionGrid = (nodes, cellSize) => {
20
+ const grid = new Map();
21
+ nodes.forEach((node, index) => {
22
+ const x = Math.floor(node.x / cellSize);
23
+ const y = Math.floor(node.y / cellSize);
24
+ const key = `${x},${y}`;
25
+ const bucket = grid.get(key);
26
+ if (bucket) {
27
+ bucket.push(index);
28
+ return;
29
+ }
30
+ grid.set(key, [index]);
31
+ });
32
+ return grid;
33
+ };
34
+ const neighborCellKeys = (x, y) => [
35
+ `${x - 1},${y - 1}`,
36
+ `${x},${y - 1}`,
37
+ `${x + 1},${y - 1}`,
38
+ `${x - 1},${y}`,
39
+ `${x},${y}`,
40
+ `${x + 1},${y}`,
41
+ `${x - 1},${y + 1}`,
42
+ `${x},${y + 1}`,
43
+ `${x + 1},${y + 1}`
44
+ ];
45
+ const resolveCollisionsSpatial = (nodes, minDistance) => {
46
+ const gridCellSize = minDistance * 1.05;
47
+ const grid = buildCollisionGrid(nodes, gridCellSize);
48
+ let moved = false;
49
+ for (let index = 0; index < nodes.length; index += 1) {
50
+ const left = nodes[index];
51
+ const leftCellX = Math.floor(left.x / gridCellSize);
52
+ const leftCellY = Math.floor(left.y / gridCellSize);
53
+ neighborCellKeys(leftCellX, leftCellY).forEach((key) => {
54
+ const candidateIndices = grid.get(key) ?? [];
55
+ candidateIndices.forEach((candidateIndex) => {
56
+ if (candidateIndex <= index) {
57
+ return;
58
+ }
59
+ moved = resolveCollisionPair(left, nodes[candidateIndex], minDistance) || moved;
60
+ });
61
+ });
62
+ }
63
+ return moved;
64
+ };
65
+ const resolveCollisionsBrute = (nodes, minDistance) => {
66
+ let moved = false;
67
+ for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
68
+ const left = nodes[leftIndex];
69
+ for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) {
70
+ moved = resolveCollisionPair(left, nodes[rightIndex], minDistance) || moved;
71
+ }
72
+ }
73
+ return moved;
74
+ };
75
+ export const relaxCollisions = (nodes, minDistance = 132, rounds = 32) => {
76
+ if (nodes.length <= 1) {
77
+ return nodes;
78
+ }
79
+ const effectiveRounds = nodes.length > 1000
80
+ ? Math.min(rounds, 12)
81
+ : nodes.length > 500
82
+ ? Math.min(rounds, 20)
83
+ : Math.min(rounds, 26);
84
+ const layoutNodes = nodes.map((node) => ({
85
+ ...node,
86
+ x: Number.isFinite(node.x) ? node.x : 0,
87
+ y: Number.isFinite(node.y) ? node.y : 0
88
+ }));
89
+ for (let round = 0; round < effectiveRounds; round += 1) {
90
+ const moved = nodes.length <= 250
91
+ ? resolveCollisionsBrute(layoutNodes, minDistance)
92
+ : resolveCollisionsSpatial(layoutNodes, minDistance);
93
+ // Once a full round resolves no overlap the layout is collision-free and
94
+ // further rounds are no-ops, so the result is identical with fewer rounds.
95
+ if (!moved) {
96
+ break;
97
+ }
98
+ }
99
+ return layoutNodes;
100
+ };