@andespindola/brainlink 0.1.0-beta.9 → 0.1.0-beta.90

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 (53) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +26 -2
  3. package/CONTRIBUTING.md +2 -2
  4. package/COPYRIGHT.md +5 -0
  5. package/README.md +146 -17
  6. package/SECURITY.md +1 -1
  7. package/dist/application/analyze-vault.js +7 -7
  8. package/dist/application/build-context.js +56 -1
  9. package/dist/application/dedupe-notes.js +226 -0
  10. package/dist/application/frontend/client-css.js +154 -102
  11. package/dist/application/frontend/client-html.js +49 -40
  12. package/dist/application/frontend/client-js.js +3130 -166
  13. package/dist/application/frontend/client-worker-js.js +66 -0
  14. package/dist/application/get-graph-layout.js +18 -6
  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 +252 -19
  20. package/dist/application/list-agents.js +3 -3
  21. package/dist/application/list-links.js +5 -5
  22. package/dist/application/offline-pack-backup.js +44 -0
  23. package/dist/application/search-graph-node-ids.js +12 -0
  24. package/dist/application/search-knowledge.js +25 -10
  25. package/dist/application/server/routes.js +102 -1
  26. package/dist/application/start-server.js +75 -4
  27. package/dist/application/watch-vault.js +23 -2
  28. package/dist/benchmarks/large-vault.js +1 -1
  29. package/dist/cli/commands/agent-commands.js +20 -3
  30. package/dist/cli/commands/write-commands.js +818 -8
  31. package/dist/domain/context.js +53 -11
  32. package/dist/domain/embeddings.js +2 -1
  33. package/dist/domain/graph-layout.js +67 -16
  34. package/dist/domain/middle-out.js +18 -0
  35. package/dist/infrastructure/config.js +38 -0
  36. package/dist/infrastructure/file-index.js +358 -0
  37. package/dist/infrastructure/file-system-vault.js +15 -0
  38. package/dist/infrastructure/index-state.js +56 -0
  39. package/dist/infrastructure/private-pack-codec.js +134 -0
  40. package/dist/infrastructure/search-packs.js +452 -0
  41. package/dist/infrastructure/session-state.js +57 -2
  42. package/dist/mcp/server.js +11 -1
  43. package/dist/mcp/tools.js +215 -3
  44. package/docs/AGENT_USAGE.md +103 -16
  45. package/docs/ARCHITECTURE.md +25 -26
  46. package/docs/QUICKSTART.md +9 -1
  47. package/package.json +6 -4
  48. package/dist/infrastructure/sqlite/document-writer.js +0 -51
  49. package/dist/infrastructure/sqlite/graph-reader.js +0 -120
  50. package/dist/infrastructure/sqlite/schema.js +0 -111
  51. package/dist/infrastructure/sqlite/search-reader.js +0 -156
  52. package/dist/infrastructure/sqlite/types.js +0 -1
  53. package/dist/infrastructure/sqlite-index.js +0 -25
@@ -1,10 +1,15 @@
1
- import { readFileSync } from 'node:fs';
2
- import { mkdir, writeFile } from 'node:fs/promises';
3
- import { dirname, relative, resolve } from 'node:path';
4
- import { addNote } from '../../application/add-note.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';
5
7
  import { buildContextPackage } from '../../application/build-context.js';
6
- import { indexVault } from '../../application/index-vault.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';
7
11
  import { migrateVaultContent, planVaultMigration, previewVaultMigration, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
12
+ import { createOfflinePackBackup } from '../../application/offline-pack-backup.js';
8
13
  import { startServer } from '../../application/start-server.js';
9
14
  import { startVaultWatcher } from '../../application/watch-vault.js';
10
15
  import { doctorVault, getStats, validateVault } from '../../application/analyze-vault.js';
@@ -23,6 +28,600 @@ const resolveAddContent = (options) => {
23
28
  }
24
29
  return readFileSync(options.contentFile, 'utf8');
25
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
+ };
26
625
  export const registerWriteCommands = (program) => {
27
626
  program
28
627
  .command('init')
@@ -99,6 +698,37 @@ export const registerWriteCommands = (program) => {
99
698
  return `${summary}${indexMessage}${reportMessage}`;
100
699
  });
101
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
+ });
102
732
  program
103
733
  .command('add')
104
734
  .argument('<title>', 'note title')
@@ -113,12 +743,95 @@ export const registerWriteCommands = (program) => {
113
743
  .action(async (title, options) => {
114
744
  const resolved = await resolveOptions(options);
115
745
  const content = resolveAddContent(options);
116
- const notePath = await addNote(resolved.vault, title, content, resolved.agent, {
746
+ const added = await addNoteWithMetadata(resolved.vault, title, content, resolved.agent, {
117
747
  allowSensitive: Boolean(options.allowSensitive)
118
748
  });
119
749
  const shouldAutoIndex = options.autoIndex !== false && resolved.config.autoIndexOnWrite;
120
750
  const index = shouldAutoIndex ? await indexVault(resolved.vault) : undefined;
121
- 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}`);
122
835
  });
123
836
  program
124
837
  .command('index')
@@ -130,6 +843,63 @@ export const registerWriteCommands = (program) => {
130
843
  const result = await indexVault(resolved.vault);
131
844
  print(options.json, result, () => `Indexed ${result.documentCount} documents, ${result.chunkCount} chunks and ${result.linkCount} links`);
132
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
+ });
133
903
  program
134
904
  .command('doctor')
135
905
  .option('-v, --vault <vault>', 'vault directory')
@@ -147,6 +917,30 @@ export const registerWriteCommands = (program) => {
147
917
  });
148
918
  process.exitCode = report.ok ? 0 : 1;
149
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
+ });
150
944
  program
151
945
  .command('watch')
152
946
  .option('-v, --vault <vault>', 'vault directory')
@@ -181,6 +975,7 @@ export const registerWriteCommands = (program) => {
181
975
  .option('-h, --host <host>', 'server host', '127.0.0.1')
182
976
  .option('-p, --port <port>', 'server port', '4321')
183
977
  .option('--no-index', 'skip indexing before starting the server')
978
+ .option('--no-open', 'do not open the graph UI automatically')
184
979
  .option('-w, --watch', 'watch markdown files and reindex on changes')
185
980
  .option('--json', 'print machine-readable JSON')
186
981
  .description('start a local web UI for the knowledge graph')
@@ -193,7 +988,22 @@ export const registerWriteCommands = (program) => {
193
988
  shouldIndex: options.index,
194
989
  shouldWatch: Boolean(options.watch)
195
990
  });
196
- 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
+ : ''}`);
197
1007
  });
198
1008
  program
199
1009
  .command('quickstart')