@andespindola/brainlink 0.1.0-beta.3 → 0.1.0-beta.30

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 (56) hide show
  1. package/AGENTS.md +5 -5
  2. package/CHANGELOG.md +37 -3
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +172 -20
  6. package/SECURITY.md +1 -1
  7. package/dist/application/add-note.js +62 -13
  8. package/dist/application/analyze-vault.js +95 -8
  9. package/dist/application/frontend/client-css.js +214 -100
  10. package/dist/application/frontend/client-html.js +60 -45
  11. package/dist/application/frontend/client-js.js +525 -88
  12. package/dist/application/get-graph-layout.js +22 -7
  13. package/dist/application/get-graph-node.js +12 -0
  14. package/dist/application/get-graph-summary.js +12 -0
  15. package/dist/application/get-graph.js +3 -3
  16. package/dist/application/import-legacy-sqlite.js +296 -0
  17. package/dist/application/index-vault.js +11 -4
  18. package/dist/application/list-agents.js +3 -3
  19. package/dist/application/list-links.js +5 -5
  20. package/dist/application/migrate-vault.js +91 -0
  21. package/dist/application/search-graph-node-ids.js +12 -0
  22. package/dist/application/search-knowledge.js +75 -5
  23. package/dist/application/server/routes.js +27 -1
  24. package/dist/benchmarks/large-vault.js +1 -1
  25. package/dist/cli/commands/agent-commands.js +412 -0
  26. package/dist/cli/commands/config-commands.js +167 -0
  27. package/dist/cli/commands/read-commands.js +25 -8
  28. package/dist/cli/commands/write-commands.js +205 -4
  29. package/dist/cli/main.js +4 -0
  30. package/dist/cli/runtime.js +5 -2
  31. package/dist/domain/context.js +53 -11
  32. package/dist/domain/embeddings.js +2 -1
  33. package/dist/domain/graph-layout.js +20 -14
  34. package/dist/domain/markdown.js +36 -4
  35. package/dist/domain/middle-out.js +18 -0
  36. package/dist/infrastructure/config.js +94 -8
  37. package/dist/infrastructure/file-index.js +294 -0
  38. package/dist/infrastructure/file-system-vault.js +15 -0
  39. package/dist/infrastructure/paths.js +9 -1
  40. package/dist/infrastructure/private-pack-codec.js +73 -0
  41. package/dist/infrastructure/search-packs.js +348 -0
  42. package/dist/infrastructure/session-state.js +172 -0
  43. package/dist/mcp/main.js +11 -3
  44. package/dist/mcp/server.js +17 -2
  45. package/dist/mcp/startup.js +35 -0
  46. package/dist/mcp/tools.js +571 -19
  47. package/docs/AGENT_USAGE.md +112 -16
  48. package/docs/ARCHITECTURE.md +37 -26
  49. package/docs/QUICKSTART.md +111 -0
  50. package/package.json +2 -3
  51. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  52. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  53. package/dist/infrastructure/sqlite/schema.js +0 -111
  54. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  55. package/dist/infrastructure/sqlite/types.js +0 -1
  56. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -1,4 +1,4 @@
1
- import { getBrokenLinksReport, getOrphansReport, getStats, validateVault } from '../../application/analyze-vault.js';
1
+ import { getBrokenLinksReport, getExtendedStats, getOrphansReport, getStats, validateVault } from '../../application/analyze-vault.js';
2
2
  import { buildContextPackage } from '../../application/build-context.js';
3
3
  import { getGraph } from '../../application/get-graph.js';
4
4
  import { listAgents } from '../../application/list-agents.js';
@@ -12,14 +12,14 @@ export const registerReadCommands = (program) => {
12
12
  .argument('<query>', 'search query')
13
13
  .option('-v, --vault <vault>', 'vault directory')
14
14
  .option('-a, --agent <agent>', 'filter by agent memory namespace')
15
- .option('-l, --limit <limit>', 'maximum results', '10')
15
+ .option('-l, --limit <limit>', 'maximum results')
16
16
  .option('-m, --mode <mode>', 'search mode: fts, semantic or hybrid')
17
17
  .option('--json', 'print machine-readable JSON')
18
18
  .description('search indexed knowledge')
19
19
  .action(async (query, options) => {
20
20
  const resolved = await resolveOptions(options);
21
- const limit = parsePositiveInteger(options.limit ?? String(resolved.config.defaultSearchLimit), resolved.config.defaultSearchLimit);
22
- const mode = sanitizeSearchMode(options.mode, resolved.config.defaultSearchMode);
21
+ const limit = parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit);
22
+ const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
23
23
  const results = await searchKnowledge(resolved.vault, query, limit, resolved.agent, mode);
24
24
  print(options.json, { query, agent: resolved.agent, limit, mode, results }, () => results
25
25
  .map((result, index) => [`${index + 1}. ${result.title} (${result.path}) score=${result.score.toFixed(3)} mode=${result.searchMode}`, result.content].join('\n'))
@@ -58,15 +58,15 @@ export const registerReadCommands = (program) => {
58
58
  .argument('<query>', 'context query')
59
59
  .option('-v, --vault <vault>', 'vault directory')
60
60
  .option('-a, --agent <agent>', 'filter by agent memory namespace')
61
- .option('-l, --limit <limit>', 'maximum search results before context selection', '12')
62
- .option('-t, --tokens <tokens>', 'maximum estimated context tokens', '2000')
61
+ .option('-l, --limit <limit>', 'maximum search results before context selection')
62
+ .option('-t, --tokens <tokens>', 'maximum estimated context tokens')
63
63
  .option('-m, --mode <mode>', 'search mode: fts, semantic or hybrid')
64
64
  .option('--json', 'print machine-readable JSON')
65
65
  .description('build a compact context package for an agent')
66
66
  .action(async (query, options) => {
67
67
  const resolved = await resolveOptions(options);
68
- const mode = sanitizeSearchMode(options.mode, resolved.config.defaultSearchMode);
69
- const contextPackage = await buildContextPackage(resolved.vault, query, parsePositiveInteger(options.limit ?? '12', 12), parsePositiveInteger(options.tokens ?? String(resolved.config.defaultContextTokens), resolved.config.defaultContextTokens), resolved.agent, mode);
68
+ const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
69
+ const contextPackage = await buildContextPackage(resolved.vault, query, parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit), parsePositiveInteger(options.tokens ?? String(resolved.defaults.defaultContextTokens), resolved.defaults.defaultContextTokens), resolved.agent, mode);
70
70
  print(options.json, contextPackage, () => contextPackage.content);
71
71
  });
72
72
  program
@@ -94,10 +94,27 @@ export const registerReadCommands = (program) => {
94
94
  .command('stats')
95
95
  .option('-v, --vault <vault>', 'vault directory')
96
96
  .option('-a, --agent <agent>', 'filter by agent memory namespace')
97
+ .option('--extended', 'include storage, quality and latency observability probes')
97
98
  .option('--json', 'print machine-readable JSON')
98
99
  .description('print indexed vault statistics')
99
100
  .action(async (options) => {
100
101
  const resolved = await resolveOptions(options);
102
+ if (options.extended) {
103
+ const stats = await getExtendedStats(resolved.vault, resolved.agent);
104
+ print(options.json, stats, () => [
105
+ `Documents: ${stats.stats.documentCount}`,
106
+ `Links: ${stats.stats.linkCount}`,
107
+ `Resolved links: ${stats.stats.resolvedLinkCount}`,
108
+ `Broken links: ${stats.stats.brokenLinkCount}`,
109
+ `Orphans: ${stats.stats.orphanCount}`,
110
+ `Tags: ${stats.stats.tagCount}`,
111
+ `Total files: ${stats.storage.totalFileCount}`,
112
+ `Markdown files: ${stats.storage.markdownFileCount}`,
113
+ `Vault bytes: ${stats.storage.totalBytes}`,
114
+ `Latency index/search/context (ms): ${stats.observability.latenciesMs.index}/${stats.observability.latenciesMs.search}/${stats.observability.latenciesMs.context}`
115
+ ].join('\n'));
116
+ return;
117
+ }
101
118
  const stats = await getStats(resolved.vault, resolved.agent);
102
119
  print(options.json, stats, () => [
103
120
  `Documents: ${stats.documentCount}`,
@@ -1,11 +1,19 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, relative, resolve } from 'node:path';
2
4
  import { addNote } from '../../application/add-note.js';
5
+ import { buildContextPackage } from '../../application/build-context.js';
6
+ import { importLegacySqliteDatabase } from '../../application/import-legacy-sqlite.js';
3
7
  import { indexVault } from '../../application/index-vault.js';
8
+ import { migrateVaultContent, planVaultMigration, previewVaultMigration, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
4
9
  import { startServer } from '../../application/start-server.js';
5
10
  import { startVaultWatcher } from '../../application/watch-vault.js';
6
- import { doctorVault } from '../../application/analyze-vault.js';
11
+ import { doctorVault, getStats, validateVault } from '../../application/analyze-vault.js';
12
+ import { defaultBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/config.js';
7
13
  import { loadBrainlinkConfig } from '../../infrastructure/config.js';
8
14
  import { assertVaultAllowed, ensureVault } from '../../infrastructure/file-system-vault.js';
15
+ import { getBootstrapPolicy, getBootstrapSessionStatus, touchBootstrapSession } from '../../infrastructure/session-state.js';
16
+ import { installAgentIntegration } from './agent-commands.js';
9
17
  import { parsePositiveInteger, print, resolveOptions } from '../runtime.js';
10
18
  const resolveAddContent = (options) => {
11
19
  if (options.content != null && options.content.trim().length > 0) {
@@ -20,12 +28,108 @@ export const registerWriteCommands = (program) => {
20
28
  program
21
29
  .command('init')
22
30
  .argument('[vault]', 'vault directory')
31
+ .option('--migrate-from <vault>', 'copy existing vault content into the initialized vault')
32
+ .option('--no-migrate-existing', 'skip automatic migration from the default Brainlink vault into an empty custom vault')
23
33
  .option('--json', 'print machine-readable JSON')
24
34
  .description('initialize a Brainlink vault')
25
35
  .action(async (vault, options) => {
26
36
  const config = await loadBrainlinkConfig();
27
- const path = await ensureVault(assertVaultAllowed(vault ?? config.vault, config.allowedVaults));
28
- print(options.json, { path }, () => `Initialized Brainlink vault at ${path}`);
37
+ const targetVault = assertVaultAllowed(vault ?? config.vault, config.allowedVaults);
38
+ const path = await ensureVault(targetVault);
39
+ const explicitSource = options.migrateFrom ? assertVaultAllowed(options.migrateFrom, config.allowedVaults) : undefined;
40
+ const shouldAutoMigrate = explicitSource === undefined &&
41
+ options.migrateExisting !== false &&
42
+ (await shouldMigrateDefaultVault(defaultBrainlinkConfig.vault, targetVault));
43
+ const migration = explicitSource || shouldAutoMigrate ? await migrateVaultContent(explicitSource ?? defaultBrainlinkConfig.vault, targetVault) : undefined;
44
+ const index = migration && migration.copied + migration.conflicted > 0 ? await indexVault(targetVault) : undefined;
45
+ print(options.json, { path, ...(migration ? { migration } : {}), ...(index ? { index } : {}) }, () => {
46
+ const migrated = migration
47
+ ? ` Migrated ${migration.copied} files, preserved ${migration.conflicted} conflicts and kept ${migration.unchanged} unchanged files.`
48
+ : '';
49
+ return `Initialized Brainlink vault at ${path}.${migrated}`;
50
+ });
51
+ });
52
+ program
53
+ .command('migrate-vault')
54
+ .option('--from <vault>', 'source vault path')
55
+ .option('--to <vault>', 'target vault path')
56
+ .option('--dry-run', 'preview migration without writing files')
57
+ .option('--report <path>', 'write detailed per-file migration report to JSON file')
58
+ .option('--no-index', 'skip reindexing target vault after migration')
59
+ .option('--json', 'print machine-readable JSON')
60
+ .description('copy markdown memory from one vault to another with conflict preservation')
61
+ .action(async (options) => {
62
+ const config = await loadBrainlinkConfig();
63
+ const sourceVault = assertVaultAllowed(options.from ?? config.vault, config.allowedVaults);
64
+ const targetVault = assertVaultAllowed(options.to ?? defaultBrainlinkConfig.vault, config.allowedVaults);
65
+ const sourceRoot = await ensureVault(sourceVault);
66
+ const targetRoot = await ensureVault(targetVault);
67
+ const preview = await previewVaultMigration(sourceVault, targetVault);
68
+ const actions = await planVaultMigration(sourceRoot, targetRoot);
69
+ const reportEntries = actions.map((action) => ({
70
+ kind: action.kind,
71
+ sourcePath: action.sourcePath,
72
+ sourceRelativePath: relative(sourceRoot, action.sourcePath),
73
+ targetPath: action.targetPath,
74
+ targetRelativePath: relative(targetRoot, action.targetPath)
75
+ }));
76
+ const writeReport = async () => {
77
+ if (!options.report) {
78
+ return null;
79
+ }
80
+ const reportPath = resolve(options.report);
81
+ await mkdir(dirname(reportPath), { recursive: true });
82
+ await writeFile(reportPath, `${JSON.stringify({ source: sourceVault, target: targetVault, summary: preview, entries: reportEntries }, null, 2)}\n`, 'utf8');
83
+ return reportPath;
84
+ };
85
+ if (options.dryRun) {
86
+ const reportPath = await writeReport();
87
+ print(options.json, { dryRun: true, ...preview, entries: reportEntries, ...(reportPath ? { reportPath } : {}) }, () => `Dry run migration ${preview.source} -> ${preview.target}: copy=${preview.copied}, conflicts=${preview.conflicted}, unchanged=${preview.unchanged}${reportPath ? ` report=${reportPath}` : ''}`);
88
+ return;
89
+ }
90
+ const migration = await migrateVaultContent(sourceVault, targetVault);
91
+ const shouldIndex = options.index !== false && migration.copied + migration.conflicted > 0;
92
+ const index = shouldIndex ? await indexVault(targetVault) : undefined;
93
+ const reportPath = await writeReport();
94
+ print(options.json, { dryRun: false, ...migration, entries: reportEntries, ...(index ? { index } : {}), ...(reportPath ? { reportPath } : {}) }, () => {
95
+ const summary = `Migrated ${migration.copied} files, preserved ${migration.conflicted} conflicts and kept ${migration.unchanged} unchanged files.`;
96
+ const indexMessage = index
97
+ ? ` Indexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} links.`
98
+ : '';
99
+ const reportMessage = reportPath ? ` Report written to ${reportPath}.` : '';
100
+ return `${summary}${indexMessage}${reportMessage}`;
101
+ });
102
+ });
103
+ program
104
+ .command('db-import')
105
+ .option('-v, --vault <vault>', 'vault directory')
106
+ .option('--db <path>', 'legacy SQLite database path (default: <vault>/.brainlink/brainlink.db)')
107
+ .option('--table <name>', 'legacy table name override')
108
+ .option('-a, --agent <agent>', 'force imported notes into a target agent namespace')
109
+ .option('-l, --limit <limit>', 'maximum number of rows to import')
110
+ .option('--dry-run', 'preview import without writing Markdown files')
111
+ .option('--no-index', 'skip reindexing after import')
112
+ .option('--json', 'print machine-readable JSON')
113
+ .description('import legacy SQLite memory into Markdown vault and current index model')
114
+ .action(async (options) => {
115
+ const resolved = await resolveOptions(options);
116
+ const result = await importLegacySqliteDatabase(resolved.vault, {
117
+ dbPath: options.db,
118
+ table: options.table,
119
+ agentOverride: options.agent ? resolved.agent : undefined,
120
+ limit: options.limit ? parsePositiveInteger(options.limit, 100_000) : undefined,
121
+ dryRun: Boolean(options.dryRun)
122
+ });
123
+ const shouldIndex = options.index !== false && !result.dryRun && result.imported > 0;
124
+ const index = shouldIndex ? await indexVault(resolved.vault) : undefined;
125
+ print(options.json, { ...result, ...(index ? { index } : {}) }, () => {
126
+ const summary = `Imported ${result.imported}/${result.rowsRead} rows from ${result.table} (skipped ${result.skipped}).`;
127
+ const indexMessage = index
128
+ ? ` Indexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} links.`
129
+ : '';
130
+ const dryRunMessage = result.dryRun ? ' Dry run only; no files were written.' : '';
131
+ return `${summary}${indexMessage}${dryRunMessage}`;
132
+ });
29
133
  });
30
134
  program
31
135
  .command('add')
@@ -66,7 +170,13 @@ export const registerWriteCommands = (program) => {
66
170
  .action(async (options) => {
67
171
  const resolved = await resolveOptions(options);
68
172
  const report = await doctorVault(resolved.vault);
69
- print(options.json, report, () => report.checks.map((check) => `${check.ok ? 'OK' : 'FAIL'} ${check.name}: ${check.message}`).join('\n'));
173
+ print(options.json, report, () => {
174
+ const checks = report.checks.map((check) => `${check.ok ? 'OK' : 'FAIL'} ${check.name}: ${check.message}`).join('\n');
175
+ const recommendations = report.recommendations && report.recommendations.length > 0
176
+ ? `\n\nRecommended next steps:\n${report.recommendations.map((step) => `- ${step}`).join('\n')}`
177
+ : '';
178
+ return `${checks}${recommendations}`;
179
+ });
70
180
  process.exitCode = report.ok ? 0 : 1;
71
181
  });
72
182
  program
@@ -117,4 +227,95 @@ export const registerWriteCommands = (program) => {
117
227
  });
118
228
  print(options.json, { url: server.url, watch: Boolean(options.watch), readonly: true }, () => `Brainlink graph server running at ${server.url}`);
119
229
  });
230
+ program
231
+ .command('quickstart')
232
+ .option('-v, --vault <vault>', 'vault directory')
233
+ .option('-a, --agent <agent>', 'agent memory namespace')
234
+ .option('--query <query>', 'optional task query to return immediate grounded context')
235
+ .option('--mode <mode>', 'search mode for context (fts|semantic|hybrid)')
236
+ .option('--limit <limit>', 'maximum context sections')
237
+ .option('--tokens <tokens>', 'maximum context token budget')
238
+ .option('--no-install-agent', 'skip agent MCP/plugin installation and upgrade automation')
239
+ .option('--mcp-only', 'when installing agent integration, only configure MCP section')
240
+ .option('--plugin-path <path>', 'custom source path for Brainlink plugin files')
241
+ .option('--allowed-vaults <paths>', 'comma separated vault allowlist to inject in MCP env')
242
+ .option('--brainlink-home <path>', 'BRAINLINK_HOME value to inject in MCP env')
243
+ .option('--json', 'print machine-readable JSON')
244
+ .description('run plug-and-play setup for vault, MCP integration and bootstrap readiness')
245
+ .action(async (options) => {
246
+ const resolved = await resolveOptions(options);
247
+ const limit = parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit);
248
+ const tokens = parsePositiveInteger(options.tokens ?? String(resolved.defaults.defaultContextTokens), resolved.defaults.defaultContextTokens);
249
+ const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
250
+ const index = await indexVault(resolved.vault);
251
+ const stats = await getStats(resolved.vault, resolved.agent);
252
+ const validation = await validateVault(resolved.vault, resolved.agent);
253
+ const doctor = await doctorVault(resolved.vault);
254
+ const session = await touchBootstrapSession(resolved.vault, resolved.agent);
255
+ const policy = await getBootstrapPolicy();
256
+ const bootstrapStatus = await getBootstrapSessionStatus(resolved.vault, resolved.agent);
257
+ const context = options.query
258
+ ? await buildContextPackage(resolved.vault, options.query, limit, tokens, resolved.agent, mode)
259
+ : null;
260
+ const agentIntegration = options.installAgent === false
261
+ ? null
262
+ : await installAgentIntegration({
263
+ mcpOnly: options.mcpOnly,
264
+ pluginPath: options.pluginPath,
265
+ allowedVaults: options.allowedVaults,
266
+ brainlinkHome: options.brainlinkHome,
267
+ selfTest: true
268
+ });
269
+ const nextActions = stats.documentCount === 0
270
+ ? [
271
+ {
272
+ priority: 'required',
273
+ command: `blink add "Architecture" --vault "${resolved.vault}" --content "Durable memory with [[Links]] and #tags."`,
274
+ reason: 'Seed your vault with at least one durable Markdown note.'
275
+ },
276
+ {
277
+ priority: 'required',
278
+ command: `blink index --vault "${resolved.vault}"`,
279
+ reason: 'Rebuild index after adding notes so retrieval can find new memory.'
280
+ }
281
+ ]
282
+ : options.query
283
+ ? [
284
+ {
285
+ priority: 'recommended',
286
+ command: `blink add "Task Update" --vault "${resolved.vault}" --agent "${resolved.agent ?? 'shared'}" --content "<durable memory>"`,
287
+ reason: 'Persist important findings as Markdown notes after using the returned context.'
288
+ }
289
+ ]
290
+ : [
291
+ {
292
+ priority: 'recommended',
293
+ command: `blink context "<task>" --vault "${resolved.vault}" --agent "${resolved.agent ?? 'shared'}" --mode ${mode}`,
294
+ reason: 'Retrieve grounded context for each task before responding.'
295
+ }
296
+ ];
297
+ print(options.json, {
298
+ vault: resolved.vault,
299
+ agent: resolved.agent ?? 'shared',
300
+ mode,
301
+ index,
302
+ stats,
303
+ validation,
304
+ doctor,
305
+ policy,
306
+ bootstrapStatus,
307
+ session,
308
+ context,
309
+ agentIntegration,
310
+ nextActions
311
+ }, () => [
312
+ `quickstart vault=${resolved.vault}`,
313
+ `agent=${resolved.agent ?? 'shared'}`,
314
+ `documents=${stats.documentCount}`,
315
+ `links=${stats.linkCount}`,
316
+ `bootstrapReady=${bootstrapStatus.ready}`,
317
+ ...(agentIntegration?.selfTest ? [`agentIntegrationSelfTest=${agentIntegration.selfTest.ok}`] : []),
318
+ ...(nextActions.length > 0 ? ['Next actions:', ...nextActions.map((step) => `- ${step.command}`)] : [])
319
+ ].join('\n'));
320
+ });
120
321
  };
package/dist/cli/main.js CHANGED
@@ -3,6 +3,8 @@ 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 { registerAgentCommands } from './commands/agent-commands.js';
7
+ import { registerConfigCommands } from './commands/config-commands.js';
6
8
  import { registerReadCommands } from './commands/read-commands.js';
7
9
  import { registerWriteCommands } from './commands/write-commands.js';
8
10
  const readPackageVersion = () => {
@@ -21,6 +23,8 @@ program
21
23
  .version(readPackageVersion());
22
24
  registerWriteCommands(program);
23
25
  registerReadCommands(program);
26
+ registerConfigCommands(program);
27
+ registerAgentCommands(program);
24
28
  program.parseAsync().catch((error) => {
25
29
  const message = error instanceof Error ? error.message : String(error);
26
30
  console.error(message);
@@ -1,4 +1,4 @@
1
- import { loadBrainlinkConfig } from '../infrastructure/config.js';
1
+ import { loadBrainlinkConfig, resolveAgentRuntimeDefaults } from '../infrastructure/config.js';
2
2
  import { assertVaultAllowed } from '../infrastructure/file-system-vault.js';
3
3
  export const parsePositiveInteger = (value, fallback) => {
4
4
  const parsed = Number.parseInt(value, 10);
@@ -8,10 +8,13 @@ export const resolveOptions = async (options) => {
8
8
  const config = await loadBrainlinkConfig();
9
9
  const vault = options.vault ?? config.vault;
10
10
  const allowedVault = assertVaultAllowed(vault, config.allowedVaults);
11
+ const agent = options.agent ?? config.defaultAgent;
12
+ const defaults = resolveAgentRuntimeDefaults(config, agent);
11
13
  return {
12
14
  config,
13
15
  vault: allowedVault,
14
- agent: options.agent ?? config.defaultAgent
16
+ agent,
17
+ defaults
15
18
  };
16
19
  };
17
20
  export const print = (json, value, human) => {
@@ -1,13 +1,50 @@
1
+ import { middleOutIndices } from './middle-out.js';
2
+ const maxSectionsPerDocument = 3;
3
+ const byScore = (left, right) => right.score - left.score || left.title.localeCompare(right.title);
4
+ const byOrdinal = (left, right) => (left.chunkOrdinal ?? Number.MAX_SAFE_INTEGER) - (right.chunkOrdinal ?? Number.MAX_SAFE_INTEGER);
5
+ const middleOutDocumentResults = (results) => {
6
+ if (results.length <= 1) {
7
+ return results;
8
+ }
9
+ const sortedByOrdinal = [...results].sort(byOrdinal);
10
+ const pivotChunkId = [...results].sort(byScore)[0]?.chunkId;
11
+ const pivotIndex = sortedByOrdinal.findIndex((result) => result.chunkId === pivotChunkId);
12
+ if (pivotIndex < 0) {
13
+ return [...results].sort(byScore);
14
+ }
15
+ return middleOutIndices(sortedByOrdinal.length, pivotIndex).map((index) => sortedByOrdinal[index]);
16
+ };
1
17
  export const selectContextSections = (results, maxTokens) => {
2
- const selected = results.reduce((state, result) => {
3
- const tokenCost = Math.ceil(result.content.length / 4);
4
- if (state.usedTokens + tokenCost > maxTokens || state.seenDocuments.has(result.documentId)) {
5
- return state;
18
+ const grouped = results.reduce((state, result) => {
19
+ const current = state.get(result.documentId) ?? [];
20
+ state.set(result.documentId, [...current, result]);
21
+ return state;
22
+ }, new Map());
23
+ const documentOrder = Array.from(results.reduce((state, result) => {
24
+ if (!state.has(result.documentId)) {
25
+ state.set(result.documentId, result.score);
6
26
  }
7
- return {
8
- usedTokens: state.usedTokens + tokenCost,
9
- sections: [
10
- ...state.sections,
27
+ return state;
28
+ }, new Map()).entries())
29
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
30
+ .map(([documentId]) => documentId);
31
+ const selected = documentOrder.reduce((state, documentId) => {
32
+ const ordered = middleOutDocumentResults(grouped.get(documentId) ?? []);
33
+ let usedTokens = state.usedTokens;
34
+ let sections = state.sections;
35
+ let seenChunks = state.seenChunks;
36
+ for (let index = 0; index < ordered.length && index < maxSectionsPerDocument; index += 1) {
37
+ const result = ordered[index];
38
+ if (seenChunks.has(result.chunkId)) {
39
+ continue;
40
+ }
41
+ const tokenCost = Math.ceil(result.content.length / 4);
42
+ if (usedTokens + tokenCost > maxTokens) {
43
+ break;
44
+ }
45
+ usedTokens += tokenCost;
46
+ sections = [
47
+ ...sections,
11
48
  {
12
49
  title: result.title,
13
50
  path: result.path,
@@ -16,13 +53,18 @@ export const selectContextSections = (results, maxTokens) => {
16
53
  searchMode: result.searchMode,
17
54
  tags: result.tags
18
55
  }
19
- ],
20
- seenDocuments: new Set([...state.seenDocuments, result.documentId])
56
+ ];
57
+ seenChunks = new Set([...seenChunks, result.chunkId]);
58
+ }
59
+ return {
60
+ usedTokens,
61
+ sections,
62
+ seenChunks
21
63
  };
22
64
  }, {
23
65
  usedTokens: 0,
24
66
  sections: [],
25
- seenDocuments: new Set()
67
+ seenChunks: new Set()
26
68
  });
27
69
  return selected.sections;
28
70
  };
@@ -58,7 +58,8 @@ const tokenize = (input) => input
58
58
  .match(tokenPattern)
59
59
  ?.map(normalizeToken)
60
60
  .filter((token) => token.length > 1 && !stopWords.has(token)) ?? [];
61
- const expandTokens = (tokens) => tokens.flatMap((token) => [token, ...(aliases[token] ?? [])]);
61
+ const getAliasesForToken = (token) => Object.hasOwn(aliases, token) ? aliases[token] ?? [] : [];
62
+ const expandTokens = (tokens) => tokens.flatMap((token) => [token, ...getAliasesForToken(token)]);
62
63
  const hash = (value) => Array.from(value).reduce((state, char) => Math.imul(state ^ char.charCodeAt(0), 16777619), 2166136261) >>> 0;
63
64
  const featureHash = (feature) => {
64
65
  const value = hash(feature);
@@ -45,20 +45,17 @@ const countDegrees = (edges) => edges.reduce((degrees, edge) => {
45
45
  ? incrementDegreeBy(incrementDegreeBy(degrees, edge.source, weight), edge.target, weight)
46
46
  : incrementDegreeBy(degrees, edge.source, weight);
47
47
  }, new Map());
48
- const uniqueIds = (ids) => Array.from(new Set(ids));
49
48
  const createAdjacency = (nodes, edges) => {
50
49
  const nodeIds = new Set(nodes.map((node) => node.id));
51
- const emptyAdjacency = new Map(nodes.map((node) => [node.id, []]));
52
- return edges.reduce((adjacency, edge) => {
50
+ const adjacency = new Map(nodes.map((node) => [node.id, new Set()]));
51
+ edges.forEach((edge) => {
53
52
  if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
54
- return adjacency;
53
+ return;
55
54
  }
56
- return new Map([
57
- ...adjacency,
58
- [edge.source, uniqueIds([...(adjacency.get(edge.source) ?? []), edge.target])],
59
- [edge.target, uniqueIds([...(adjacency.get(edge.target) ?? []), edge.source])]
60
- ]);
61
- }, emptyAdjacency);
55
+ adjacency.get(edge.source)?.add(edge.target);
56
+ adjacency.get(edge.target)?.add(edge.source);
57
+ });
58
+ return new Map(Array.from(adjacency.entries(), ([id, neighbors]) => [id, Array.from(neighbors)]));
62
59
  };
63
60
  const byTitle = (left, right) => left.title.localeCompare(right.title);
64
61
  const byDegreeThenTitle = (degrees) => (left, right) => {
@@ -117,10 +114,19 @@ const assignSegments = (nodes, edges, degrees) => {
117
114
  }
118
115
  return new Map(nodes.map((node) => [node.id, assignments.get(node.id) ?? groupLabel(groupKey(node))]));
119
116
  };
120
- const groupNodesBySegment = (nodes, segments) => nodes.reduce((groups, node) => {
121
- const segment = segments.get(node.id) ?? groupLabel(groupKey(node));
122
- return new Map([...groups, [segment, [...(groups.get(segment) ?? []), node]]]);
123
- }, new Map());
117
+ const groupNodesBySegment = (nodes, segments) => {
118
+ const groups = new Map();
119
+ nodes.forEach((node) => {
120
+ const segment = segments.get(node.id) ?? groupLabel(groupKey(node));
121
+ const bucket = groups.get(segment);
122
+ if (bucket) {
123
+ bucket.push(node);
124
+ return;
125
+ }
126
+ groups.set(segment, [node]);
127
+ });
128
+ return new Map(groups);
129
+ };
124
130
  const segmentAngle = (segment, index, count) => segmentAngles[segment] ?? (Math.PI * 2 * index) / Math.max(count, 1) - Math.PI / 2;
125
131
  const createSegmentNodes = (segments, degrees, segmentCount) => ([segment, nodes], segmentIndex) => {
126
132
  const sortedNodes = [...nodes].sort(byDegreeThenTitle(degrees));
@@ -77,11 +77,13 @@ export const extractWikiLinkReferences = (content) => visibleMarkdownLines(conte
77
77
  }));
78
78
  });
79
79
  const priorityFromWeight = (weight) => weight >= 8 ? 'critical' : weight >= 4 ? 'high' : 'normal';
80
+ const normalizeAccumulatedWeight = (weight) => Math.max(1, Math.min(12, weight));
80
81
  export const extractWikiLinkWeights = (content) => {
81
82
  const weights = extractWikiLinkReferences(content).reduce((state, reference) => {
82
83
  const titleKey = reference.title.toLowerCase();
83
84
  const current = state.get(titleKey);
84
- const weight = (current?.weight ?? 0) + reference.weight;
85
+ const rawWeight = (current?.weight ?? 0) + reference.weight;
86
+ const weight = normalizeAccumulatedWeight(rawWeight);
85
87
  const explicitPriority = reference.priority
86
88
  ? maxPriority(current?.priority ?? reference.priority, reference.priority)
87
89
  : current?.priority;
@@ -116,10 +118,38 @@ const normalizeChunkContent = (content) => content
116
118
  .join('\n')
117
119
  .replace(/\n{3,}/g, '\n\n')
118
120
  .trim();
121
+ const splitLongParagraph = (paragraph, maxCharacters) => {
122
+ if (paragraph.length <= maxCharacters) {
123
+ return [paragraph];
124
+ }
125
+ const sentences = paragraph
126
+ .split(/(?<=[.!?])\s+/)
127
+ .map((sentence) => sentence.trim())
128
+ .filter(Boolean);
129
+ if (sentences.length <= 1) {
130
+ const chunks = [];
131
+ for (let index = 0; index < paragraph.length; index += maxCharacters) {
132
+ chunks.push(paragraph.slice(index, index + maxCharacters).trim());
133
+ }
134
+ return chunks.filter(Boolean);
135
+ }
136
+ return sentences.reduce((state, sentence) => {
137
+ const last = state.at(-1);
138
+ if (!last) {
139
+ return [sentence];
140
+ }
141
+ const merged = `${last} ${sentence}`;
142
+ if (merged.length <= maxCharacters) {
143
+ return [...state.slice(0, -1), merged];
144
+ }
145
+ return [...state, sentence];
146
+ }, []);
147
+ };
119
148
  export const splitIntoChunks = (documentId, content, maxCharacters = 1200) => {
120
149
  const paragraphs = normalizeChunkContent(stripFrontmatter(content))
121
150
  .split(/\n{2,}/)
122
- .filter(Boolean);
151
+ .filter(Boolean)
152
+ .flatMap((paragraph) => splitLongParagraph(paragraph, maxCharacters));
123
153
  const chunks = paragraphs.reduce((state, paragraph) => {
124
154
  const lastChunk = state.at(-1);
125
155
  if (!lastChunk) {
@@ -162,13 +192,15 @@ export const parseMarkdownDocument = (input) => {
162
192
  export const createIndexedDocument = (document, titleToDocumentId, maxChunkCharacters = 1200) => {
163
193
  const chunks = splitIntoChunks(document.id, document.content, maxChunkCharacters);
164
194
  const linkWeights = new Map(extractWikiLinkWeights(document.content).map((link) => [link.title.toLowerCase(), link]));
165
- const links = document.links.map((toTitle) => ({
195
+ const links = document.links
196
+ .map((toTitle) => ({
166
197
  fromDocumentId: document.id,
167
198
  toTitle,
168
199
  toDocumentId: titleToDocumentId.get(toTitle.toLowerCase()) ?? null,
169
200
  weight: linkWeights.get(toTitle.toLowerCase())?.weight ?? 1,
170
201
  priority: linkWeights.get(toTitle.toLowerCase())?.priority ?? 'normal'
171
- }));
202
+ }))
203
+ .filter((link) => link.toDocumentId !== document.id);
172
204
  return {
173
205
  document,
174
206
  chunks,
@@ -0,0 +1,18 @@
1
+ export const middleOutIndices = (size, pivotIndex) => {
2
+ if (!Number.isFinite(size) || size <= 0) {
3
+ return [];
4
+ }
5
+ const clampedPivot = Math.max(0, Math.min(Math.floor(pivotIndex), size - 1));
6
+ const indices = [clampedPivot];
7
+ for (let offset = 1; indices.length < size; offset += 1) {
8
+ const left = clampedPivot - offset;
9
+ const right = clampedPivot + offset;
10
+ if (left >= 0) {
11
+ indices.push(left);
12
+ }
13
+ if (right < size) {
14
+ indices.push(right);
15
+ }
16
+ }
17
+ return indices;
18
+ };