@andespindola/brainlink 0.1.0-beta.99 → 1.0.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.
Files changed (48) hide show
  1. package/AGENTS.md +6 -6
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +198 -38
  4. package/dist/application/add-note.js +13 -44
  5. package/dist/application/analyze-vault.js +1 -1
  6. package/dist/application/auto-migrate-configured-vault.js +37 -0
  7. package/dist/application/build-context.js +119 -20
  8. package/dist/application/canonical-context-links.js +209 -0
  9. package/dist/application/delete-note.js +80 -0
  10. package/dist/application/frontend/client-css.js +212 -42
  11. package/dist/application/frontend/client-html.js +42 -28
  12. package/dist/application/frontend/client-js.js +1294 -3222
  13. package/dist/application/frontend/client-render-worker-js.js +676 -0
  14. package/dist/application/get-graph-contexts.js +33 -0
  15. package/dist/application/get-graph-layout.js +62 -8
  16. package/dist/application/get-graph-stream-chunk.js +326 -0
  17. package/dist/application/get-graph-view.js +246 -0
  18. package/dist/application/graph-view-state.js +66 -0
  19. package/dist/application/import-legacy-sqlite.js +3 -33
  20. package/dist/application/index-vault.js +35 -22
  21. package/dist/application/migrate-context-links.js +79 -0
  22. package/dist/application/search-graph-node-ids.js +63 -3
  23. package/dist/application/server/routes.js +197 -12
  24. package/dist/cli/commands/read-commands.js +39 -3
  25. package/dist/cli/commands/vault-commands.js +182 -0
  26. package/dist/cli/commands/write-commands.js +172 -12
  27. package/dist/cli/main.js +2 -0
  28. package/dist/cli/runtime.js +10 -2
  29. package/dist/domain/context.js +1 -0
  30. package/dist/domain/graph-contexts.js +180 -0
  31. package/dist/domain/graph-layout.js +347 -21
  32. package/dist/domain/markdown.js +53 -9
  33. package/dist/infrastructure/config.js +105 -6
  34. package/dist/infrastructure/context-packs.js +122 -0
  35. package/dist/infrastructure/file-index.js +6 -3
  36. package/dist/infrastructure/file-system-vault.js +21 -1
  37. package/dist/infrastructure/index-state.js +2 -0
  38. package/dist/infrastructure/vault-migration-state.js +69 -0
  39. package/dist/infrastructure/volatile-memory.js +100 -0
  40. package/dist/mcp/http-server.js +97 -0
  41. package/dist/mcp/runtime.js +20 -0
  42. package/dist/mcp/server.js +41 -13
  43. package/dist/mcp/tools.js +226 -14
  44. package/docs/AGENT_USAGE.md +60 -5
  45. package/docs/ARCHITECTURE.md +11 -0
  46. package/docs/QUICKSTART.md +3 -1
  47. package/docs/RELEASE.md +4 -3
  48. package/package.json +3 -1
@@ -5,18 +5,23 @@ import { platform, tmpdir } from 'node:os';
5
5
  import { spawn, spawnSync } from 'node:child_process';
6
6
  import { addNoteWithMetadata } from '../../application/add-note.js';
7
7
  import { buildContextPackage } from '../../application/build-context.js';
8
+ import { deleteNote } from '../../application/delete-note.js';
8
9
  import { resolveDuplicateNotes, scanDuplicateNotes } from '../../application/dedupe-notes.js';
9
10
  import { importLegacySqliteDatabase } from '../../application/import-legacy-sqlite.js';
10
11
  import { indexVault, indexVaultWithOptions } from '../../application/index-vault.js';
12
+ import { migrateContextLinks } from '../../application/migrate-context-links.js';
13
+ import { canonicalizeContextLinks } from '../../application/canonical-context-links.js';
11
14
  import { migrateVaultContent, planVaultMigration, previewVaultMigration, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
12
15
  import { createOfflinePackBackup } from '../../application/offline-pack-backup.js';
13
16
  import { startServer } from '../../application/start-server.js';
14
17
  import { startVaultWatcher } from '../../application/watch-vault.js';
15
18
  import { doctorVault, getStats, validateVault } from '../../application/analyze-vault.js';
16
- import { defaultBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/config.js';
19
+ import { defaultBrainlinkConfig, sanitizeContextStrategy, sanitizeSearchMode } from '../../infrastructure/config.js';
17
20
  import { loadBrainlinkConfig } from '../../infrastructure/config.js';
18
- import { assertVaultAllowed, ensureVault } from '../../infrastructure/file-system-vault.js';
21
+ import { assertVaultAllowed, ensureVault, isBucketVaultPath } from '../../infrastructure/file-system-vault.js';
19
22
  import { getBootstrapPolicy, getBootstrapSessionStatus, touchBootstrapSession } from '../../infrastructure/session-state.js';
23
+ import { addVolatileMemory, clearVolatileMemory } from '../../infrastructure/volatile-memory.js';
24
+ import { startRemoteMcpServer } from '../../mcp/http-server.js';
20
25
  import { installAgentIntegration } from './agent-commands.js';
21
26
  import { parsePositiveInteger, print, resolveOptions } from '../runtime.js';
22
27
  const resolveAddContent = (options) => {
@@ -698,6 +703,68 @@ export const registerWriteCommands = (program) => {
698
703
  return `${summary}${indexMessage}${reportMessage}`;
699
704
  });
700
705
  });
706
+ program
707
+ .command('migrate-context-links')
708
+ .option('-v, --vault <vault>', 'vault directory')
709
+ .option('-a, --agent <agent>', 'agent memory namespace')
710
+ .option('-l, --limit <limit>', 'maximum context links to add per note', '5')
711
+ .option('--dry-run', 'preview context-link migration without writing files')
712
+ .option('--no-index', 'skip reindexing after migration')
713
+ .option('--json', 'print machine-readable JSON')
714
+ .description('add concise Context Links sections from existing wiki-link mentions')
715
+ .action(async (options) => {
716
+ const resolved = await resolveOptions(options);
717
+ const result = await migrateContextLinks(resolved.vault, {
718
+ dryRun: options.dryRun === true,
719
+ limit: parsePositiveInteger(options.limit ?? '5', 5),
720
+ agentId: resolved.agent
721
+ });
722
+ const shouldIndex = options.index !== false && !result.dryRun && result.changed > 0;
723
+ const index = shouldIndex ? await indexVault(resolved.vault, { full: true }) : undefined;
724
+ print(options.json, {
725
+ vault: resolved.vault,
726
+ agent: resolved.agent ?? 'shared',
727
+ ...result,
728
+ ...(index ? { index } : {})
729
+ }, () => {
730
+ const mode = result.dryRun ? 'Previewed' : 'Migrated';
731
+ const indexMessage = index
732
+ ? ` Fully reindexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} context links.`
733
+ : '';
734
+ return `${mode} ${result.scanned} notes: changed=${result.changed}, skipped=${result.skipped}, limit=${result.limit}.${indexMessage}`;
735
+ });
736
+ });
737
+ program
738
+ .command('canonicalize-context-links')
739
+ .option('-v, --vault <vault>', 'vault directory')
740
+ .option('-a, --agent <agent>', 'agent memory namespace')
741
+ .option('--dry-run', 'preview canonical context links without writing files')
742
+ .option('--no-create-hubs', 'do not create missing context hub notes')
743
+ .option('--no-index', 'skip reindexing after canonicalization')
744
+ .option('--json', 'print machine-readable JSON')
745
+ .description('ensure notes have canonical Context Links to their inferred context hubs')
746
+ .action(async (options) => {
747
+ const resolved = await resolveOptions(options);
748
+ const result = await canonicalizeContextLinks(resolved.vault, {
749
+ dryRun: options.dryRun === true,
750
+ agentId: resolved.agent,
751
+ createMissingHubs: options.createHubs !== false
752
+ });
753
+ const shouldIndex = options.index !== false && !result.dryRun && result.changed > 0;
754
+ const index = shouldIndex ? await indexVault(resolved.vault, { full: true }) : undefined;
755
+ print(options.json, {
756
+ vault: resolved.vault,
757
+ agent: resolved.agent ?? 'shared',
758
+ ...result,
759
+ ...(index ? { index } : {})
760
+ }, () => {
761
+ const mode = result.dryRun ? 'Previewed' : 'Canonicalized';
762
+ const indexMessage = index
763
+ ? ` Fully reindexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} context links.`
764
+ : '';
765
+ return `${mode} ${result.scanned} notes: changed=${result.changed}, createdHubs=${result.createdHubs}, skipped=${result.skipped}.${indexMessage}`;
766
+ });
767
+ });
701
768
  program
702
769
  .command('db-import')
703
770
  .option('-v, --vault <vault>', 'vault directory')
@@ -729,6 +796,29 @@ export const registerWriteCommands = (program) => {
729
796
  return `${summary}${indexMessage}${dryRunMessage}`;
730
797
  });
731
798
  });
799
+ program
800
+ .command('volatile')
801
+ .option('-c, --content <content>', 'temporary memory content to add')
802
+ .option('--ttl <minutes>', 'time-to-live in minutes', '240')
803
+ .option('--tag <tag...>', 'volatile memory tag')
804
+ .option('-v, --vault <vault>', 'vault directory')
805
+ .option('-a, --agent <agent>', 'agent memory namespace')
806
+ .option('--clear', 'clear volatile memory for the current agent namespace')
807
+ .option('--json', 'print machine-readable JSON')
808
+ .description('add or clear temporary agent-decided memory')
809
+ .action(async (options) => {
810
+ const resolved = await resolveOptions(options);
811
+ if (options.clear) {
812
+ const cleared = await clearVolatileMemory(resolved.vault, resolved.agent);
813
+ print(options.json, { cleared, agent: resolved.agent ?? 'shared' }, () => `Cleared ${cleared} volatile memories.`);
814
+ return;
815
+ }
816
+ if (!options.content || options.content.trim().length === 0) {
817
+ throw new Error('Use --content to add volatile memory, or --clear to remove it.');
818
+ }
819
+ const entry = await addVolatileMemory(resolved.vault, options.content, resolved.agent ?? 'shared', parsePositiveInteger(options.ttl ?? '240', 240), options.tag ?? []);
820
+ print(options.json, { entry }, () => `Stored volatile memory until ${entry.expiresAt}.`);
821
+ });
732
822
  program
733
823
  .command('add')
734
824
  .argument('<title>', 'note title')
@@ -737,6 +827,7 @@ export const registerWriteCommands = (program) => {
737
827
  .option('-v, --vault <vault>', 'vault directory')
738
828
  .option('-a, --agent <agent>', 'agent memory namespace')
739
829
  .option('--allow-sensitive', 'allow writing content that looks like a secret')
830
+ .option('--no-auto-context-links', 'skip canonical Context Links for this note')
740
831
  .option('--no-auto-index', 'skip reindexing after add')
741
832
  .option('--json', 'print machine-readable JSON')
742
833
  .description('add a markdown note to the vault')
@@ -744,7 +835,8 @@ export const registerWriteCommands = (program) => {
744
835
  const resolved = await resolveOptions(options);
745
836
  const content = resolveAddContent(options);
746
837
  const added = await addNoteWithMetadata(resolved.vault, title, content, resolved.agent, {
747
- allowSensitive: Boolean(options.allowSensitive)
838
+ allowSensitive: Boolean(options.allowSensitive),
839
+ autoContextLinks: options.autoContextLinks !== false && resolved.config.autoCanonicalContextLinks
748
840
  });
749
841
  const shouldAutoIndex = options.autoIndex !== false && resolved.config.autoIndexOnWrite;
750
842
  const index = shouldAutoIndex ? await indexVault(resolved.vault) : undefined;
@@ -768,7 +860,9 @@ export const registerWriteCommands = (program) => {
768
860
  writeConnectivity: {
769
861
  autoLinked: added.autoLinked,
770
862
  linkTarget: added.linkTarget,
771
- guaranteedEdge: true
863
+ context: added.context,
864
+ hubCreated: added.hubCreated,
865
+ guaranteedEdge: added.autoLinked
772
866
  },
773
867
  possibleDuplicates,
774
868
  ...(index ? { index } : {})
@@ -776,8 +870,33 @@ export const registerWriteCommands = (program) => {
776
870
  const duplicateMessage = possibleDuplicates.length > 0
777
871
  ? `\nPotential duplicates: ${possibleDuplicates.length}. Use "blink dedupe --json" or "blink dedupe-resolve".`
778
872
  : '';
779
- return `Created note at ${added.path}${duplicateMessage}`;
873
+ const linkMessage = added.autoLinked ? ` Linked to [[${added.linkTarget}]].` : '';
874
+ return `Created note at ${added.path}.${linkMessage}${duplicateMessage}`;
875
+ });
876
+ });
877
+ program
878
+ .command('delete-note')
879
+ .option('-v, --vault <vault>', 'vault directory')
880
+ .option('-a, --agent <agent>', 'agent memory namespace when deleting by title')
881
+ .option('--title <title>', 'note title to delete')
882
+ .option('--path <path>', 'vault-relative or absolute markdown note path to delete')
883
+ .option('--yes', 'confirm note deletion')
884
+ .option('--no-auto-index', 'skip reindexing after delete')
885
+ .option('--json', 'print machine-readable JSON')
886
+ .description('delete a Markdown note from the vault after explicit confirmation')
887
+ .action(async (options) => {
888
+ const resolved = await resolveOptions(options);
889
+ const result = await deleteNote(resolved.vault, {
890
+ title: options.title,
891
+ path: options.path,
892
+ agentId: resolved.agent,
893
+ confirm: Boolean(options.yes),
894
+ autoIndex: options.autoIndex !== false
780
895
  });
896
+ print(options.json, {
897
+ vault: resolved.vault,
898
+ ...result
899
+ }, () => `Deleted note ${result.relativePath}.`);
781
900
  });
782
901
  program
783
902
  .command('dedupe')
@@ -836,12 +955,17 @@ export const registerWriteCommands = (program) => {
836
955
  program
837
956
  .command('index')
838
957
  .option('-v, --vault <vault>', 'vault directory')
958
+ .option('--full', 'force a complete reindex from Markdown source without reusing unchanged index entries')
839
959
  .option('--json', 'print machine-readable JSON')
840
960
  .description('index markdown notes, links, tags and chunks')
841
961
  .action(async (options) => {
842
962
  const resolved = await resolveOptions(options);
843
- const result = await indexVault(resolved.vault);
844
- print(options.json, result, () => `Indexed ${result.documentCount} documents, ${result.chunkCount} chunks and ${result.linkCount} links`);
963
+ const result = await indexVault(resolved.vault, {
964
+ full: options.full === true
965
+ });
966
+ print(options.json, result, () => options.full === true
967
+ ? `Fully reindexed ${result.documentCount} documents, ${result.chunkCount} chunks and ${result.linkCount} links`
968
+ : `Indexed ${result.documentCount} documents, ${result.chunkCount} chunks and ${result.linkCount} links`);
845
969
  });
846
970
  program
847
971
  .command('bench')
@@ -976,26 +1100,28 @@ export const registerWriteCommands = (program) => {
976
1100
  .option('-p, --port <port>', 'server port', '4321')
977
1101
  .option('--no-index', 'skip indexing before starting the server')
978
1102
  .option('--no-open', 'do not open the graph UI automatically')
979
- .option('-w, --watch', 'watch markdown files and reindex on changes')
1103
+ .option('-w, --watch', 'watch markdown files and reindex on changes', true)
1104
+ .option('--no-watch', 'disable markdown file watching')
980
1105
  .option('--json', 'print machine-readable JSON')
981
1106
  .description('start a local web UI for the knowledge graph')
982
1107
  .action(async (options) => {
983
1108
  const resolved = await resolveOptions(options);
1109
+ const shouldWatch = options.watch !== false && !isBucketVaultPath(resolved.vault);
984
1110
  const server = await startServer({
985
1111
  vaultPath: resolved.vault,
986
1112
  host: options.host ?? resolved.config.host,
987
1113
  port: parsePositiveInteger(options.port ?? String(resolved.config.port), resolved.config.port),
988
1114
  shouldIndex: options.index,
989
- shouldWatch: Boolean(options.watch)
1115
+ shouldWatch
990
1116
  });
991
1117
  const openResult = options.open !== false ? openUrlInUi(server.url, process.pid) : { opened: false, mode: 'none' };
992
1118
  print(options.json, {
993
1119
  url: server.url,
994
- watch: Boolean(options.watch),
1120
+ watch: shouldWatch,
995
1121
  readonly: true,
996
1122
  openedUi: openResult.opened,
997
1123
  openMode: openResult.mode
998
- }, () => `Brainlink graph server running at ${server.url}${openResult.opened
1124
+ }, () => `Brainlink graph server running at ${server.url} (${shouldWatch ? 'watching for changes' : 'watch disabled'})${openResult.opened
999
1125
  ? openResult.mode === 'native-gui'
1000
1126
  ? ' (opened in native desktop GUI)'
1001
1127
  : openResult.mode === 'app-window'
@@ -1005,12 +1131,45 @@ export const registerWriteCommands = (program) => {
1005
1131
  ? ' (auto-open disabled)'
1006
1132
  : ''}`);
1007
1133
  });
1134
+ program
1135
+ .command('mcp-server')
1136
+ .option('-v, --vault <vault>', 'vault directory')
1137
+ .option('-a, --agent <agent>', 'agent memory namespace')
1138
+ .option('-h, --host <host>', 'remote MCP server host', '0.0.0.0')
1139
+ .option('-p, --port <port>', 'remote MCP server port', '3333')
1140
+ .option('--path <path>', 'remote MCP endpoint path', '/mcp')
1141
+ .option('--token <token>', 'bearer token required for MCP requests')
1142
+ .option('--no-index', 'skip indexing before starting the MCP server')
1143
+ .option('--json', 'print machine-readable JSON')
1144
+ .description('start a remote MCP server for centralized cluster access')
1145
+ .action(async (options) => {
1146
+ const resolved = await resolveOptions(options);
1147
+ const token = options.token ?? process.env.BRAINLINK_MCP_TOKEN;
1148
+ const server = await startRemoteMcpServer({
1149
+ vaultPath: resolved.vault,
1150
+ agent: resolved.agent,
1151
+ host: options.host ?? '0.0.0.0',
1152
+ port: parsePositiveInteger(options.port ?? '3333', 3333),
1153
+ path: options.path ?? '/mcp',
1154
+ token,
1155
+ shouldIndex: options.index
1156
+ });
1157
+ print(options.json, {
1158
+ url: server.url,
1159
+ healthUrl: server.healthUrl,
1160
+ readyUrl: server.readyUrl,
1161
+ vault: resolved.vault,
1162
+ agent: resolved.agent ?? '*',
1163
+ auth: token === undefined ? 'disabled' : 'bearer'
1164
+ }, () => `Brainlink remote MCP server running at ${server.url} (health: ${server.healthUrl}, readiness: ${server.readyUrl})`);
1165
+ });
1008
1166
  program
1009
1167
  .command('quickstart')
1010
1168
  .option('-v, --vault <vault>', 'vault directory')
1011
1169
  .option('-a, --agent <agent>', 'agent memory namespace')
1012
1170
  .option('--query <query>', 'optional task query to return immediate grounded context')
1013
1171
  .option('--mode <mode>', 'search mode for context (fts|semantic|hybrid)')
1172
+ .option('--strategy <strategy>', 'context strategy for context (rag|cag|auto)')
1014
1173
  .option('--limit <limit>', 'maximum context sections')
1015
1174
  .option('--tokens <tokens>', 'maximum context token budget')
1016
1175
  .option('--no-install-agent', 'skip agent MCP/plugin installation and upgrade automation')
@@ -1025,6 +1184,7 @@ export const registerWriteCommands = (program) => {
1025
1184
  const limit = parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit);
1026
1185
  const tokens = parsePositiveInteger(options.tokens ?? String(resolved.defaults.defaultContextTokens), resolved.defaults.defaultContextTokens);
1027
1186
  const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
1187
+ const strategy = sanitizeContextStrategy(options.strategy, resolved.defaults.defaultContextStrategy);
1028
1188
  const index = await indexVault(resolved.vault);
1029
1189
  const stats = await getStats(resolved.vault, resolved.agent);
1030
1190
  const validation = await validateVault(resolved.vault, resolved.agent);
@@ -1033,7 +1193,7 @@ export const registerWriteCommands = (program) => {
1033
1193
  const policy = await getBootstrapPolicy();
1034
1194
  const bootstrapStatus = await getBootstrapSessionStatus(resolved.vault, resolved.agent);
1035
1195
  const context = options.query
1036
- ? await buildContextPackage(resolved.vault, options.query, limit, tokens, resolved.agent, mode)
1196
+ ? await buildContextPackage(resolved.vault, options.query, limit, tokens, resolved.agent, mode, strategy, resolved.defaults.defaultContextCacheTtlMs)
1037
1197
  : null;
1038
1198
  const agentIntegration = options.installAgent === false
1039
1199
  ? null
package/dist/cli/main.js CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { registerAgentCommands } from './commands/agent-commands.js';
7
7
  import { registerConfigCommands } from './commands/config-commands.js';
8
8
  import { registerReadCommands } from './commands/read-commands.js';
9
+ import { registerVaultCommands } from './commands/vault-commands.js';
9
10
  import { registerWriteCommands } from './commands/write-commands.js';
10
11
  const readPackageVersion = () => {
11
12
  const packagePath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
@@ -24,6 +25,7 @@ program
24
25
  registerWriteCommands(program);
25
26
  registerReadCommands(program);
26
27
  registerConfigCommands(program);
28
+ registerVaultCommands(program);
27
29
  registerAgentCommands(program);
28
30
  program.parseAsync().catch((error) => {
29
31
  const message = error instanceof Error ? error.message : String(error);
@@ -1,11 +1,19 @@
1
- import { loadBrainlinkConfig, resolveAgentRuntimeDefaults } from '../infrastructure/config.js';
1
+ import { autoMigrateConfiguredVaultIfChanged } from '../application/auto-migrate-configured-vault.js';
2
+ import { loadBrainlinkConfigWithSource, resolveAgentRuntimeDefaults } from '../infrastructure/config.js';
2
3
  import { assertVaultAllowed } from '../infrastructure/file-system-vault.js';
3
4
  export const parsePositiveInteger = (value, fallback) => {
4
5
  const parsed = Number.parseInt(value, 10);
5
6
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
6
7
  };
7
8
  export const resolveOptions = async (options) => {
8
- const config = await loadBrainlinkConfig();
9
+ const { config, vaultSource } = await loadBrainlinkConfigWithSource();
10
+ if (options.vault === undefined) {
11
+ const sourceKey = vaultSource.sourcePath ? `${vaultSource.source}:${vaultSource.sourcePath}` : vaultSource.source;
12
+ await autoMigrateConfiguredVaultIfChanged({
13
+ configKey: sourceKey,
14
+ configuredVault: config.vault
15
+ });
16
+ }
9
17
  const vault = options.vault ?? config.vault;
10
18
  const allowedVault = assertVaultAllowed(vault, config.allowedVaults);
11
19
  const agent = options.agent ?? config.defaultAgent;
@@ -76,6 +76,7 @@ export const formatContextPackage = (query, sections) => {
76
76
  section.tags.length > 0 ? `Tags: ${section.tags.map((tag) => `#${tag}`).join(' ')}` : null,
77
77
  `Score: ${section.score.toFixed(3)}`,
78
78
  `Mode: ${section.searchMode}`,
79
+ section.volatile ? `Volatile: true${section.expiresAt ? `, expires ${section.expiresAt}` : ''}` : null,
79
80
  '',
80
81
  section.content
81
82
  ]
@@ -0,0 +1,180 @@
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 (includesAny(text, [/pingu_ai_codding_pair_programming/, /\bpingu\b/]))
29
+ return context('Pingu');
30
+ if (path.startsWith('github-repos/'))
31
+ return context('GitHub Repositories');
32
+ if (path.startsWith('github-org-repos/'))
33
+ return context('GitHub Organizations');
34
+ if (path.startsWith('machine-config/'))
35
+ 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');
77
+ }
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
+ if (path.startsWith('agents/'))
87
+ return context('Agent Memory');
88
+ return null;
89
+ };
90
+ export const inferVisualGraphContext = (node) => {
91
+ const explicit = inferExplicitVisualGraphContext(node);
92
+ if (explicit) {
93
+ return explicit;
94
+ }
95
+ const [root] = node.path.split('/').filter(Boolean);
96
+ return context(root ? root.replace(/[-_]+/g, ' ') : 'Root');
97
+ };
98
+ export const groupNodesByVisualContext = (nodes) => {
99
+ const groups = new Map();
100
+ nodes.forEach((node) => {
101
+ const visualContext = inferVisualGraphContext(node);
102
+ const bucket = groups.get(visualContext.title);
103
+ if (bucket) {
104
+ bucket.push(node);
105
+ return;
106
+ }
107
+ groups.set(visualContext.title, [node]);
108
+ });
109
+ return new Map(Array.from(groups.entries(), ([title, groupedNodes]) => [title, [...groupedNodes].sort(byTitle)]));
110
+ };
111
+ const countDegrees = (edges) => edges.reduce((degrees, edge) => {
112
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edge.weight);
113
+ if (edge.target) {
114
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edge.weight);
115
+ }
116
+ return degrees;
117
+ }, new Map());
118
+ const selectVisualHub = (contextTitle, nodes, degrees) => {
119
+ const normalizedContext = normalize(contextTitle).replace(/\s+/g, ' ');
120
+ const ranked = [...nodes].sort((left, right) => {
121
+ const leftTitle = normalize(left.title);
122
+ const rightTitle = normalize(right.title);
123
+ const leftHubScore = leftTitle === normalizedContext || leftTitle === `${normalizedContext} hub`
124
+ ? 4
125
+ : leftTitle.includes(normalizedContext) && /\bhub\b/.test(leftTitle)
126
+ ? 3
127
+ : /\b(memory hub|knowledge root|moc|map|hub)\b/.test(leftTitle)
128
+ ? 2
129
+ : 0;
130
+ const rightHubScore = rightTitle === normalizedContext || rightTitle === `${normalizedContext} hub`
131
+ ? 4
132
+ : rightTitle.includes(normalizedContext) && /\bhub\b/.test(rightTitle)
133
+ ? 3
134
+ : /\b(memory hub|knowledge root|moc|map|hub)\b/.test(rightTitle)
135
+ ? 2
136
+ : 0;
137
+ const hubDelta = rightHubScore - leftHubScore;
138
+ if (hubDelta !== 0)
139
+ return hubDelta;
140
+ const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
141
+ return degreeDelta === 0 ? left.title.localeCompare(right.title) : degreeDelta;
142
+ });
143
+ return ranked[0] ?? null;
144
+ };
145
+ export const addVisualContextEdges = (graph) => {
146
+ const existingPairs = new Set(graph.edges
147
+ .filter((edge) => Boolean(edge.target))
148
+ .map((edge) => edgeKey(edge.source, edge.target)));
149
+ const degrees = countDegrees(graph.edges);
150
+ const derivedEdges = [];
151
+ for (const [contextTitle, nodes] of groupNodesByVisualContext(graph.nodes).entries()) {
152
+ if (nodes.length <= 1) {
153
+ continue;
154
+ }
155
+ const hub = selectVisualHub(contextTitle, nodes, degrees);
156
+ if (!hub) {
157
+ continue;
158
+ }
159
+ nodes
160
+ .filter((node) => node.id !== hub.id)
161
+ .forEach((node) => {
162
+ const key = edgeKey(hub.id, node.id);
163
+ if (existingPairs.has(key)) {
164
+ return;
165
+ }
166
+ existingPairs.add(key);
167
+ derivedEdges.push({
168
+ source: hub.id,
169
+ target: node.id,
170
+ targetTitle: node.title,
171
+ weight: 0.5,
172
+ priority: 'low'
173
+ });
174
+ });
175
+ }
176
+ return {
177
+ nodes: graph.nodes,
178
+ edges: [...graph.edges, ...derivedEdges]
179
+ };
180
+ };