@andespindola/brainlink 0.1.0-beta.35 → 0.1.0-beta.37

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.
package/CHANGELOG.md CHANGED
@@ -27,6 +27,9 @@
27
27
  - Improved graph UI auto-fit and viewport recovery so loaded nodes are re-centered when zoom/pan drifts to empty canvas.
28
28
  - Added cross-platform native desktop GUI auto-open for `blink server` (macOS Swift/WebKit, Windows PowerShell WinForms, Linux Python GTK/WebKit2), with app-window/browser fallback.
29
29
  - Changed Linux default UI launch to app-window/browser for lighter startup; Linux native GUI is now opt-in via `BRAINLINK_LINUX_NATIVE_GUI=1`.
30
+ - Added native GUI parent-process monitoring so GUI windows close automatically when `blink server` stops.
31
+ - Improved non-mac browser detection fallback to try installed Edge/Chrome/Firefox/Chromium candidates before system default open.
32
+ - Improved graph filter rendering to keep hub anchor nodes visible (`Memory Hub`/`MOC`/high-degree fallback) for coherent relationship context.
30
33
 
31
34
  ## 0.1.0-beta.3
32
35
 
package/COPYRIGHT.md CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2026 Anderson Espindola
1
+ Copyright (c) 2026 Substructa
2
2
 
3
3
  This project is licensed under the MIT License.
4
4
 
package/README.md CHANGED
@@ -561,6 +561,7 @@ By default, `blink server` tries to open the graph in a native desktop GUI windo
561
561
  On Linux, native GUI is disabled by default for better startup performance. Enable it with `BRAINLINK_LINUX_NATIVE_GUI=1`.
562
562
  If native GUI launch is unavailable on your system, it falls back to dedicated app-window mode and then to the default browser.
563
563
  Use `--no-open` to keep it headless.
564
+ When native GUI is used, the GUI window automatically closes when the `blink server` process stops.
564
565
 
565
566
  The graph UI shows:
566
567
 
@@ -570,6 +571,7 @@ The graph UI shows:
570
571
  - neutral graph nodes with segment/group metadata
571
572
  - agent selector (id-only labels) for isolated views
572
573
  - graph filter matches title, path, tags and note content
574
+ - graph filter keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) to preserve relationship readability
573
575
  - realtime refresh while `--watch` is enabled
574
576
  - graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
575
577
  - wheel zoom (including `cmd+scroll` and `ctrl+scroll`) anchored to cursor position for faster navigation in large graphs
@@ -1054,7 +1056,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
1054
1056
  ## License
1055
1057
 
1056
1058
  MIT. See [LICENSE](LICENSE).
1057
- Copyright (c) 2026 Anderson Espindola. See [COPYRIGHT.md](COPYRIGHT.md).
1059
+ Copyright (c) 2026 Substructa. See [COPYRIGHT.md](COPYRIGHT.md).
1058
1060
 
1059
1061
  ### Memory Optimization Loop (1-7)
1060
1062
 
@@ -46,8 +46,8 @@ export const createClientHtml = () => `<!doctype html>
46
46
  <canvas id="graph" aria-label="Brainlink knowledge graph"></canvas>
47
47
  </section>
48
48
  </main>
49
- <footer class="app-footer" aria-label="License notice">
50
- <small>MIT License · Copyright © 2026 Anderson Espindola</small>
49
+ <footer class="app-footer" aria-label="Copyright notice">
50
+ <small>Copyright © 2026 Substructa</small>
51
51
  </footer>
52
52
  <dialog id="contentDialog" class="content-dialog" aria-labelledby="contentTitle">
53
53
  <article>
@@ -99,6 +99,8 @@ const resize = () => {
99
99
  }
100
100
 
101
101
  const normalizeQuery = value => value.trim().toLowerCase()
102
+ const hubNodeRetentionLimit = 2
103
+ const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
102
104
 
103
105
  const localFilteredNodes = query =>
104
106
  state.nodes.filter(node =>
@@ -107,14 +109,51 @@ const localFilteredNodes = query =>
107
109
  node.tags.some(tag => tag.toLowerCase().includes(query))
108
110
  )
109
111
 
112
+ const rankedHubNodes = () => {
113
+ if (state.nodes.length === 0) {
114
+ return []
115
+ }
116
+
117
+ const byTitleAndDegree = [...state.nodes]
118
+ .filter(node => hubNodePattern.test(node.title) || hubNodePattern.test(node.path) || node.tags.some(tag => hubNodePattern.test(tag)))
119
+ .sort((left, right) => {
120
+ const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
121
+ if (byDegree !== 0) return byDegree
122
+ return left.title.localeCompare(right.title)
123
+ })
124
+
125
+ if (byTitleAndDegree.length > 0) {
126
+ return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
127
+ }
128
+
129
+ return [...state.nodes]
130
+ .sort((left, right) => {
131
+ const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
132
+ if (byDegree !== 0) return byDegree
133
+ return left.title.localeCompare(right.title)
134
+ })
135
+ .slice(0, 1)
136
+ }
137
+
138
+ const withPersistentHubNodes = nodes => {
139
+ if (nodes.length === 0) {
140
+ return rankedHubNodes()
141
+ }
142
+
143
+ const ids = new Set(nodes.map(node => node.id))
144
+ const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
145
+ return nodes.concat(hubsToKeep)
146
+ }
147
+
110
148
  const filteredNodes = () => {
111
149
  const query = normalizeQuery(state.query)
112
150
  if (!query) return state.nodes
113
151
  if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
114
- return state.nodes.filter(node => state.contentFilter.ids.has(node.id))
152
+ const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
153
+ return withPersistentHubNodes(matched)
115
154
  }
116
155
 
117
- return localFilteredNodes(query)
156
+ return withPersistentHubNodes(localFilteredNodes(query))
118
157
  }
119
158
 
120
159
  const recomputeVisibility = () => {
@@ -42,14 +42,18 @@ const nativeGuiLinuxScriptPath = join(tmpdir(), 'brainlink-native-gui-linux.py')
42
42
  const nativeGuiSwiftScript = `import Foundation
43
43
  import AppKit
44
44
  import WebKit
45
+ import Darwin
45
46
 
46
47
  final class BrainlinkAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
47
48
  private let targetUrl: URL
49
+ private let parentPid: Int32
48
50
  private var window: NSWindow?
49
51
  private var webView: WKWebView?
52
+ private var monitorTimer: Timer?
50
53
 
51
- init(targetUrl: URL) {
54
+ init(targetUrl: URL, parentPid: Int32) {
52
55
  self.targetUrl = targetUrl
56
+ self.parentPid = parentPid
53
57
  }
54
58
 
55
59
  func applicationDidFinishLaunching(_ notification: Notification) {
@@ -73,16 +77,27 @@ final class BrainlinkAppDelegate: NSObject, NSApplicationDelegate, NSWindowDeleg
73
77
  self.window = window
74
78
  self.webView = webView
75
79
 
80
+ if parentPid > 0 {
81
+ monitorTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
82
+ if kill(self.parentPid, 0) != 0 {
83
+ NSApp.terminate(nil)
84
+ }
85
+ }
86
+ }
87
+
76
88
  window.makeKeyAndOrderFront(nil)
77
89
  NSApp.activate(ignoringOtherApps: true)
78
90
  }
79
91
 
80
92
  func windowWillClose(_ notification: Notification) {
93
+ monitorTimer?.invalidate()
81
94
  NSApp.terminate(nil)
82
95
  }
83
96
  }
84
97
 
85
- let rawTarget = CommandLine.arguments.dropFirst().first ?? "http://127.0.0.1:4321"
98
+ let args = Array(CommandLine.arguments.dropFirst())
99
+ let rawTarget = args.indices.contains(0) ? args[0] : "http://127.0.0.1:4321"
100
+ let parentPid: Int32 = args.indices.contains(1) ? (Int32(args[1]) ?? 0) : 0
86
101
 
87
102
  guard let targetUrl = URL(string: rawTarget) else {
88
103
  fputs("Invalid URL for Brainlink GUI: \\(rawTarget)\\n", stderr)
@@ -91,12 +106,13 @@ guard let targetUrl = URL(string: rawTarget) else {
91
106
 
92
107
  let app = NSApplication.shared
93
108
  app.setActivationPolicy(.regular)
94
- let delegate = BrainlinkAppDelegate(targetUrl: targetUrl)
109
+ let delegate = BrainlinkAppDelegate(targetUrl: targetUrl, parentPid: parentPid)
95
110
  app.delegate = delegate
96
111
  app.run()
97
112
  `;
98
113
  const nativeGuiPowershellScript = `param(
99
- [string]$TargetUrl = "http://127.0.0.1:4321"
114
+ [string]$TargetUrl = "http://127.0.0.1:4321",
115
+ [int]$ParentPid = 0
100
116
  )
101
117
 
102
118
  Add-Type -AssemblyName System.Windows.Forms
@@ -115,6 +131,23 @@ $browser.ScriptErrorsSuppressed = $true
115
131
  $browser.Navigate($TargetUrl)
116
132
 
117
133
  $form.Controls.Add($browser)
134
+ $timer = New-Object System.Windows.Forms.Timer
135
+ $timer.Interval = 1000
136
+ $timer.Add_Tick({
137
+ if ($ParentPid -le 0) {
138
+ return
139
+ }
140
+ try {
141
+ Get-Process -Id $ParentPid -ErrorAction Stop | Out-Null
142
+ } catch {
143
+ $timer.Stop()
144
+ $form.Close()
145
+ }
146
+ })
147
+ $form.Add_FormClosed({
148
+ $timer.Stop()
149
+ })
150
+ $timer.Start()
118
151
  [void]$form.ShowDialog()
119
152
  `;
120
153
  const nativeGuiLinuxPythonScript = `#!/usr/bin/env python3
@@ -128,11 +161,12 @@ def run() -> int:
128
161
  gi.require_version("WebKit2", "4.1")
129
162
  except ValueError:
130
163
  gi.require_version("WebKit2", "4.0")
131
- from gi.repository import Gtk, WebKit2
164
+ from gi.repository import Gtk, WebKit2, GLib
132
165
  except Exception:
133
166
  return 1
134
167
 
135
168
  target_url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:4321"
169
+ parent_pid = int(sys.argv[2]) if len(sys.argv) > 2 else 0
136
170
 
137
171
  window = Gtk.Window(title="Brainlink Graph")
138
172
  window.set_default_size(1320, 860)
@@ -142,6 +176,19 @@ def run() -> int:
142
176
  webview.load_uri(target_url)
143
177
  window.add(webview)
144
178
  window.show_all()
179
+
180
+ if parent_pid > 0:
181
+ def _watch_parent() -> bool:
182
+ try:
183
+ import os
184
+ os.kill(parent_pid, 0)
185
+ except Exception:
186
+ Gtk.main_quit()
187
+ return False
188
+ return True
189
+
190
+ GLib.timeout_add(1000, _watch_parent)
191
+
145
192
  Gtk.main()
146
193
  return 0
147
194
 
@@ -160,6 +207,10 @@ const commandExists = (command) => {
160
207
  }
161
208
  };
162
209
  const envFlagEnabled = (name) => process.env[name] === '1' || process.env[name] === 'true';
210
+ const spawnAnyDetached = (candidates) => candidates.some(([command, args]) => spawnDetached(command, args));
211
+ const windowsStartCandidates = (program, args = []) => [
212
+ ['cmd', ['/c', 'start', '', program, ...args]]
213
+ ];
163
214
  const resolveSwiftExecutable = () => {
164
215
  const directSwift = '/usr/bin/swift';
165
216
  if (existsSync(directSwift)) {
@@ -174,7 +225,7 @@ const resolveSwiftExecutable = () => {
174
225
  return null;
175
226
  }
176
227
  };
177
- const openGraphInMacNativeGui = (url) => {
228
+ const openGraphInMacNativeGui = (url, parentPid) => {
178
229
  const swiftBinary = resolveSwiftExecutable();
179
230
  if (!swiftBinary) {
180
231
  return false;
@@ -185,7 +236,7 @@ const openGraphInMacNativeGui = (url) => {
185
236
  catch {
186
237
  return false;
187
238
  }
188
- return spawnDetached(swiftBinary, [nativeGuiSwiftScriptPath, url]);
239
+ return spawnDetached(swiftBinary, [nativeGuiSwiftScriptPath, url, String(parentPid)]);
189
240
  };
190
241
  const resolveWindowsPowershellExecutable = () => {
191
242
  if (commandExists('powershell')) {
@@ -196,7 +247,7 @@ const resolveWindowsPowershellExecutable = () => {
196
247
  }
197
248
  return null;
198
249
  };
199
- const openGraphInWindowsNativeGui = (url) => {
250
+ const openGraphInWindowsNativeGui = (url, parentPid) => {
200
251
  const powershell = resolveWindowsPowershellExecutable();
201
252
  if (!powershell) {
202
253
  return false;
@@ -207,9 +258,9 @@ const openGraphInWindowsNativeGui = (url) => {
207
258
  catch {
208
259
  return false;
209
260
  }
210
- return spawnDetached(powershell, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-File', nativeGuiPowershellScriptPath, url]);
261
+ return spawnDetached(powershell, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-File', nativeGuiPowershellScriptPath, url, String(parentPid)]);
211
262
  };
212
- const openGraphInLinuxNativeGui = (url) => {
263
+ const openGraphInLinuxNativeGui = (url, parentPid) => {
213
264
  if (!commandExists('python3')) {
214
265
  return false;
215
266
  }
@@ -219,16 +270,16 @@ const openGraphInLinuxNativeGui = (url) => {
219
270
  catch {
220
271
  return false;
221
272
  }
222
- return spawnDetached('python3', [nativeGuiLinuxScriptPath, url]);
273
+ return spawnDetached('python3', [nativeGuiLinuxScriptPath, url, String(parentPid)]);
223
274
  };
224
- const openGraphInNativeGui = (url) => {
275
+ const openGraphInNativeGui = (url, parentPid) => {
225
276
  if (platform() === 'darwin') {
226
- return openGraphInMacNativeGui(url);
277
+ return openGraphInMacNativeGui(url, parentPid);
227
278
  }
228
279
  if (platform() === 'win32') {
229
- return openGraphInWindowsNativeGui(url);
280
+ return openGraphInWindowsNativeGui(url, parentPid);
230
281
  }
231
- return openGraphInLinuxNativeGui(url);
282
+ return openGraphInLinuxNativeGui(url, parentPid);
232
283
  };
233
284
  const openGraphInAppWindow = (url) => {
234
285
  if (platform() === 'darwin') {
@@ -248,18 +299,49 @@ const openGraphInAppWindow = (url) => {
248
299
  }
249
300
  if (platform() === 'win32') {
250
301
  const appArgument = `--app=${url}`;
251
- return (spawnDetached('cmd', ['/c', 'start', '', 'chrome', appArgument, '--new-window']) ||
252
- spawnDetached('cmd', ['/c', 'start', '', 'msedge', appArgument, '--new-window']) ||
253
- spawnDetached('cmd', ['/c', 'start', '', 'chromium', appArgument, '--new-window']));
302
+ return spawnAnyDetached([
303
+ ...windowsStartCandidates('msedge', [appArgument, '--new-window']),
304
+ ...windowsStartCandidates('chrome', [appArgument, '--new-window']),
305
+ ...windowsStartCandidates('chromium', [appArgument, '--new-window']),
306
+ ...windowsStartCandidates('brave', [appArgument, '--new-window'])
307
+ ]);
254
308
  }
255
309
  const appArgument = `--app=${url}`;
256
- return (spawnDetached('google-chrome', [appArgument, '--new-window']) ||
257
- spawnDetached('chromium-browser', [appArgument, '--new-window']) ||
258
- spawnDetached('chromium', [appArgument, '--new-window']) ||
259
- spawnDetached('microsoft-edge', [appArgument, '--new-window']) ||
260
- spawnDetached('microsoft-edge-stable', [appArgument, '--new-window']));
310
+ const linuxAppWindowCandidates = [
311
+ 'microsoft-edge',
312
+ 'microsoft-edge-stable',
313
+ 'google-chrome',
314
+ 'google-chrome-stable',
315
+ 'chromium',
316
+ 'chromium-browser',
317
+ 'brave-browser'
318
+ ].filter((candidate) => commandExists(candidate));
319
+ return spawnAnyDetached(linuxAppWindowCandidates.map((command) => [command, [appArgument, '--new-window']]));
320
+ };
321
+ const openGraphInDetectedBrowser = (url) => {
322
+ if (platform() === 'win32') {
323
+ return spawnAnyDetached([
324
+ ...windowsStartCandidates('msedge', [url]),
325
+ ...windowsStartCandidates('chrome', [url]),
326
+ ...windowsStartCandidates('firefox', ['-new-window', url]),
327
+ ...windowsStartCandidates('chromium', [url]),
328
+ ...windowsStartCandidates('brave', [url])
329
+ ]);
330
+ }
331
+ const linuxBrowserCandidates = [
332
+ ['microsoft-edge', [url]],
333
+ ['microsoft-edge-stable', [url]],
334
+ ['google-chrome', [url]],
335
+ ['google-chrome-stable', [url]],
336
+ ['chromium', [url]],
337
+ ['chromium-browser', [url]],
338
+ ['brave-browser', [url]],
339
+ ['firefox', ['-new-window', url]]
340
+ ];
341
+ const available = linuxBrowserCandidates.filter(([command]) => commandExists(command));
342
+ return spawnAnyDetached(available);
261
343
  };
262
- const openUrlInUi = (url) => {
344
+ const openUrlInUi = (url, parentPid) => {
263
345
  const openDisabled = process.env.BRAINLINK_NO_BROWSER === '1' ||
264
346
  process.env.BRAINLINK_NO_BROWSER === 'true' ||
265
347
  process.env.CI === 'true';
@@ -269,7 +351,7 @@ const openUrlInUi = (url) => {
269
351
  const currentPlatform = platform();
270
352
  const nativeGuiEnabled = !envFlagEnabled('BRAINLINK_NO_NATIVE_GUI') &&
271
353
  (currentPlatform !== 'linux' || envFlagEnabled('BRAINLINK_LINUX_NATIVE_GUI') || envFlagEnabled('BRAINLINK_FORCE_NATIVE_GUI'));
272
- if (nativeGuiEnabled && openGraphInNativeGui(url)) {
354
+ if (nativeGuiEnabled && openGraphInNativeGui(url, parentPid)) {
273
355
  return { opened: true, mode: 'native-gui' };
274
356
  }
275
357
  if (openGraphInAppWindow(url)) {
@@ -279,6 +361,9 @@ const openUrlInUi = (url) => {
279
361
  if (platform() === 'darwin') {
280
362
  return { opened: spawnDetached('open', [url]), mode: 'browser' };
281
363
  }
364
+ if (openGraphInDetectedBrowser(url)) {
365
+ return { opened: true, mode: 'browser' };
366
+ }
282
367
  if (platform() === 'win32') {
283
368
  return { opened: spawnDetached('cmd', ['/c', 'start', '', url]), mode: 'browser' };
284
369
  }
@@ -490,7 +575,7 @@ export const registerWriteCommands = (program) => {
490
575
  shouldIndex: options.index,
491
576
  shouldWatch: Boolean(options.watch)
492
577
  });
493
- const openResult = options.open !== false ? openUrlInUi(server.url) : { opened: false, mode: 'none' };
578
+ const openResult = options.open !== false ? openUrlInUi(server.url, process.pid) : { opened: false, mode: 'none' };
494
579
  print(options.json, {
495
580
  url: server.url,
496
581
  watch: Boolean(options.watch),
@@ -548,12 +548,14 @@ By default it tries to open the graph in a native desktop GUI window:
548
548
  On Linux, native GUI is disabled by default for better startup performance. Enable it with `BRAINLINK_LINUX_NATIVE_GUI=1`.
549
549
  If native GUI launch is unavailable, it falls back to dedicated app-window mode and then to the default browser.
550
550
  Use `--no-open` to keep the server headless.
551
+ When native GUI is active, the GUI window closes automatically when the `blink server` process stops.
551
552
 
552
553
  Without `--vault`, the graph UI serves `$HOME/.brainlink/vault`.
553
554
 
554
555
  The frontend includes an agent selector that shows only the agent id. Selecting an agent calls the same read APIs with `agent=<agent-id>` and renders that namespace instead of merging every agent into one graph.
555
556
 
556
557
  Graph navigation controls include zoom in, zoom out, fit visible nodes and reset-to-fit-all nodes. Mouse wheel zoom (including `cmd+scroll` and `ctrl+scroll`) is anchored to the cursor. Keyboard shortcuts are `+` (zoom in), `-` (zoom out) and `0` (reset fit). Double-click on canvas zooms in at cursor position. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open on click in a modal (tags, outgoing links, backlinks and Markdown content).
558
+ During graph filtering, Brainlink keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) so filtered views still show relationship anchors.
557
559
 
558
560
  The command reindexes by default, then serves:
559
561
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.35",
3
+ "version": "0.1.0-beta.37",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "author": "Anderson Espindola",
7
+ "author": "Substructa",
8
8
  "homepage": "https://github.com/andersonflima/brainlink#readme",
9
9
  "repository": {
10
10
  "type": "git",