@andespindola/brainlink 0.1.0-beta.16 → 0.1.0-beta.160

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