@andespindola/brainlink 0.1.0-beta.4 → 0.1.0-beta.41

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 (59) hide show
  1. package/AGENTS.md +5 -5
  2. package/CHANGELOG.md +45 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +216 -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/build-context.js +56 -1
  10. package/dist/application/dedupe-notes.js +226 -0
  11. package/dist/application/frontend/client-css.js +214 -100
  12. package/dist/application/frontend/client-html.js +60 -45
  13. package/dist/application/frontend/client-js.js +818 -94
  14. package/dist/application/get-graph-layout.js +22 -7
  15. package/dist/application/get-graph-node.js +12 -0
  16. package/dist/application/get-graph-summary.js +12 -0
  17. package/dist/application/get-graph.js +3 -3
  18. package/dist/application/import-legacy-sqlite.js +296 -0
  19. package/dist/application/index-vault.js +143 -20
  20. package/dist/application/list-agents.js +3 -3
  21. package/dist/application/list-links.js +5 -5
  22. package/dist/application/migrate-vault.js +91 -0
  23. package/dist/application/search-graph-node-ids.js +12 -0
  24. package/dist/application/search-knowledge.js +75 -5
  25. package/dist/application/server/routes.js +27 -1
  26. package/dist/benchmarks/large-vault.js +1 -1
  27. package/dist/cli/commands/agent-commands.js +412 -0
  28. package/dist/cli/commands/config-commands.js +167 -0
  29. package/dist/cli/commands/read-commands.js +25 -8
  30. package/dist/cli/commands/write-commands.js +669 -9
  31. package/dist/cli/main.js +4 -0
  32. package/dist/cli/runtime.js +5 -2
  33. package/dist/domain/context.js +53 -11
  34. package/dist/domain/embeddings.js +2 -1
  35. package/dist/domain/graph-layout.js +20 -14
  36. package/dist/domain/markdown.js +36 -4
  37. package/dist/domain/middle-out.js +18 -0
  38. package/dist/infrastructure/config.js +94 -8
  39. package/dist/infrastructure/file-index.js +358 -0
  40. package/dist/infrastructure/file-system-vault.js +30 -0
  41. package/dist/infrastructure/index-state.js +50 -0
  42. package/dist/infrastructure/paths.js +9 -1
  43. package/dist/infrastructure/private-pack-codec.js +73 -0
  44. package/dist/infrastructure/search-packs.js +348 -0
  45. package/dist/infrastructure/session-state.js +172 -0
  46. package/dist/mcp/main.js +11 -3
  47. package/dist/mcp/server.js +27 -2
  48. package/dist/mcp/startup.js +35 -0
  49. package/dist/mcp/tools.js +633 -19
  50. package/docs/AGENT_USAGE.md +144 -16
  51. package/docs/ARCHITECTURE.md +37 -26
  52. package/docs/QUICKSTART.md +111 -0
  53. package/package.json +6 -4
  54. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  55. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  56. package/dist/infrastructure/sqlite/schema.js +0 -111
  57. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  58. package/dist/infrastructure/sqlite/types.js +0 -1
  59. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -1,11 +1,22 @@
1
- import { readFileSync } from 'node:fs';
2
- import { addNote } from '../../application/add-note.js';
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, relative, resolve } from 'node:path';
4
+ import { platform, tmpdir } from 'node:os';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import { addNoteWithMetadata } from '../../application/add-note.js';
7
+ import { buildContextPackage } from '../../application/build-context.js';
8
+ import { resolveDuplicateNotes, scanDuplicateNotes } from '../../application/dedupe-notes.js';
9
+ import { importLegacySqliteDatabase } from '../../application/import-legacy-sqlite.js';
3
10
  import { indexVault } from '../../application/index-vault.js';
11
+ import { migrateVaultContent, planVaultMigration, previewVaultMigration, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
4
12
  import { startServer } from '../../application/start-server.js';
5
13
  import { startVaultWatcher } from '../../application/watch-vault.js';
6
- import { doctorVault } from '../../application/analyze-vault.js';
14
+ import { doctorVault, getStats, validateVault } from '../../application/analyze-vault.js';
15
+ import { defaultBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/config.js';
7
16
  import { loadBrainlinkConfig } from '../../infrastructure/config.js';
8
17
  import { assertVaultAllowed, ensureVault } from '../../infrastructure/file-system-vault.js';
18
+ import { getBootstrapPolicy, getBootstrapSessionStatus, touchBootstrapSession } from '../../infrastructure/session-state.js';
19
+ import { installAgentIntegration } from './agent-commands.js';
9
20
  import { parsePositiveInteger, print, resolveOptions } from '../runtime.js';
10
21
  const resolveAddContent = (options) => {
11
22
  if (options.content != null && options.content.trim().length > 0) {
@@ -16,16 +27,469 @@ const resolveAddContent = (options) => {
16
27
  }
17
28
  return readFileSync(options.contentFile, 'utf8');
18
29
  };
30
+ const parseScore = (value, fallback) => {
31
+ if (value == null) {
32
+ return fallback;
33
+ }
34
+ const parsed = Number.parseFloat(value);
35
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
36
+ throw new Error(`Invalid score value: ${value}. Expected a number between 0 and 1.`);
37
+ }
38
+ return parsed;
39
+ };
40
+ const spawnDetached = (command, args) => {
41
+ try {
42
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
43
+ child.unref();
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ };
50
+ const nativeGuiSwiftScriptPath = join(tmpdir(), 'brainlink-native-gui.swift');
51
+ const nativeGuiPowershellScriptPath = join(tmpdir(), 'brainlink-native-gui.ps1');
52
+ const nativeGuiLinuxScriptPath = join(tmpdir(), 'brainlink-native-gui-linux.py');
53
+ const nativeGuiSwiftScript = `import Foundation
54
+ import AppKit
55
+ import WebKit
56
+ import Darwin
57
+
58
+ final class BrainlinkAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
59
+ private let targetUrl: URL
60
+ private let parentPid: Int32
61
+ private var window: NSWindow?
62
+ private var webView: WKWebView?
63
+ private var monitorTimer: Timer?
64
+
65
+ init(targetUrl: URL, parentPid: Int32) {
66
+ self.targetUrl = targetUrl
67
+ self.parentPid = parentPid
68
+ }
69
+
70
+ func applicationDidFinishLaunching(_ notification: Notification) {
71
+ let window = NSWindow(
72
+ contentRect: NSRect(x: 0, y: 0, width: 1320, height: 860),
73
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
74
+ backing: .buffered,
75
+ defer: false
76
+ )
77
+ window.title = "Brainlink Graph"
78
+ window.center()
79
+ window.isReleasedWhenClosed = false
80
+ window.delegate = self
81
+
82
+ let webView = WKWebView(frame: window.contentView?.bounds ?? .zero)
83
+ webView.autoresizingMask = [.width, .height]
84
+ webView.allowsBackForwardNavigationGestures = true
85
+ webView.load(URLRequest(url: targetUrl))
86
+ window.contentView?.addSubview(webView)
87
+
88
+ self.window = window
89
+ self.webView = webView
90
+
91
+ if parentPid > 0 {
92
+ monitorTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
93
+ if kill(self.parentPid, 0) != 0 {
94
+ NSApp.terminate(nil)
95
+ }
96
+ }
97
+ }
98
+
99
+ window.makeKeyAndOrderFront(nil)
100
+ NSApp.activate(ignoringOtherApps: true)
101
+ }
102
+
103
+ func windowWillClose(_ notification: Notification) {
104
+ monitorTimer?.invalidate()
105
+ NSApp.terminate(nil)
106
+ }
107
+ }
108
+
109
+ let args = Array(CommandLine.arguments.dropFirst())
110
+ let rawTarget = args.indices.contains(0) ? args[0] : "http://127.0.0.1:4321"
111
+ let parentPid: Int32 = args.indices.contains(1) ? (Int32(args[1]) ?? 0) : 0
112
+
113
+ guard let targetUrl = URL(string: rawTarget) else {
114
+ fputs("Invalid URL for Brainlink GUI: \\(rawTarget)\\n", stderr)
115
+ exit(1)
116
+ }
117
+
118
+ let app = NSApplication.shared
119
+ app.setActivationPolicy(.regular)
120
+ let delegate = BrainlinkAppDelegate(targetUrl: targetUrl, parentPid: parentPid)
121
+ app.delegate = delegate
122
+ app.run()
123
+ `;
124
+ const nativeGuiPowershellScript = `param(
125
+ [string]$TargetUrl = "http://127.0.0.1:4321",
126
+ [int]$ParentPid = 0
127
+ )
128
+
129
+ Add-Type -AssemblyName System.Windows.Forms
130
+ Add-Type -AssemblyName System.Drawing
131
+ [System.Windows.Forms.Application]::EnableVisualStyles()
132
+
133
+ $form = New-Object System.Windows.Forms.Form
134
+ $form.Text = "Brainlink Graph"
135
+ $form.Width = 1320
136
+ $form.Height = 860
137
+ $form.StartPosition = "CenterScreen"
138
+
139
+ $browser = New-Object System.Windows.Forms.WebBrowser
140
+ $browser.Dock = [System.Windows.Forms.DockStyle]::Fill
141
+ $browser.ScriptErrorsSuppressed = $true
142
+ $browser.Navigate($TargetUrl)
143
+
144
+ $form.Controls.Add($browser)
145
+ $timer = New-Object System.Windows.Forms.Timer
146
+ $timer.Interval = 1000
147
+ $timer.Add_Tick({
148
+ if ($ParentPid -le 0) {
149
+ return
150
+ }
151
+ try {
152
+ Get-Process -Id $ParentPid -ErrorAction Stop | Out-Null
153
+ } catch {
154
+ $timer.Stop()
155
+ $form.Close()
156
+ }
157
+ })
158
+ $form.Add_FormClosed({
159
+ $timer.Stop()
160
+ })
161
+ $timer.Start()
162
+ [void]$form.ShowDialog()
163
+ `;
164
+ const nativeGuiLinuxPythonScript = `#!/usr/bin/env python3
165
+ import sys
166
+
167
+ def run() -> int:
168
+ try:
169
+ import gi
170
+ gi.require_version("Gtk", "3.0")
171
+ try:
172
+ gi.require_version("WebKit2", "4.1")
173
+ except ValueError:
174
+ gi.require_version("WebKit2", "4.0")
175
+ from gi.repository import Gtk, WebKit2, GLib
176
+ except Exception:
177
+ return 1
178
+
179
+ target_url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:4321"
180
+ parent_pid = int(sys.argv[2]) if len(sys.argv) > 2 else 0
181
+
182
+ window = Gtk.Window(title="Brainlink Graph")
183
+ window.set_default_size(1320, 860)
184
+ window.connect("destroy", Gtk.main_quit)
185
+
186
+ webview = WebKit2.WebView()
187
+ webview.load_uri(target_url)
188
+ window.add(webview)
189
+ window.show_all()
190
+
191
+ if parent_pid > 0:
192
+ def _watch_parent() -> bool:
193
+ try:
194
+ import os
195
+ os.kill(parent_pid, 0)
196
+ except Exception:
197
+ Gtk.main_quit()
198
+ return False
199
+ return True
200
+
201
+ GLib.timeout_add(1000, _watch_parent)
202
+
203
+ Gtk.main()
204
+ return 0
205
+
206
+ if __name__ == "__main__":
207
+ raise SystemExit(run())
208
+ `;
209
+ const commandExists = (command) => {
210
+ try {
211
+ const probe = platform() === 'win32'
212
+ ? spawnSync('where', [command], { stdio: 'ignore' })
213
+ : spawnSync('which', [command], { stdio: 'ignore' });
214
+ return probe.status === 0;
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ };
220
+ const envFlagEnabled = (name) => process.env[name] === '1' || process.env[name] === 'true';
221
+ const spawnAnyDetached = (candidates) => candidates.some(([command, args]) => spawnDetached(command, args));
222
+ const windowsStartCandidates = (program, args = []) => [
223
+ ['cmd', ['/c', 'start', '', program, ...args]]
224
+ ];
225
+ const resolveSwiftExecutable = () => {
226
+ const directSwift = '/usr/bin/swift';
227
+ if (existsSync(directSwift)) {
228
+ return directSwift;
229
+ }
230
+ try {
231
+ const probe = spawnSync('xcrun', ['--find', 'swift'], { encoding: 'utf8' });
232
+ const swiftPath = probe.status === 0 ? probe.stdout.trim() : '';
233
+ return swiftPath.length > 0 ? swiftPath : null;
234
+ }
235
+ catch {
236
+ return null;
237
+ }
238
+ };
239
+ const openGraphInMacNativeGui = (url, parentPid) => {
240
+ const swiftBinary = resolveSwiftExecutable();
241
+ if (!swiftBinary) {
242
+ return false;
243
+ }
244
+ try {
245
+ writeFileSync(nativeGuiSwiftScriptPath, nativeGuiSwiftScript, 'utf8');
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ return spawnDetached(swiftBinary, [nativeGuiSwiftScriptPath, url, String(parentPid)]);
251
+ };
252
+ const resolveWindowsPowershellExecutable = () => {
253
+ if (commandExists('powershell')) {
254
+ return 'powershell';
255
+ }
256
+ if (commandExists('pwsh')) {
257
+ return 'pwsh';
258
+ }
259
+ return null;
260
+ };
261
+ const openGraphInWindowsNativeGui = (url, parentPid) => {
262
+ const powershell = resolveWindowsPowershellExecutable();
263
+ if (!powershell) {
264
+ return false;
265
+ }
266
+ try {
267
+ writeFileSync(nativeGuiPowershellScriptPath, nativeGuiPowershellScript, 'utf8');
268
+ }
269
+ catch {
270
+ return false;
271
+ }
272
+ return spawnDetached(powershell, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-File', nativeGuiPowershellScriptPath, url, String(parentPid)]);
273
+ };
274
+ const openGraphInLinuxNativeGui = (url, parentPid) => {
275
+ if (!commandExists('python3')) {
276
+ return false;
277
+ }
278
+ try {
279
+ writeFileSync(nativeGuiLinuxScriptPath, nativeGuiLinuxPythonScript, 'utf8');
280
+ }
281
+ catch {
282
+ return false;
283
+ }
284
+ return spawnDetached('python3', [nativeGuiLinuxScriptPath, url, String(parentPid)]);
285
+ };
286
+ const openGraphInNativeGui = (url, parentPid) => {
287
+ if (platform() === 'darwin') {
288
+ return openGraphInMacNativeGui(url, parentPid);
289
+ }
290
+ if (platform() === 'win32') {
291
+ return openGraphInWindowsNativeGui(url, parentPid);
292
+ }
293
+ return openGraphInLinuxNativeGui(url, parentPid);
294
+ };
295
+ const openGraphInAppWindow = (url) => {
296
+ if (platform() === 'darwin') {
297
+ const macCandidates = [
298
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
299
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
300
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'
301
+ ]
302
+ .filter((candidate) => existsSync(candidate))
303
+ .map((binary) => ({ binary, args: [`--app=${url}`, '--new-window'] }));
304
+ for (const candidate of macCandidates) {
305
+ if (spawnDetached(candidate.binary, candidate.args)) {
306
+ return true;
307
+ }
308
+ }
309
+ return false;
310
+ }
311
+ if (platform() === 'win32') {
312
+ const appArgument = `--app=${url}`;
313
+ return spawnAnyDetached([
314
+ ...windowsStartCandidates('msedge', [appArgument, '--new-window']),
315
+ ...windowsStartCandidates('chrome', [appArgument, '--new-window']),
316
+ ...windowsStartCandidates('chromium', [appArgument, '--new-window']),
317
+ ...windowsStartCandidates('brave', [appArgument, '--new-window'])
318
+ ]);
319
+ }
320
+ const appArgument = `--app=${url}`;
321
+ const linuxAppWindowCandidates = [
322
+ 'microsoft-edge',
323
+ 'microsoft-edge-stable',
324
+ 'google-chrome',
325
+ 'google-chrome-stable',
326
+ 'chromium',
327
+ 'chromium-browser',
328
+ 'brave-browser'
329
+ ].filter((candidate) => commandExists(candidate));
330
+ return spawnAnyDetached(linuxAppWindowCandidates.map((command) => [command, [appArgument, '--new-window']]));
331
+ };
332
+ const openGraphInDetectedBrowser = (url) => {
333
+ if (platform() === 'win32') {
334
+ return spawnAnyDetached([
335
+ ...windowsStartCandidates('msedge', [url]),
336
+ ...windowsStartCandidates('chrome', [url]),
337
+ ...windowsStartCandidates('firefox', ['-new-window', url]),
338
+ ...windowsStartCandidates('chromium', [url]),
339
+ ...windowsStartCandidates('brave', [url])
340
+ ]);
341
+ }
342
+ const linuxBrowserCandidates = [
343
+ ['microsoft-edge', [url]],
344
+ ['microsoft-edge-stable', [url]],
345
+ ['google-chrome', [url]],
346
+ ['google-chrome-stable', [url]],
347
+ ['chromium', [url]],
348
+ ['chromium-browser', [url]],
349
+ ['brave-browser', [url]],
350
+ ['firefox', ['-new-window', url]]
351
+ ];
352
+ const available = linuxBrowserCandidates.filter(([command]) => commandExists(command));
353
+ return spawnAnyDetached(available);
354
+ };
355
+ const openUrlInUi = (url, parentPid) => {
356
+ const openDisabled = process.env.BRAINLINK_NO_BROWSER === '1' ||
357
+ process.env.BRAINLINK_NO_BROWSER === 'true' ||
358
+ process.env.CI === 'true';
359
+ if (openDisabled) {
360
+ return { opened: false, mode: 'none' };
361
+ }
362
+ const currentPlatform = platform();
363
+ const nativeGuiEnabled = !envFlagEnabled('BRAINLINK_NO_NATIVE_GUI') &&
364
+ (currentPlatform !== 'linux' || envFlagEnabled('BRAINLINK_LINUX_NATIVE_GUI') || envFlagEnabled('BRAINLINK_FORCE_NATIVE_GUI'));
365
+ if (nativeGuiEnabled && openGraphInNativeGui(url, parentPid)) {
366
+ return { opened: true, mode: 'native-gui' };
367
+ }
368
+ if (openGraphInAppWindow(url)) {
369
+ return { opened: true, mode: 'app-window' };
370
+ }
371
+ try {
372
+ if (platform() === 'darwin') {
373
+ return { opened: spawnDetached('open', [url]), mode: 'browser' };
374
+ }
375
+ if (openGraphInDetectedBrowser(url)) {
376
+ return { opened: true, mode: 'browser' };
377
+ }
378
+ if (platform() === 'win32') {
379
+ return { opened: spawnDetached('cmd', ['/c', 'start', '', url]), mode: 'browser' };
380
+ }
381
+ return { opened: spawnDetached('xdg-open', [url]), mode: 'browser' };
382
+ }
383
+ catch {
384
+ return { opened: false, mode: 'none' };
385
+ }
386
+ };
19
387
  export const registerWriteCommands = (program) => {
20
388
  program
21
389
  .command('init')
22
390
  .argument('[vault]', 'vault directory')
391
+ .option('--migrate-from <vault>', 'copy existing vault content into the initialized vault')
392
+ .option('--no-migrate-existing', 'skip automatic migration from the default Brainlink vault into an empty custom vault')
23
393
  .option('--json', 'print machine-readable JSON')
24
394
  .description('initialize a Brainlink vault')
25
395
  .action(async (vault, options) => {
26
396
  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}`);
397
+ const targetVault = assertVaultAllowed(vault ?? config.vault, config.allowedVaults);
398
+ const path = await ensureVault(targetVault);
399
+ const explicitSource = options.migrateFrom ? assertVaultAllowed(options.migrateFrom, config.allowedVaults) : undefined;
400
+ const shouldAutoMigrate = explicitSource === undefined &&
401
+ options.migrateExisting !== false &&
402
+ (await shouldMigrateDefaultVault(defaultBrainlinkConfig.vault, targetVault));
403
+ const migration = explicitSource || shouldAutoMigrate ? await migrateVaultContent(explicitSource ?? defaultBrainlinkConfig.vault, targetVault) : undefined;
404
+ const index = migration && migration.copied + migration.conflicted > 0 ? await indexVault(targetVault) : undefined;
405
+ print(options.json, { path, ...(migration ? { migration } : {}), ...(index ? { index } : {}) }, () => {
406
+ const migrated = migration
407
+ ? ` Migrated ${migration.copied} files, preserved ${migration.conflicted} conflicts and kept ${migration.unchanged} unchanged files.`
408
+ : '';
409
+ return `Initialized Brainlink vault at ${path}.${migrated}`;
410
+ });
411
+ });
412
+ program
413
+ .command('migrate-vault')
414
+ .option('--from <vault>', 'source vault path')
415
+ .option('--to <vault>', 'target vault path')
416
+ .option('--dry-run', 'preview migration without writing files')
417
+ .option('--report <path>', 'write detailed per-file migration report to JSON file')
418
+ .option('--no-index', 'skip reindexing target vault after migration')
419
+ .option('--json', 'print machine-readable JSON')
420
+ .description('copy markdown memory from one vault to another with conflict preservation')
421
+ .action(async (options) => {
422
+ const config = await loadBrainlinkConfig();
423
+ const sourceVault = assertVaultAllowed(options.from ?? config.vault, config.allowedVaults);
424
+ const targetVault = assertVaultAllowed(options.to ?? defaultBrainlinkConfig.vault, config.allowedVaults);
425
+ const sourceRoot = await ensureVault(sourceVault);
426
+ const targetRoot = await ensureVault(targetVault);
427
+ const preview = await previewVaultMigration(sourceVault, targetVault);
428
+ const actions = await planVaultMigration(sourceRoot, targetRoot);
429
+ const reportEntries = actions.map((action) => ({
430
+ kind: action.kind,
431
+ sourcePath: action.sourcePath,
432
+ sourceRelativePath: relative(sourceRoot, action.sourcePath),
433
+ targetPath: action.targetPath,
434
+ targetRelativePath: relative(targetRoot, action.targetPath)
435
+ }));
436
+ const writeReport = async () => {
437
+ if (!options.report) {
438
+ return null;
439
+ }
440
+ const reportPath = resolve(options.report);
441
+ await mkdir(dirname(reportPath), { recursive: true });
442
+ await writeFile(reportPath, `${JSON.stringify({ source: sourceVault, target: targetVault, summary: preview, entries: reportEntries }, null, 2)}\n`, 'utf8');
443
+ return reportPath;
444
+ };
445
+ if (options.dryRun) {
446
+ const reportPath = await writeReport();
447
+ 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}` : ''}`);
448
+ return;
449
+ }
450
+ const migration = await migrateVaultContent(sourceVault, targetVault);
451
+ const shouldIndex = options.index !== false && migration.copied + migration.conflicted > 0;
452
+ const index = shouldIndex ? await indexVault(targetVault) : undefined;
453
+ const reportPath = await writeReport();
454
+ print(options.json, { dryRun: false, ...migration, entries: reportEntries, ...(index ? { index } : {}), ...(reportPath ? { reportPath } : {}) }, () => {
455
+ const summary = `Migrated ${migration.copied} files, preserved ${migration.conflicted} conflicts and kept ${migration.unchanged} unchanged files.`;
456
+ const indexMessage = index
457
+ ? ` Indexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} links.`
458
+ : '';
459
+ const reportMessage = reportPath ? ` Report written to ${reportPath}.` : '';
460
+ return `${summary}${indexMessage}${reportMessage}`;
461
+ });
462
+ });
463
+ program
464
+ .command('db-import')
465
+ .option('-v, --vault <vault>', 'vault directory')
466
+ .option('--db <path>', 'legacy SQLite database path (default: <vault>/.brainlink/brainlink.db)')
467
+ .option('--table <name>', 'legacy table name override')
468
+ .option('-a, --agent <agent>', 'force imported notes into a target agent namespace')
469
+ .option('-l, --limit <limit>', 'maximum number of rows to import')
470
+ .option('--dry-run', 'preview import without writing Markdown files')
471
+ .option('--no-index', 'skip reindexing after import')
472
+ .option('--json', 'print machine-readable JSON')
473
+ .description('import legacy SQLite memory into Markdown vault and current index model')
474
+ .action(async (options) => {
475
+ const resolved = await resolveOptions(options);
476
+ const result = await importLegacySqliteDatabase(resolved.vault, {
477
+ dbPath: options.db,
478
+ table: options.table,
479
+ agentOverride: options.agent ? resolved.agent : undefined,
480
+ limit: options.limit ? parsePositiveInteger(options.limit, 100_000) : undefined,
481
+ dryRun: Boolean(options.dryRun)
482
+ });
483
+ const shouldIndex = options.index !== false && !result.dryRun && result.imported > 0;
484
+ const index = shouldIndex ? await indexVault(resolved.vault) : undefined;
485
+ print(options.json, { ...result, ...(index ? { index } : {}) }, () => {
486
+ const summary = `Imported ${result.imported}/${result.rowsRead} rows from ${result.table} (skipped ${result.skipped}).`;
487
+ const indexMessage = index
488
+ ? ` Indexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} links.`
489
+ : '';
490
+ const dryRunMessage = result.dryRun ? ' Dry run only; no files were written.' : '';
491
+ return `${summary}${indexMessage}${dryRunMessage}`;
492
+ });
29
493
  });
30
494
  program
31
495
  .command('add')
@@ -41,12 +505,95 @@ export const registerWriteCommands = (program) => {
41
505
  .action(async (title, options) => {
42
506
  const resolved = await resolveOptions(options);
43
507
  const content = resolveAddContent(options);
44
- const notePath = await addNote(resolved.vault, title, content, resolved.agent, {
508
+ const added = await addNoteWithMetadata(resolved.vault, title, content, resolved.agent, {
45
509
  allowSensitive: Boolean(options.allowSensitive)
46
510
  });
47
511
  const shouldAutoIndex = options.autoIndex !== false && resolved.config.autoIndexOnWrite;
48
512
  const index = shouldAutoIndex ? await indexVault(resolved.vault) : undefined;
49
- print(options.json, { title, agent: resolved.agent ?? 'shared', path: notePath, ...(index ? { index } : {}) }, () => `Created note at ${notePath}`);
513
+ const absoluteVaultPath = await ensureVault(resolved.vault);
514
+ const focusPath = added.path.startsWith(absoluteVaultPath)
515
+ ? relative(absoluteVaultPath, added.path).replaceAll('\\', '/')
516
+ : added.path.includes('agents/')
517
+ ? added.path.slice(added.path.indexOf('agents/')).replaceAll('\\', '/')
518
+ : undefined;
519
+ const possibleDuplicates = await scanDuplicateNotes(resolved.vault, {
520
+ agentId: resolved.agent,
521
+ focusPath,
522
+ limit: 5,
523
+ minSemanticScore: 0.92,
524
+ includeSemantic: true
525
+ });
526
+ print(options.json, {
527
+ title,
528
+ agent: resolved.agent ?? 'shared',
529
+ path: added.path,
530
+ writeConnectivity: {
531
+ autoLinked: added.autoLinked,
532
+ linkTarget: added.linkTarget,
533
+ guaranteedEdge: true
534
+ },
535
+ possibleDuplicates,
536
+ ...(index ? { index } : {})
537
+ }, () => {
538
+ const duplicateMessage = possibleDuplicates.length > 0
539
+ ? `\nPotential duplicates: ${possibleDuplicates.length}. Use "blink dedupe --json" or "blink dedupe-resolve".`
540
+ : '';
541
+ return `Created note at ${added.path}${duplicateMessage}`;
542
+ });
543
+ });
544
+ program
545
+ .command('dedupe')
546
+ .option('-v, --vault <vault>', 'vault directory')
547
+ .option('-a, --agent <agent>', 'agent memory namespace')
548
+ .option('-l, --limit <limit>', 'maximum duplicate candidate pairs')
549
+ .option('--min-score <score>', 'minimum semantic similarity score between 0 and 1', '0.92')
550
+ .option('--no-semantic', 'disable semantic duplicate detection and keep exact-content matching only')
551
+ .option('--json', 'print machine-readable JSON')
552
+ .description('detect possible duplicate notes with exact hash and semantic similarity scores')
553
+ .action(async (options) => {
554
+ const resolved = await resolveOptions(options);
555
+ const duplicates = await scanDuplicateNotes(resolved.vault, {
556
+ agentId: resolved.agent,
557
+ limit: parsePositiveInteger(options.limit ?? '25', 25),
558
+ minSemanticScore: parseScore(options.minScore, 0.92),
559
+ includeSemantic: options.semantic !== false
560
+ });
561
+ print(options.json, { vault: resolved.vault, agent: resolved.agent, duplicates }, () => {
562
+ if (duplicates.length === 0) {
563
+ return 'No possible duplicates found.';
564
+ }
565
+ return duplicates
566
+ .map((item, index) => `${index + 1}. [${item.kind}] score=${item.score.toFixed(4)} ${item.left.path} <-> ${item.right.path} (${item.reason})`)
567
+ .join('\n');
568
+ });
569
+ });
570
+ program
571
+ .command('dedupe-resolve')
572
+ .option('-v, --vault <vault>', 'vault directory')
573
+ .option('--left <path>', 'left note relative path from dedupe result')
574
+ .option('--right <path>', 'right note relative path from dedupe result')
575
+ .option('--action <action>', 'resolution action: merge, link or ignore')
576
+ .option('--no-auto-index', 'skip reindex after duplicate resolution')
577
+ .option('--json', 'print machine-readable JSON')
578
+ .description('resolve a duplicate candidate with merge, link or ignore')
579
+ .action(async (options) => {
580
+ const resolved = await resolveOptions(options);
581
+ if (!options.left || !options.right) {
582
+ throw new Error('Use --left <path> and --right <path> to resolve a duplicate pair.');
583
+ }
584
+ if (options.action !== 'merge' && options.action !== 'link' && options.action !== 'ignore') {
585
+ throw new Error('Use --action merge|link|ignore.');
586
+ }
587
+ const result = await resolveDuplicateNotes(resolved.vault, {
588
+ leftPath: options.left,
589
+ rightPath: options.right,
590
+ action: options.action,
591
+ autoIndex: options.autoIndex !== false
592
+ });
593
+ print(options.json, {
594
+ vault: resolved.vault,
595
+ ...result
596
+ }, () => `Resolved duplicate (${result.action}) for ${result.leftPath} <-> ${result.rightPath}`);
50
597
  });
51
598
  program
52
599
  .command('index')
@@ -66,7 +613,13 @@ export const registerWriteCommands = (program) => {
66
613
  .action(async (options) => {
67
614
  const resolved = await resolveOptions(options);
68
615
  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'));
616
+ print(options.json, report, () => {
617
+ const checks = report.checks.map((check) => `${check.ok ? 'OK' : 'FAIL'} ${check.name}: ${check.message}`).join('\n');
618
+ const recommendations = report.recommendations && report.recommendations.length > 0
619
+ ? `\n\nRecommended next steps:\n${report.recommendations.map((step) => `- ${step}`).join('\n')}`
620
+ : '';
621
+ return `${checks}${recommendations}`;
622
+ });
70
623
  process.exitCode = report.ok ? 0 : 1;
71
624
  });
72
625
  program
@@ -103,6 +656,7 @@ export const registerWriteCommands = (program) => {
103
656
  .option('-h, --host <host>', 'server host', '127.0.0.1')
104
657
  .option('-p, --port <port>', 'server port', '4321')
105
658
  .option('--no-index', 'skip indexing before starting the server')
659
+ .option('--no-open', 'do not open the graph UI automatically')
106
660
  .option('-w, --watch', 'watch markdown files and reindex on changes')
107
661
  .option('--json', 'print machine-readable JSON')
108
662
  .description('start a local web UI for the knowledge graph')
@@ -115,6 +669,112 @@ export const registerWriteCommands = (program) => {
115
669
  shouldIndex: options.index,
116
670
  shouldWatch: Boolean(options.watch)
117
671
  });
118
- print(options.json, { url: server.url, watch: Boolean(options.watch), readonly: true }, () => `Brainlink graph server running at ${server.url}`);
672
+ const openResult = options.open !== false ? openUrlInUi(server.url, process.pid) : { opened: false, mode: 'none' };
673
+ print(options.json, {
674
+ url: server.url,
675
+ watch: Boolean(options.watch),
676
+ readonly: true,
677
+ openedUi: openResult.opened,
678
+ openMode: openResult.mode
679
+ }, () => `Brainlink graph server running at ${server.url}${openResult.opened
680
+ ? openResult.mode === 'native-gui'
681
+ ? ' (opened in native desktop GUI)'
682
+ : openResult.mode === 'app-window'
683
+ ? ' (opened in dedicated app window)'
684
+ : ' (opened in browser)'
685
+ : options.open === false
686
+ ? ' (auto-open disabled)'
687
+ : ''}`);
688
+ });
689
+ program
690
+ .command('quickstart')
691
+ .option('-v, --vault <vault>', 'vault directory')
692
+ .option('-a, --agent <agent>', 'agent memory namespace')
693
+ .option('--query <query>', 'optional task query to return immediate grounded context')
694
+ .option('--mode <mode>', 'search mode for context (fts|semantic|hybrid)')
695
+ .option('--limit <limit>', 'maximum context sections')
696
+ .option('--tokens <tokens>', 'maximum context token budget')
697
+ .option('--no-install-agent', 'skip agent MCP/plugin installation and upgrade automation')
698
+ .option('--mcp-only', 'when installing agent integration, only configure MCP section')
699
+ .option('--plugin-path <path>', 'custom source path for Brainlink plugin files')
700
+ .option('--allowed-vaults <paths>', 'comma separated vault allowlist to inject in MCP env')
701
+ .option('--brainlink-home <path>', 'BRAINLINK_HOME value to inject in MCP env')
702
+ .option('--json', 'print machine-readable JSON')
703
+ .description('run plug-and-play setup for vault, MCP integration and bootstrap readiness')
704
+ .action(async (options) => {
705
+ const resolved = await resolveOptions(options);
706
+ const limit = parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit);
707
+ const tokens = parsePositiveInteger(options.tokens ?? String(resolved.defaults.defaultContextTokens), resolved.defaults.defaultContextTokens);
708
+ const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
709
+ const index = await indexVault(resolved.vault);
710
+ const stats = await getStats(resolved.vault, resolved.agent);
711
+ const validation = await validateVault(resolved.vault, resolved.agent);
712
+ const doctor = await doctorVault(resolved.vault);
713
+ const session = await touchBootstrapSession(resolved.vault, resolved.agent);
714
+ const policy = await getBootstrapPolicy();
715
+ const bootstrapStatus = await getBootstrapSessionStatus(resolved.vault, resolved.agent);
716
+ const context = options.query
717
+ ? await buildContextPackage(resolved.vault, options.query, limit, tokens, resolved.agent, mode)
718
+ : null;
719
+ const agentIntegration = options.installAgent === false
720
+ ? null
721
+ : await installAgentIntegration({
722
+ mcpOnly: options.mcpOnly,
723
+ pluginPath: options.pluginPath,
724
+ allowedVaults: options.allowedVaults,
725
+ brainlinkHome: options.brainlinkHome,
726
+ selfTest: true
727
+ });
728
+ const nextActions = stats.documentCount === 0
729
+ ? [
730
+ {
731
+ priority: 'required',
732
+ command: `blink add "Architecture" --vault "${resolved.vault}" --content "Durable memory with [[Links]] and #tags."`,
733
+ reason: 'Seed your vault with at least one durable Markdown note.'
734
+ },
735
+ {
736
+ priority: 'required',
737
+ command: `blink index --vault "${resolved.vault}"`,
738
+ reason: 'Rebuild index after adding notes so retrieval can find new memory.'
739
+ }
740
+ ]
741
+ : options.query
742
+ ? [
743
+ {
744
+ priority: 'recommended',
745
+ command: `blink add "Task Update" --vault "${resolved.vault}" --agent "${resolved.agent ?? 'shared'}" --content "<durable memory>"`,
746
+ reason: 'Persist important findings as Markdown notes after using the returned context.'
747
+ }
748
+ ]
749
+ : [
750
+ {
751
+ priority: 'recommended',
752
+ command: `blink context "<task>" --vault "${resolved.vault}" --agent "${resolved.agent ?? 'shared'}" --mode ${mode}`,
753
+ reason: 'Retrieve grounded context for each task before responding.'
754
+ }
755
+ ];
756
+ print(options.json, {
757
+ vault: resolved.vault,
758
+ agent: resolved.agent ?? 'shared',
759
+ mode,
760
+ index,
761
+ stats,
762
+ validation,
763
+ doctor,
764
+ policy,
765
+ bootstrapStatus,
766
+ session,
767
+ context,
768
+ agentIntegration,
769
+ nextActions
770
+ }, () => [
771
+ `quickstart vault=${resolved.vault}`,
772
+ `agent=${resolved.agent ?? 'shared'}`,
773
+ `documents=${stats.documentCount}`,
774
+ `links=${stats.linkCount}`,
775
+ `bootstrapReady=${bootstrapStatus.ready}`,
776
+ ...(agentIntegration?.selfTest ? [`agentIntegrationSelfTest=${agentIntegration.selfTest.ok}`] : []),
777
+ ...(nextActions.length > 0 ? ['Next actions:', ...nextActions.map((step) => `- ${step.command}`)] : [])
778
+ ].join('\n'));
119
779
  });
120
780
  };