@andespindola/brainlink 0.1.0-beta.8 → 0.1.0-beta.80

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 (63) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +58 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +266 -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 +138 -103
  12. package/dist/application/frontend/client-html.js +47 -41
  13. package/dist/application/frontend/client-js.js +2469 -156
  14. package/dist/application/frontend/client-worker-js.js +66 -0
  15. package/dist/application/get-graph-layout.js +18 -6
  16. package/dist/application/get-graph-node.js +12 -0
  17. package/dist/application/get-graph-summary.js +12 -0
  18. package/dist/application/get-graph.js +3 -3
  19. package/dist/application/import-legacy-sqlite.js +296 -0
  20. package/dist/application/index-vault.js +252 -19
  21. package/dist/application/list-agents.js +3 -3
  22. package/dist/application/list-links.js +5 -5
  23. package/dist/application/migrate-vault.js +46 -16
  24. package/dist/application/offline-pack-backup.js +44 -0
  25. package/dist/application/search-graph-node-ids.js +12 -0
  26. package/dist/application/search-knowledge.js +75 -5
  27. package/dist/application/server/routes.js +102 -1
  28. package/dist/application/start-server.js +75 -4
  29. package/dist/application/watch-vault.js +23 -2
  30. package/dist/benchmarks/large-vault.js +1 -1
  31. package/dist/cli/commands/agent-commands.js +419 -0
  32. package/dist/cli/commands/config-commands.js +167 -0
  33. package/dist/cli/commands/read-commands.js +25 -8
  34. package/dist/cli/commands/write-commands.js +973 -10
  35. package/dist/cli/main.js +4 -0
  36. package/dist/cli/runtime.js +5 -2
  37. package/dist/domain/context.js +53 -11
  38. package/dist/domain/embeddings.js +2 -1
  39. package/dist/domain/graph-layout.js +67 -16
  40. package/dist/domain/markdown.js +36 -4
  41. package/dist/domain/middle-out.js +18 -0
  42. package/dist/infrastructure/config.js +132 -8
  43. package/dist/infrastructure/file-index.js +358 -0
  44. package/dist/infrastructure/file-system-vault.js +15 -0
  45. package/dist/infrastructure/index-state.js +56 -0
  46. package/dist/infrastructure/paths.js +9 -1
  47. package/dist/infrastructure/private-pack-codec.js +134 -0
  48. package/dist/infrastructure/search-packs.js +452 -0
  49. package/dist/infrastructure/session-state.js +172 -0
  50. package/dist/mcp/main.js +11 -3
  51. package/dist/mcp/server.js +27 -2
  52. package/dist/mcp/startup.js +35 -0
  53. package/dist/mcp/tools.js +633 -19
  54. package/docs/AGENT_USAGE.md +177 -15
  55. package/docs/ARCHITECTURE.md +37 -26
  56. package/docs/QUICKSTART.md +111 -0
  57. package/package.json +6 -4
  58. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  59. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  60. package/dist/infrastructure/sqlite/schema.js +0 -111
  61. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  62. package/dist/infrastructure/sqlite/types.js +0 -1
  63. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -1,13 +1,23 @@
1
- import { readFileSync } from 'node:fs';
2
- import { addNote } from '../../application/add-note.js';
3
- import { indexVault } from '../../application/index-vault.js';
4
- import { migrateVaultContent, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { mkdir, readFile, 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';
10
+ import { indexVault, indexVaultWithOptions } from '../../application/index-vault.js';
11
+ import { migrateVaultContent, planVaultMigration, previewVaultMigration, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
12
+ import { createOfflinePackBackup } from '../../application/offline-pack-backup.js';
5
13
  import { startServer } from '../../application/start-server.js';
6
14
  import { startVaultWatcher } from '../../application/watch-vault.js';
7
- import { doctorVault } from '../../application/analyze-vault.js';
8
- import { defaultBrainlinkConfig } from '../../infrastructure/config.js';
15
+ import { doctorVault, getStats, validateVault } from '../../application/analyze-vault.js';
16
+ import { defaultBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/config.js';
9
17
  import { loadBrainlinkConfig } from '../../infrastructure/config.js';
10
18
  import { assertVaultAllowed, ensureVault } from '../../infrastructure/file-system-vault.js';
19
+ import { getBootstrapPolicy, getBootstrapSessionStatus, touchBootstrapSession } from '../../infrastructure/session-state.js';
20
+ import { installAgentIntegration } from './agent-commands.js';
11
21
  import { parsePositiveInteger, print, resolveOptions } from '../runtime.js';
12
22
  const resolveAddContent = (options) => {
13
23
  if (options.content != null && options.content.trim().length > 0) {
@@ -18,6 +28,600 @@ const resolveAddContent = (options) => {
18
28
  }
19
29
  return readFileSync(options.contentFile, 'utf8');
20
30
  };
31
+ const parseScore = (value, fallback) => {
32
+ if (value == null) {
33
+ return fallback;
34
+ }
35
+ const parsed = Number.parseFloat(value);
36
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
37
+ throw new Error(`Invalid score value: ${value}. Expected a number between 0 and 1.`);
38
+ }
39
+ return parsed;
40
+ };
41
+ const formatBytes = (bytes) => {
42
+ if (!Number.isFinite(bytes) || bytes == null) {
43
+ return 'n/a';
44
+ }
45
+ if (bytes < 1024)
46
+ return `${bytes} B`;
47
+ const units = ['KB', 'MB', 'GB', 'TB'];
48
+ let value = bytes / 1024;
49
+ let unitIndex = 0;
50
+ while (value >= 1024 && unitIndex < units.length - 1) {
51
+ value /= 1024;
52
+ unitIndex += 1;
53
+ }
54
+ return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
55
+ };
56
+ const formatMs = (value) => Number.isFinite(value) && value != null ? `${value.toFixed(value >= 100 ? 0 : 1)}ms` : 'n/a';
57
+ const benchEventLabel = (event) => `${event.phase}:${event.status}`;
58
+ const printBenchRealtimeEvent = (json, event) => {
59
+ print(json, {
60
+ event: 'bench-progress',
61
+ ...event
62
+ }, () => `[bench] ${benchEventLabel(event)} ${event.message} (${formatMs(event.elapsedMs)})`);
63
+ };
64
+ const printBenchSummary = (json, trigger, vault, result) => {
65
+ print(json, {
66
+ event: 'bench-result',
67
+ trigger,
68
+ vault,
69
+ result
70
+ }, () => {
71
+ const packs = result.packs;
72
+ const compression = packs?.compression;
73
+ const savedPercent = compression && compression.inputBytes > 0
74
+ ? `${((1 - compression.ratio) * 100).toFixed(1)}%`
75
+ : 'n/a';
76
+ return [
77
+ `[bench] trigger=${trigger}`,
78
+ `documents=${result.documentCount} chunks=${result.chunkCount} links=${result.linkCount}`,
79
+ `changedDocuments=${result.changedDocumentCount ?? 0} totalElapsed=${formatMs(result.elapsedMs)}`,
80
+ `packsRebuilt=${packs?.rebuilt ? 'yes' : 'no'} reason=${packs?.reason ?? 'n/a'}`,
81
+ packs?.rebuilt
82
+ ? `packCount=${packs.packCount ?? 0} packDuration=${formatMs(packs.durationMs)} input=${formatBytes(compression?.inputBytes)} output=${formatBytes(compression?.outputBytes)} saved=${savedPercent}`
83
+ : 'packCompression=n/a'
84
+ ].join('\n');
85
+ });
86
+ };
87
+ const benchHistoryPath = (vaultPath) => join(vaultPath, '.brainlink', 'benchmarks', 'latest.json');
88
+ const readBenchHistory = async (vaultPath) => {
89
+ try {
90
+ const parsed = JSON.parse(await readFile(benchHistoryPath(vaultPath), 'utf8'));
91
+ if (typeof parsed.elapsedMs !== 'number' || typeof parsed.timestamp !== 'string') {
92
+ return null;
93
+ }
94
+ return {
95
+ elapsedMs: parsed.elapsedMs,
96
+ timestamp: parsed.timestamp,
97
+ ...(typeof parsed.compressionRatio === 'number' ? { compressionRatio: parsed.compressionRatio } : {})
98
+ };
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ };
104
+ const writeBenchHistory = async (vaultPath, result) => {
105
+ await mkdir(dirname(benchHistoryPath(vaultPath)), { recursive: true });
106
+ const payload = {
107
+ elapsedMs: result.elapsedMs ?? 0,
108
+ timestamp: new Date().toISOString(),
109
+ ...(typeof result.packs?.compression?.ratio === 'number' ? { compressionRatio: result.packs.compression.ratio } : {})
110
+ };
111
+ await writeFile(benchHistoryPath(vaultPath), `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
112
+ };
113
+ const evaluateBenchGuardrails = (config, result, baseline) => {
114
+ const compressionRatio = result.packs?.compression?.ratio;
115
+ const compressionSavingsPercent = typeof compressionRatio === 'number' ? Math.max(0, (1 - compressionRatio) * 100) : undefined;
116
+ const compressionPass = compressionSavingsPercent != null
117
+ ? compressionSavingsPercent >= config.searchPack.guardrailMinSavingsPercent
118
+ : undefined;
119
+ const latencyRegressionPercent = baseline && baseline.elapsedMs > 0 && typeof result.elapsedMs === 'number'
120
+ ? ((result.elapsedMs - baseline.elapsedMs) / baseline.elapsedMs) * 100
121
+ : undefined;
122
+ const latencyPass = latencyRegressionPercent != null
123
+ ? latencyRegressionPercent <= config.searchPack.guardrailMaxLatencyRegressionPercent
124
+ : undefined;
125
+ return {
126
+ ...(compressionSavingsPercent != null ? { compressionSavingsPercent } : {}),
127
+ ...(compressionPass != null ? { compressionPass } : {}),
128
+ ...(latencyRegressionPercent != null ? { latencyRegressionPercent } : {}),
129
+ ...(latencyPass != null ? { latencyPass } : {})
130
+ };
131
+ };
132
+ const printBenchGuardrails = (json, vault, config, guardrails) => {
133
+ print(json, {
134
+ event: 'bench-guardrails',
135
+ vault,
136
+ thresholds: {
137
+ minSavingsPercent: config.searchPack.guardrailMinSavingsPercent,
138
+ maxLatencyRegressionPercent: config.searchPack.guardrailMaxLatencyRegressionPercent
139
+ },
140
+ guardrails
141
+ }, () => {
142
+ const savings = guardrails.compressionSavingsPercent;
143
+ const latency = guardrails.latencyRegressionPercent;
144
+ return [
145
+ '[bench] guardrails',
146
+ `minSavings=${config.searchPack.guardrailMinSavingsPercent.toFixed(1)}% maxLatencyRegression=${config.searchPack.guardrailMaxLatencyRegressionPercent.toFixed(1)}%`,
147
+ `compressionSavings=${savings != null ? `${savings.toFixed(2)}%` : 'n/a'} pass=${guardrails.compressionPass != null ? (guardrails.compressionPass ? 'yes' : 'no') : 'n/a'}`,
148
+ `latencyRegression=${latency != null ? `${latency.toFixed(2)}%` : 'n/a'} pass=${guardrails.latencyPass != null ? (guardrails.latencyPass ? 'yes' : 'no') : 'n/a'}`
149
+ ].join('\n');
150
+ });
151
+ };
152
+ const spawnDetached = (command, args, envOverrides) => {
153
+ try {
154
+ const child = spawn(command, args, {
155
+ detached: true,
156
+ stdio: 'ignore',
157
+ env: envOverrides ? { ...process.env, ...envOverrides } : process.env
158
+ });
159
+ child.unref();
160
+ return true;
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ };
166
+ const nativeGuiSwiftScriptPath = join(tmpdir(), 'brainlink-native-gui.swift');
167
+ const nativeGuiPowershellScriptPath = join(tmpdir(), 'brainlink-native-gui.ps1');
168
+ const nativeGuiLinuxScriptPath = join(tmpdir(), 'brainlink-native-gui-linux.py');
169
+ const nativeGuiSwiftScript = `import Foundation
170
+ import AppKit
171
+ import WebKit
172
+ import Darwin
173
+
174
+ final class BrainlinkAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
175
+ private let targetUrl: URL
176
+ private let parentPid: Int32
177
+ private var window: NSWindow?
178
+ private var webView: WKWebView?
179
+ private var monitorTimer: Timer?
180
+
181
+ init(targetUrl: URL, parentPid: Int32) {
182
+ self.targetUrl = targetUrl
183
+ self.parentPid = parentPid
184
+ }
185
+
186
+ func applicationDidFinishLaunching(_ notification: Notification) {
187
+ let window = NSWindow(
188
+ contentRect: NSRect(x: 0, y: 0, width: 1320, height: 860),
189
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
190
+ backing: .buffered,
191
+ defer: false
192
+ )
193
+ window.title = "Brainlink Graph"
194
+ window.center()
195
+ window.isReleasedWhenClosed = false
196
+ window.delegate = self
197
+
198
+ let webView = WKWebView(frame: window.contentView?.bounds ?? .zero)
199
+ webView.autoresizingMask = [.width, .height]
200
+ webView.allowsBackForwardNavigationGestures = true
201
+ webView.load(URLRequest(url: targetUrl))
202
+ window.contentView?.addSubview(webView)
203
+
204
+ self.window = window
205
+ self.webView = webView
206
+
207
+ if parentPid > 0 {
208
+ monitorTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
209
+ if kill(self.parentPid, 0) != 0 {
210
+ NSApp.terminate(nil)
211
+ }
212
+ }
213
+ }
214
+
215
+ window.makeKeyAndOrderFront(nil)
216
+ NSApp.activate(ignoringOtherApps: true)
217
+ }
218
+
219
+ func windowWillClose(_ notification: Notification) {
220
+ monitorTimer?.invalidate()
221
+ NSApp.terminate(nil)
222
+ }
223
+ }
224
+
225
+ let args = Array(CommandLine.arguments.dropFirst())
226
+ let rawTarget = args.indices.contains(0) ? args[0] : "http://127.0.0.1:4321"
227
+ let parentPid: Int32 = args.indices.contains(1) ? (Int32(args[1]) ?? 0) : 0
228
+
229
+ guard let targetUrl = URL(string: rawTarget) else {
230
+ fputs("Invalid URL for Brainlink GUI: \\(rawTarget)\\n", stderr)
231
+ exit(1)
232
+ }
233
+
234
+ let app = NSApplication.shared
235
+ app.setActivationPolicy(.regular)
236
+ let delegate = BrainlinkAppDelegate(targetUrl: targetUrl, parentPid: parentPid)
237
+ app.delegate = delegate
238
+ app.run()
239
+ `;
240
+ const nativeGuiPowershellScript = `param(
241
+ [string]$TargetUrl = "http://127.0.0.1:4321",
242
+ [int]$ParentPid = 0
243
+ )
244
+
245
+ Add-Type -AssemblyName System.Windows.Forms
246
+ Add-Type -AssemblyName System.Drawing
247
+ [System.Windows.Forms.Application]::EnableVisualStyles()
248
+
249
+ $form = New-Object System.Windows.Forms.Form
250
+ $form.Text = "Brainlink Graph"
251
+ $form.Width = 1320
252
+ $form.Height = 860
253
+ $form.StartPosition = "CenterScreen"
254
+
255
+ $browser = New-Object System.Windows.Forms.WebBrowser
256
+ $browser.Dock = [System.Windows.Forms.DockStyle]::Fill
257
+ $browser.ScriptErrorsSuppressed = $true
258
+ $browser.Navigate($TargetUrl)
259
+
260
+ $form.Controls.Add($browser)
261
+ $timer = New-Object System.Windows.Forms.Timer
262
+ $timer.Interval = 1000
263
+ $timer.Add_Tick({
264
+ if ($ParentPid -le 0) {
265
+ return
266
+ }
267
+ try {
268
+ Get-Process -Id $ParentPid -ErrorAction Stop | Out-Null
269
+ } catch {
270
+ $timer.Stop()
271
+ $form.Close()
272
+ }
273
+ })
274
+ $form.Add_FormClosed({
275
+ $timer.Stop()
276
+ })
277
+ $timer.Start()
278
+ [void]$form.ShowDialog()
279
+ `;
280
+ const nativeGuiLinuxPythonScript = `#!/usr/bin/env python3
281
+ import sys
282
+
283
+ def run() -> int:
284
+ try:
285
+ import gi
286
+ gi.require_version("Gtk", "3.0")
287
+ try:
288
+ gi.require_version("WebKit2", "4.1")
289
+ except ValueError:
290
+ gi.require_version("WebKit2", "4.0")
291
+ from gi.repository import Gtk, WebKit2, GLib
292
+ except Exception:
293
+ return 1
294
+
295
+ target_url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:4321"
296
+ parent_pid = int(sys.argv[2]) if len(sys.argv) > 2 else 0
297
+
298
+ window = Gtk.Window(title="Brainlink Graph")
299
+ window.set_default_size(1320, 860)
300
+ window.connect("destroy", Gtk.main_quit)
301
+
302
+ webview = WebKit2.WebView()
303
+ webview.load_uri(target_url)
304
+ window.add(webview)
305
+ window.show_all()
306
+
307
+ if parent_pid > 0:
308
+ def _watch_parent() -> bool:
309
+ try:
310
+ import os
311
+ os.kill(parent_pid, 0)
312
+ except Exception:
313
+ Gtk.main_quit()
314
+ return False
315
+ return True
316
+
317
+ GLib.timeout_add(1000, _watch_parent)
318
+
319
+ Gtk.main()
320
+ return 0
321
+
322
+ if __name__ == "__main__":
323
+ raise SystemExit(run())
324
+ `;
325
+ const commandExists = (command) => {
326
+ try {
327
+ const probe = platform() === 'win32'
328
+ ? spawnSync('where', [command], { stdio: 'ignore' })
329
+ : spawnSync('which', [command], { stdio: 'ignore' });
330
+ return probe.status === 0;
331
+ }
332
+ catch {
333
+ return false;
334
+ }
335
+ };
336
+ const readLinuxDefaultBrowserDesktopEntry = () => {
337
+ try {
338
+ const preferred = spawnSync('xdg-settings', ['get', 'default-web-browser'], { encoding: 'utf8' });
339
+ const rawPreferred = preferred.status === 0 ? preferred.stdout.trim() : '';
340
+ if (rawPreferred.length > 0) {
341
+ return rawPreferred;
342
+ }
343
+ }
344
+ catch {
345
+ // fallback below
346
+ }
347
+ try {
348
+ const fallback = spawnSync('xdg-mime', ['query', 'default', 'x-scheme-handler/https'], { encoding: 'utf8' });
349
+ const rawFallback = fallback.status === 0 ? fallback.stdout.trim() : '';
350
+ return rawFallback.length > 0 ? rawFallback : null;
351
+ }
352
+ catch {
353
+ return null;
354
+ }
355
+ };
356
+ const toLinuxDefaultBrowserCommands = (desktopEntry) => {
357
+ if (!desktopEntry) {
358
+ return [];
359
+ }
360
+ const normalized = desktopEntry.toLowerCase().trim();
361
+ if (normalized.includes('firefox')) {
362
+ return ['firefox'];
363
+ }
364
+ if (normalized.includes('edge')) {
365
+ return ['microsoft-edge', 'microsoft-edge-stable'];
366
+ }
367
+ if (normalized.includes('brave')) {
368
+ return ['brave-browser'];
369
+ }
370
+ if (normalized.includes('chromium')) {
371
+ return ['chromium', 'chromium-browser'];
372
+ }
373
+ if (normalized.includes('chrome')) {
374
+ return ['google-chrome', 'google-chrome-stable'];
375
+ }
376
+ return [];
377
+ };
378
+ const readBrowserEnvCommands = () => {
379
+ const value = process.env.BROWSER?.trim();
380
+ if (!value) {
381
+ return [];
382
+ }
383
+ return value
384
+ .split(':')
385
+ .map((entry) => entry.trim().split(/\s+/)[0] ?? '')
386
+ .map((entry) => entry.trim())
387
+ .filter((entry) => entry.length > 0);
388
+ };
389
+ const prioritizeLinuxBrowserCandidates = (candidates) => {
390
+ const preferredCommands = toLinuxDefaultBrowserCommands(readLinuxDefaultBrowserDesktopEntry());
391
+ if (preferredCommands.length === 0) {
392
+ return candidates;
393
+ }
394
+ const priorityMap = new Map(preferredCommands.map((command, index) => [command, index]));
395
+ return [...candidates].sort((left, right) => {
396
+ const leftPriority = priorityMap.get(left[0]);
397
+ const rightPriority = priorityMap.get(right[0]);
398
+ const leftScore = leftPriority == null ? Number.POSITIVE_INFINITY : leftPriority;
399
+ const rightScore = rightPriority == null ? Number.POSITIVE_INFINITY : rightPriority;
400
+ return leftScore - rightScore;
401
+ });
402
+ };
403
+ const envFlagEnabled = (name) => process.env[name] === '1' || process.env[name] === 'true';
404
+ const spawnAnyDetached = (candidates) => candidates.some(([command, args]) => spawnDetached(command, args));
405
+ const spawnAnyDetachedWithEnv = (candidates) => candidates.some(([command, args, env]) => spawnDetached(command, args, env));
406
+ const windowsStartCandidates = (program, args = []) => [
407
+ ['cmd', ['/c', 'start', '', program, ...args]]
408
+ ];
409
+ const resolveSwiftExecutable = () => {
410
+ const directSwift = '/usr/bin/swift';
411
+ if (existsSync(directSwift)) {
412
+ return directSwift;
413
+ }
414
+ try {
415
+ const probe = spawnSync('xcrun', ['--find', 'swift'], { encoding: 'utf8' });
416
+ const swiftPath = probe.status === 0 ? probe.stdout.trim() : '';
417
+ return swiftPath.length > 0 ? swiftPath : null;
418
+ }
419
+ catch {
420
+ return null;
421
+ }
422
+ };
423
+ const openGraphInMacNativeGui = (url, parentPid) => {
424
+ const swiftBinary = resolveSwiftExecutable();
425
+ if (!swiftBinary) {
426
+ return false;
427
+ }
428
+ try {
429
+ writeFileSync(nativeGuiSwiftScriptPath, nativeGuiSwiftScript, 'utf8');
430
+ }
431
+ catch {
432
+ return false;
433
+ }
434
+ return spawnDetached(swiftBinary, [nativeGuiSwiftScriptPath, url, String(parentPid)]);
435
+ };
436
+ const resolveWindowsPowershellExecutable = () => {
437
+ if (commandExists('powershell')) {
438
+ return 'powershell';
439
+ }
440
+ if (commandExists('pwsh')) {
441
+ return 'pwsh';
442
+ }
443
+ return null;
444
+ };
445
+ const openGraphInWindowsNativeGui = (url, parentPid) => {
446
+ const powershell = resolveWindowsPowershellExecutable();
447
+ if (!powershell) {
448
+ return false;
449
+ }
450
+ try {
451
+ writeFileSync(nativeGuiPowershellScriptPath, nativeGuiPowershellScript, 'utf8');
452
+ }
453
+ catch {
454
+ return false;
455
+ }
456
+ return spawnDetached(powershell, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-File', nativeGuiPowershellScriptPath, url, String(parentPid)]);
457
+ };
458
+ const openGraphInLinuxNativeGui = (url, parentPid) => {
459
+ if (!commandExists('python3')) {
460
+ return false;
461
+ }
462
+ try {
463
+ writeFileSync(nativeGuiLinuxScriptPath, nativeGuiLinuxPythonScript, 'utf8');
464
+ }
465
+ catch {
466
+ return false;
467
+ }
468
+ return spawnDetached('python3', [nativeGuiLinuxScriptPath, url, String(parentPid)]);
469
+ };
470
+ const openGraphInNativeGui = (url, parentPid) => {
471
+ if (platform() === 'darwin') {
472
+ return openGraphInMacNativeGui(url, parentPid);
473
+ }
474
+ if (platform() === 'win32') {
475
+ return openGraphInWindowsNativeGui(url, parentPid);
476
+ }
477
+ return openGraphInLinuxNativeGui(url, parentPid);
478
+ };
479
+ const openGraphInAppWindow = (url) => {
480
+ if (platform() === 'darwin') {
481
+ const macCandidates = [
482
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
483
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
484
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'
485
+ ]
486
+ .filter((candidate) => existsSync(candidate))
487
+ .map((binary) => ({ binary, args: [`--app=${url}`, '--new-window'] }));
488
+ for (const candidate of macCandidates) {
489
+ if (spawnDetached(candidate.binary, candidate.args)) {
490
+ return true;
491
+ }
492
+ }
493
+ return false;
494
+ }
495
+ if (platform() === 'win32') {
496
+ const appArgument = `--app=${url}`;
497
+ return spawnAnyDetached([
498
+ ...windowsStartCandidates('msedge', [appArgument, '--new-window']),
499
+ ...windowsStartCandidates('chrome', [appArgument, '--new-window']),
500
+ ...windowsStartCandidates('chromium', [appArgument, '--new-window']),
501
+ ...windowsStartCandidates('brave', [appArgument, '--new-window'])
502
+ ]);
503
+ }
504
+ const appArgument = `--app=${url}`;
505
+ const linuxAppWindowEnabled = envFlagEnabled('BRAINLINK_LINUX_APP_WINDOW');
506
+ if (!linuxAppWindowEnabled) {
507
+ return false;
508
+ }
509
+ const linuxChromiumStableFlags = [
510
+ '--ozone-platform=x11',
511
+ '--ozone-platform-hint=x11',
512
+ '--disable-gpu',
513
+ '--disable-vulkan',
514
+ '--use-gl=swiftshader',
515
+ '--disable-features=Vulkan,VaapiVideoDecoder',
516
+ '--disable-background-networking'
517
+ ];
518
+ const linuxChromiumEnv = {
519
+ GDK_BACKEND: 'x11',
520
+ OZONE_PLATFORM: 'x11'
521
+ };
522
+ const linuxAppWindowCandidates = [
523
+ 'microsoft-edge',
524
+ 'microsoft-edge-stable',
525
+ 'google-chrome',
526
+ 'google-chrome-stable',
527
+ 'chromium',
528
+ 'chromium-browser',
529
+ 'brave-browser'
530
+ ].filter((candidate) => commandExists(candidate));
531
+ return spawnAnyDetachedWithEnv(linuxAppWindowCandidates.map((command) => [
532
+ command,
533
+ [...linuxChromiumStableFlags, appArgument, '--new-window'],
534
+ linuxChromiumEnv
535
+ ]));
536
+ };
537
+ const openGraphInDetectedBrowser = (url) => {
538
+ if (platform() === 'win32') {
539
+ return spawnAnyDetached([
540
+ ...windowsStartCandidates('msedge', [url]),
541
+ ...windowsStartCandidates('chrome', [url]),
542
+ ...windowsStartCandidates('firefox', ['-new-window', url]),
543
+ ...windowsStartCandidates('chromium', [url]),
544
+ ...windowsStartCandidates('brave', [url])
545
+ ]);
546
+ }
547
+ const linuxChromiumStableFlags = [
548
+ '--ozone-platform=x11',
549
+ '--ozone-platform-hint=x11',
550
+ '--disable-gpu',
551
+ '--disable-vulkan',
552
+ '--use-gl=swiftshader',
553
+ '--disable-features=Vulkan,VaapiVideoDecoder',
554
+ '--disable-background-networking'
555
+ ];
556
+ const linuxChromiumEnv = {
557
+ GDK_BACKEND: 'x11',
558
+ OZONE_PLATFORM: 'x11'
559
+ };
560
+ const envBrowserCandidates = readBrowserEnvCommands()
561
+ .map((command) => command.includes('firefox')
562
+ ? [command, ['-new-window', url], undefined]
563
+ : [command, [url], undefined])
564
+ .filter(([command]) => commandExists(command));
565
+ if (envBrowserCandidates.length > 0 && spawnAnyDetachedWithEnv(envBrowserCandidates)) {
566
+ return true;
567
+ }
568
+ const linuxBrowserCandidates = [
569
+ ['firefox', ['-new-window', url], undefined],
570
+ ['microsoft-edge', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
571
+ ['microsoft-edge-stable', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
572
+ ['google-chrome', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
573
+ ['google-chrome-stable', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
574
+ ['brave-browser', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
575
+ ['chromium', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
576
+ ['chromium-browser', [...linuxChromiumStableFlags, url], linuxChromiumEnv]
577
+ ];
578
+ const available = prioritizeLinuxBrowserCandidates(linuxBrowserCandidates.filter(([command]) => commandExists(command)));
579
+ return spawnAnyDetachedWithEnv(available);
580
+ };
581
+ const openUrlInUi = (url, parentPid) => {
582
+ const openDisabled = process.env.BRAINLINK_NO_BROWSER === '1' ||
583
+ process.env.BRAINLINK_NO_BROWSER === 'true' ||
584
+ process.env.CI === 'true';
585
+ if (openDisabled) {
586
+ return { opened: false, mode: 'none' };
587
+ }
588
+ const currentPlatform = platform();
589
+ const nativeGuiEnabled = !envFlagEnabled('BRAINLINK_NO_NATIVE_GUI') &&
590
+ (currentPlatform !== 'linux' || envFlagEnabled('BRAINLINK_LINUX_NATIVE_GUI') || envFlagEnabled('BRAINLINK_FORCE_NATIVE_GUI'));
591
+ if (nativeGuiEnabled && openGraphInNativeGui(url, parentPid)) {
592
+ return { opened: true, mode: 'native-gui' };
593
+ }
594
+ if (platform() === 'linux') {
595
+ if (spawnDetached('xdg-open', [url])) {
596
+ return { opened: true, mode: 'browser' };
597
+ }
598
+ if (openGraphInDetectedBrowser(url)) {
599
+ return { opened: true, mode: 'browser' };
600
+ }
601
+ if (openGraphInAppWindow(url)) {
602
+ return { opened: true, mode: 'app-window' };
603
+ }
604
+ return { opened: false, mode: 'none' };
605
+ }
606
+ if (openGraphInAppWindow(url)) {
607
+ return { opened: true, mode: 'app-window' };
608
+ }
609
+ try {
610
+ if (platform() === 'darwin') {
611
+ return { opened: spawnDetached('open', [url]), mode: 'browser' };
612
+ }
613
+ if (openGraphInDetectedBrowser(url)) {
614
+ return { opened: true, mode: 'browser' };
615
+ }
616
+ if (platform() === 'win32') {
617
+ return { opened: spawnDetached('cmd', ['/c', 'start', '', url]), mode: 'browser' };
618
+ }
619
+ return { opened: spawnDetached('xdg-open', [url]), mode: 'browser' };
620
+ }
621
+ catch {
622
+ return { opened: false, mode: 'none' };
623
+ }
624
+ };
21
625
  export const registerWriteCommands = (program) => {
22
626
  program
23
627
  .command('init')
@@ -43,6 +647,88 @@ export const registerWriteCommands = (program) => {
43
647
  return `Initialized Brainlink vault at ${path}.${migrated}`;
44
648
  });
45
649
  });
650
+ program
651
+ .command('migrate-vault')
652
+ .option('--from <vault>', 'source vault path')
653
+ .option('--to <vault>', 'target vault path')
654
+ .option('--dry-run', 'preview migration without writing files')
655
+ .option('--report <path>', 'write detailed per-file migration report to JSON file')
656
+ .option('--no-index', 'skip reindexing target vault after migration')
657
+ .option('--json', 'print machine-readable JSON')
658
+ .description('copy markdown memory from one vault to another with conflict preservation')
659
+ .action(async (options) => {
660
+ const config = await loadBrainlinkConfig();
661
+ const sourceVault = assertVaultAllowed(options.from ?? config.vault, config.allowedVaults);
662
+ const targetVault = assertVaultAllowed(options.to ?? defaultBrainlinkConfig.vault, config.allowedVaults);
663
+ const sourceRoot = await ensureVault(sourceVault);
664
+ const targetRoot = await ensureVault(targetVault);
665
+ const preview = await previewVaultMigration(sourceVault, targetVault);
666
+ const actions = await planVaultMigration(sourceRoot, targetRoot);
667
+ const reportEntries = actions.map((action) => ({
668
+ kind: action.kind,
669
+ sourcePath: action.sourcePath,
670
+ sourceRelativePath: relative(sourceRoot, action.sourcePath),
671
+ targetPath: action.targetPath,
672
+ targetRelativePath: relative(targetRoot, action.targetPath)
673
+ }));
674
+ const writeReport = async () => {
675
+ if (!options.report) {
676
+ return null;
677
+ }
678
+ const reportPath = resolve(options.report);
679
+ await mkdir(dirname(reportPath), { recursive: true });
680
+ await writeFile(reportPath, `${JSON.stringify({ source: sourceVault, target: targetVault, summary: preview, entries: reportEntries }, null, 2)}\n`, 'utf8');
681
+ return reportPath;
682
+ };
683
+ if (options.dryRun) {
684
+ const reportPath = await writeReport();
685
+ 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}` : ''}`);
686
+ return;
687
+ }
688
+ const migration = await migrateVaultContent(sourceVault, targetVault);
689
+ const shouldIndex = options.index !== false && migration.copied + migration.conflicted > 0;
690
+ const index = shouldIndex ? await indexVault(targetVault) : undefined;
691
+ const reportPath = await writeReport();
692
+ print(options.json, { dryRun: false, ...migration, entries: reportEntries, ...(index ? { index } : {}), ...(reportPath ? { reportPath } : {}) }, () => {
693
+ const summary = `Migrated ${migration.copied} files, preserved ${migration.conflicted} conflicts and kept ${migration.unchanged} unchanged files.`;
694
+ const indexMessage = index
695
+ ? ` Indexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} links.`
696
+ : '';
697
+ const reportMessage = reportPath ? ` Report written to ${reportPath}.` : '';
698
+ return `${summary}${indexMessage}${reportMessage}`;
699
+ });
700
+ });
701
+ program
702
+ .command('db-import')
703
+ .option('-v, --vault <vault>', 'vault directory')
704
+ .option('--db <path>', 'legacy SQLite database path (default: <vault>/.brainlink/brainlink.db)')
705
+ .option('--table <name>', 'legacy table name override')
706
+ .option('-a, --agent <agent>', 'force imported notes into a target agent namespace')
707
+ .option('-l, --limit <limit>', 'maximum number of rows to import')
708
+ .option('--dry-run', 'preview import without writing Markdown files')
709
+ .option('--no-index', 'skip reindexing after import')
710
+ .option('--json', 'print machine-readable JSON')
711
+ .description('import legacy SQLite memory into Markdown vault and current index model')
712
+ .action(async (options) => {
713
+ const resolved = await resolveOptions(options);
714
+ const result = await importLegacySqliteDatabase(resolved.vault, {
715
+ dbPath: options.db,
716
+ table: options.table,
717
+ agentOverride: options.agent ? resolved.agent : undefined,
718
+ limit: options.limit ? parsePositiveInteger(options.limit, 100_000) : undefined,
719
+ dryRun: Boolean(options.dryRun)
720
+ });
721
+ const shouldIndex = options.index !== false && !result.dryRun && result.imported > 0;
722
+ const index = shouldIndex ? await indexVault(resolved.vault) : undefined;
723
+ print(options.json, { ...result, ...(index ? { index } : {}) }, () => {
724
+ const summary = `Imported ${result.imported}/${result.rowsRead} rows from ${result.table} (skipped ${result.skipped}).`;
725
+ const indexMessage = index
726
+ ? ` Indexed ${index.documentCount} documents, ${index.chunkCount} chunks and ${index.linkCount} links.`
727
+ : '';
728
+ const dryRunMessage = result.dryRun ? ' Dry run only; no files were written.' : '';
729
+ return `${summary}${indexMessage}${dryRunMessage}`;
730
+ });
731
+ });
46
732
  program
47
733
  .command('add')
48
734
  .argument('<title>', 'note title')
@@ -57,12 +743,95 @@ export const registerWriteCommands = (program) => {
57
743
  .action(async (title, options) => {
58
744
  const resolved = await resolveOptions(options);
59
745
  const content = resolveAddContent(options);
60
- const notePath = await addNote(resolved.vault, title, content, resolved.agent, {
746
+ const added = await addNoteWithMetadata(resolved.vault, title, content, resolved.agent, {
61
747
  allowSensitive: Boolean(options.allowSensitive)
62
748
  });
63
749
  const shouldAutoIndex = options.autoIndex !== false && resolved.config.autoIndexOnWrite;
64
750
  const index = shouldAutoIndex ? await indexVault(resolved.vault) : undefined;
65
- print(options.json, { title, agent: resolved.agent ?? 'shared', path: notePath, ...(index ? { index } : {}) }, () => `Created note at ${notePath}`);
751
+ const absoluteVaultPath = await ensureVault(resolved.vault);
752
+ const focusPath = added.path.startsWith(absoluteVaultPath)
753
+ ? relative(absoluteVaultPath, added.path).replaceAll('\\', '/')
754
+ : added.path.includes('agents/')
755
+ ? added.path.slice(added.path.indexOf('agents/')).replaceAll('\\', '/')
756
+ : undefined;
757
+ const possibleDuplicates = await scanDuplicateNotes(resolved.vault, {
758
+ agentId: resolved.agent,
759
+ focusPath,
760
+ limit: 5,
761
+ minSemanticScore: 0.92,
762
+ includeSemantic: true
763
+ });
764
+ print(options.json, {
765
+ title,
766
+ agent: resolved.agent ?? 'shared',
767
+ path: added.path,
768
+ writeConnectivity: {
769
+ autoLinked: added.autoLinked,
770
+ linkTarget: added.linkTarget,
771
+ guaranteedEdge: true
772
+ },
773
+ possibleDuplicates,
774
+ ...(index ? { index } : {})
775
+ }, () => {
776
+ const duplicateMessage = possibleDuplicates.length > 0
777
+ ? `\nPotential duplicates: ${possibleDuplicates.length}. Use "blink dedupe --json" or "blink dedupe-resolve".`
778
+ : '';
779
+ return `Created note at ${added.path}${duplicateMessage}`;
780
+ });
781
+ });
782
+ program
783
+ .command('dedupe')
784
+ .option('-v, --vault <vault>', 'vault directory')
785
+ .option('-a, --agent <agent>', 'agent memory namespace')
786
+ .option('-l, --limit <limit>', 'maximum duplicate candidate pairs')
787
+ .option('--min-score <score>', 'minimum semantic similarity score between 0 and 1', '0.92')
788
+ .option('--no-semantic', 'disable semantic duplicate detection and keep exact-content matching only')
789
+ .option('--json', 'print machine-readable JSON')
790
+ .description('detect possible duplicate notes with exact hash and semantic similarity scores')
791
+ .action(async (options) => {
792
+ const resolved = await resolveOptions(options);
793
+ const duplicates = await scanDuplicateNotes(resolved.vault, {
794
+ agentId: resolved.agent,
795
+ limit: parsePositiveInteger(options.limit ?? '25', 25),
796
+ minSemanticScore: parseScore(options.minScore, 0.92),
797
+ includeSemantic: options.semantic !== false
798
+ });
799
+ print(options.json, { vault: resolved.vault, agent: resolved.agent, duplicates }, () => {
800
+ if (duplicates.length === 0) {
801
+ return 'No possible duplicates found.';
802
+ }
803
+ return duplicates
804
+ .map((item, index) => `${index + 1}. [${item.kind}] score=${item.score.toFixed(4)} ${item.left.path} <-> ${item.right.path} (${item.reason})`)
805
+ .join('\n');
806
+ });
807
+ });
808
+ program
809
+ .command('dedupe-resolve')
810
+ .option('-v, --vault <vault>', 'vault directory')
811
+ .option('--left <path>', 'left note relative path from dedupe result')
812
+ .option('--right <path>', 'right note relative path from dedupe result')
813
+ .option('--action <action>', 'resolution action: merge, link or ignore')
814
+ .option('--no-auto-index', 'skip reindex after duplicate resolution')
815
+ .option('--json', 'print machine-readable JSON')
816
+ .description('resolve a duplicate candidate with merge, link or ignore')
817
+ .action(async (options) => {
818
+ const resolved = await resolveOptions(options);
819
+ if (!options.left || !options.right) {
820
+ throw new Error('Use --left <path> and --right <path> to resolve a duplicate pair.');
821
+ }
822
+ if (options.action !== 'merge' && options.action !== 'link' && options.action !== 'ignore') {
823
+ throw new Error('Use --action merge|link|ignore.');
824
+ }
825
+ const result = await resolveDuplicateNotes(resolved.vault, {
826
+ leftPath: options.left,
827
+ rightPath: options.right,
828
+ action: options.action,
829
+ autoIndex: options.autoIndex !== false
830
+ });
831
+ print(options.json, {
832
+ vault: resolved.vault,
833
+ ...result
834
+ }, () => `Resolved duplicate (${result.action}) for ${result.leftPath} <-> ${result.rightPath}`);
66
835
  });
67
836
  program
68
837
  .command('index')
@@ -74,6 +843,63 @@ export const registerWriteCommands = (program) => {
74
843
  const result = await indexVault(resolved.vault);
75
844
  print(options.json, result, () => `Indexed ${result.documentCount} documents, ${result.chunkCount} chunks and ${result.linkCount} links`);
76
845
  });
846
+ program
847
+ .command('bench')
848
+ .option('-v, --vault <vault>', 'vault directory')
849
+ .option('-w, --watch', 'watch markdown changes and re-run benchmark in realtime')
850
+ .option('--debounce <ms>', 'watch debounce in milliseconds', '350')
851
+ .option('--json', 'print machine-readable JSON events')
852
+ .description('benchmark indexing in realtime, including compressed pack behavior')
853
+ .action(async (options) => {
854
+ const resolved = await resolveOptions(options);
855
+ const config = await loadBrainlinkConfig();
856
+ const emitProgress = (event) => {
857
+ printBenchRealtimeEvent(options.json, event);
858
+ };
859
+ const printBenchError = (error) => {
860
+ const message = error instanceof Error ? error.message : String(error);
861
+ print(options.json, { event: 'bench-error', message }, () => `[bench] error ${message}`);
862
+ };
863
+ const runAndPrint = async (trigger) => {
864
+ const baseline = await readBenchHistory(resolved.vault);
865
+ const result = await indexVaultWithOptions(resolved.vault, {
866
+ onProgress: emitProgress
867
+ });
868
+ printBenchSummary(options.json, trigger, resolved.vault, result);
869
+ const guardrails = evaluateBenchGuardrails(config, result, baseline);
870
+ printBenchGuardrails(options.json, resolved.vault, config, guardrails);
871
+ await writeBenchHistory(resolved.vault, result);
872
+ return result;
873
+ };
874
+ if (!options.watch) {
875
+ await runAndPrint('manual');
876
+ return;
877
+ }
878
+ const debounceMs = parsePositiveInteger(options.debounce ?? '350', 350);
879
+ await runAndPrint('manual');
880
+ print(options.json, {
881
+ event: 'bench-watching',
882
+ vault: resolved.vault,
883
+ debounceMs
884
+ }, () => `[bench] watching ${resolved.vault} (debounce=${debounceMs}ms)`);
885
+ const watcher = startVaultWatcher({
886
+ vaultPath: resolved.vault,
887
+ debounceMs,
888
+ onProgress: emitProgress,
889
+ onIndex: (result) => {
890
+ printBenchSummary(options.json, 'watch', resolved.vault, result);
891
+ },
892
+ onError: printBenchError
893
+ });
894
+ await new Promise((resolveSignal) => {
895
+ const shutdown = () => {
896
+ watcher.close();
897
+ resolveSignal();
898
+ };
899
+ process.once('SIGINT', shutdown);
900
+ process.once('SIGTERM', shutdown);
901
+ });
902
+ });
77
903
  program
78
904
  .command('doctor')
79
905
  .option('-v, --vault <vault>', 'vault directory')
@@ -82,9 +908,39 @@ export const registerWriteCommands = (program) => {
82
908
  .action(async (options) => {
83
909
  const resolved = await resolveOptions(options);
84
910
  const report = await doctorVault(resolved.vault);
85
- print(options.json, report, () => report.checks.map((check) => `${check.ok ? 'OK' : 'FAIL'} ${check.name}: ${check.message}`).join('\n'));
911
+ print(options.json, report, () => {
912
+ const checks = report.checks.map((check) => `${check.ok ? 'OK' : 'FAIL'} ${check.name}: ${check.message}`).join('\n');
913
+ const recommendations = report.recommendations && report.recommendations.length > 0
914
+ ? `\n\nRecommended next steps:\n${report.recommendations.map((step) => `- ${step}`).join('\n')}`
915
+ : '';
916
+ return `${checks}${recommendations}`;
917
+ });
86
918
  process.exitCode = report.ok ? 0 : 1;
87
919
  });
920
+ program
921
+ .command('pack-backup')
922
+ .option('-v, --vault <vault>', 'vault directory')
923
+ .option('-o, --output <path>', 'output file path (.blpkbak.gz)')
924
+ .option('--json', 'print machine-readable JSON')
925
+ .description('create offline backup with second-stage compression for encrypted search packs')
926
+ .action(async (options) => {
927
+ const resolved = await resolveOptions(options);
928
+ const outputPath = options.output?.trim().length
929
+ ? resolve(options.output)
930
+ : join(resolved.vault, '.brainlink', 'backups', `search-packs-${new Date().toISOString().replace(/[:.]/g, '-')}.blpkbak.gz`);
931
+ const backup = await createOfflinePackBackup({
932
+ vaultPath: resolved.vault,
933
+ outputPath
934
+ });
935
+ print(options.json, {
936
+ vault: resolved.vault,
937
+ backup
938
+ }, () => [
939
+ `Offline backup created: ${backup.outputPath}`,
940
+ `files=${backup.fileCount}`,
941
+ `input=${formatBytes(backup.inputBytes)} output=${formatBytes(backup.outputBytes)} saved=${((1 - backup.ratio) * 100).toFixed(2)}%`
942
+ ].join('\n'));
943
+ });
88
944
  program
89
945
  .command('watch')
90
946
  .option('-v, --vault <vault>', 'vault directory')
@@ -119,6 +975,7 @@ export const registerWriteCommands = (program) => {
119
975
  .option('-h, --host <host>', 'server host', '127.0.0.1')
120
976
  .option('-p, --port <port>', 'server port', '4321')
121
977
  .option('--no-index', 'skip indexing before starting the server')
978
+ .option('--no-open', 'do not open the graph UI automatically')
122
979
  .option('-w, --watch', 'watch markdown files and reindex on changes')
123
980
  .option('--json', 'print machine-readable JSON')
124
981
  .description('start a local web UI for the knowledge graph')
@@ -131,6 +988,112 @@ export const registerWriteCommands = (program) => {
131
988
  shouldIndex: options.index,
132
989
  shouldWatch: Boolean(options.watch)
133
990
  });
134
- print(options.json, { url: server.url, watch: Boolean(options.watch), readonly: true }, () => `Brainlink graph server running at ${server.url}`);
991
+ const openResult = options.open !== false ? openUrlInUi(server.url, process.pid) : { opened: false, mode: 'none' };
992
+ print(options.json, {
993
+ url: server.url,
994
+ watch: Boolean(options.watch),
995
+ readonly: true,
996
+ openedUi: openResult.opened,
997
+ openMode: openResult.mode
998
+ }, () => `Brainlink graph server running at ${server.url}${openResult.opened
999
+ ? openResult.mode === 'native-gui'
1000
+ ? ' (opened in native desktop GUI)'
1001
+ : openResult.mode === 'app-window'
1002
+ ? ' (opened in dedicated app window)'
1003
+ : ' (opened in browser)'
1004
+ : options.open === false
1005
+ ? ' (auto-open disabled)'
1006
+ : ''}`);
1007
+ });
1008
+ program
1009
+ .command('quickstart')
1010
+ .option('-v, --vault <vault>', 'vault directory')
1011
+ .option('-a, --agent <agent>', 'agent memory namespace')
1012
+ .option('--query <query>', 'optional task query to return immediate grounded context')
1013
+ .option('--mode <mode>', 'search mode for context (fts|semantic|hybrid)')
1014
+ .option('--limit <limit>', 'maximum context sections')
1015
+ .option('--tokens <tokens>', 'maximum context token budget')
1016
+ .option('--no-install-agent', 'skip agent MCP/plugin installation and upgrade automation')
1017
+ .option('--mcp-only', 'when installing agent integration, only configure MCP section')
1018
+ .option('--plugin-path <path>', 'custom source path for Brainlink plugin files')
1019
+ .option('--allowed-vaults <paths>', 'comma separated vault allowlist to inject in MCP env')
1020
+ .option('--brainlink-home <path>', 'BRAINLINK_HOME value to inject in MCP env')
1021
+ .option('--json', 'print machine-readable JSON')
1022
+ .description('run plug-and-play setup for vault, MCP integration and bootstrap readiness')
1023
+ .action(async (options) => {
1024
+ const resolved = await resolveOptions(options);
1025
+ const limit = parsePositiveInteger(options.limit ?? String(resolved.defaults.defaultSearchLimit), resolved.defaults.defaultSearchLimit);
1026
+ const tokens = parsePositiveInteger(options.tokens ?? String(resolved.defaults.defaultContextTokens), resolved.defaults.defaultContextTokens);
1027
+ const mode = sanitizeSearchMode(options.mode, resolved.defaults.defaultSearchMode);
1028
+ const index = await indexVault(resolved.vault);
1029
+ const stats = await getStats(resolved.vault, resolved.agent);
1030
+ const validation = await validateVault(resolved.vault, resolved.agent);
1031
+ const doctor = await doctorVault(resolved.vault);
1032
+ const session = await touchBootstrapSession(resolved.vault, resolved.agent);
1033
+ const policy = await getBootstrapPolicy();
1034
+ const bootstrapStatus = await getBootstrapSessionStatus(resolved.vault, resolved.agent);
1035
+ const context = options.query
1036
+ ? await buildContextPackage(resolved.vault, options.query, limit, tokens, resolved.agent, mode)
1037
+ : null;
1038
+ const agentIntegration = options.installAgent === false
1039
+ ? null
1040
+ : await installAgentIntegration({
1041
+ mcpOnly: options.mcpOnly,
1042
+ pluginPath: options.pluginPath,
1043
+ allowedVaults: options.allowedVaults,
1044
+ brainlinkHome: options.brainlinkHome,
1045
+ selfTest: true
1046
+ });
1047
+ const nextActions = stats.documentCount === 0
1048
+ ? [
1049
+ {
1050
+ priority: 'required',
1051
+ command: `blink add "Architecture" --vault "${resolved.vault}" --content "Durable memory with [[Links]] and #tags."`,
1052
+ reason: 'Seed your vault with at least one durable Markdown note.'
1053
+ },
1054
+ {
1055
+ priority: 'required',
1056
+ command: `blink index --vault "${resolved.vault}"`,
1057
+ reason: 'Rebuild index after adding notes so retrieval can find new memory.'
1058
+ }
1059
+ ]
1060
+ : options.query
1061
+ ? [
1062
+ {
1063
+ priority: 'recommended',
1064
+ command: `blink add "Task Update" --vault "${resolved.vault}" --agent "${resolved.agent ?? 'shared'}" --content "<durable memory>"`,
1065
+ reason: 'Persist important findings as Markdown notes after using the returned context.'
1066
+ }
1067
+ ]
1068
+ : [
1069
+ {
1070
+ priority: 'recommended',
1071
+ command: `blink context "<task>" --vault "${resolved.vault}" --agent "${resolved.agent ?? 'shared'}" --mode ${mode}`,
1072
+ reason: 'Retrieve grounded context for each task before responding.'
1073
+ }
1074
+ ];
1075
+ print(options.json, {
1076
+ vault: resolved.vault,
1077
+ agent: resolved.agent ?? 'shared',
1078
+ mode,
1079
+ index,
1080
+ stats,
1081
+ validation,
1082
+ doctor,
1083
+ policy,
1084
+ bootstrapStatus,
1085
+ session,
1086
+ context,
1087
+ agentIntegration,
1088
+ nextActions
1089
+ }, () => [
1090
+ `quickstart vault=${resolved.vault}`,
1091
+ `agent=${resolved.agent ?? 'shared'}`,
1092
+ `documents=${stats.documentCount}`,
1093
+ `links=${stats.linkCount}`,
1094
+ `bootstrapReady=${bootstrapStatus.ready}`,
1095
+ ...(agentIntegration?.selfTest ? [`agentIntegrationSelfTest=${agentIntegration.selfTest.ok}`] : []),
1096
+ ...(nextActions.length > 0 ? ['Next actions:', ...nextActions.map((step) => `- ${step.command}`)] : [])
1097
+ ].join('\n'));
135
1098
  });
136
1099
  };