@andespindola/brainlink 0.1.0-beta.36 → 0.1.0-beta.38

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
@@ -28,6 +28,8 @@
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
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.
31
33
 
32
34
  ## 0.1.0-beta.3
33
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
@@ -69,6 +69,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
69
69
  - Backlinks, broken-link reports, orphan detection and validation.
70
70
  - Full-text, semantic and hybrid retrieval on a local file index.
71
71
  - Middle-out context assembly around the strongest chunk per document.
72
+ - In-process index and context caching with automatic invalidation on index updates.
72
73
  - Compressed-space prefiltering for `.blpk` packs before decryption and scan.
73
74
  - Agent namespaces under `agents/<agent-id>/`.
74
75
  - S3-compatible bucket vaults through `s3://bucket/prefix` URIs.
@@ -571,6 +572,7 @@ The graph UI shows:
571
572
  - neutral graph nodes with segment/group metadata
572
573
  - agent selector (id-only labels) for isolated views
573
574
  - graph filter matches title, path, tags and note content
575
+ - graph filter keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) to preserve relationship readability
574
576
  - realtime refresh while `--watch` is enabled
575
577
  - graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
576
578
  - wheel zoom (including `cmd+scroll` and `ctrl+scroll`) anchored to cursor position for faster navigation in large graphs
@@ -763,6 +765,7 @@ blink context "question" --vault ./vault --agent coding-agent --mode hybrid --js
763
765
  ```
764
766
 
765
767
  Builds a compact context package for an agent.
768
+ Repeated calls with the same vault, agent, query, mode and token/limit settings are served from a short in-memory cache while the index is unchanged.
766
769
 
767
770
  ### `links`
768
771
 
@@ -1055,7 +1058,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
1055
1058
  ## License
1056
1059
 
1057
1060
  MIT. See [LICENSE](LICENSE).
1058
- Copyright (c) 2026 Anderson Espindola. See [COPYRIGHT.md](COPYRIGHT.md).
1061
+ Copyright (c) 2026 Substructa. See [COPYRIGHT.md](COPYRIGHT.md).
1059
1062
 
1060
1063
  ### Memory Optimization Loop (1-7)
1061
1064
 
@@ -1,13 +1,68 @@
1
+ import { stat } from 'node:fs/promises';
1
2
  import { formatContextPackage, selectContextSections } from '../domain/context.js';
3
+ import { indexStoragePath } from '../infrastructure/file-index.js';
2
4
  import { searchKnowledge } from './search-knowledge.js';
5
+ const contextCacheTtlMs = 45_000;
6
+ const contextCacheMaxEntries = 200;
7
+ const contextCache = new Map();
8
+ const readIndexMtimeMs = async (vaultPath) => {
9
+ try {
10
+ return (await stat(indexStoragePath(vaultPath))).mtimeMs;
11
+ }
12
+ catch {
13
+ return 0;
14
+ }
15
+ };
16
+ const toCacheKey = (vaultPath, query, limit, maxTokens, agentId, mode) => JSON.stringify({
17
+ vaultPath,
18
+ query: query.trim().toLowerCase(),
19
+ limit,
20
+ maxTokens,
21
+ agentId: agentId?.trim().toLowerCase() ?? '*',
22
+ mode: mode ?? 'default'
23
+ });
24
+ const contextCacheGet = (key, indexMtimeMs) => {
25
+ const entry = contextCache.get(key);
26
+ if (!entry) {
27
+ return undefined;
28
+ }
29
+ const fresh = Date.now() - entry.createdAt <= contextCacheTtlMs && entry.indexMtimeMs === indexMtimeMs;
30
+ if (!fresh) {
31
+ contextCache.delete(key);
32
+ return undefined;
33
+ }
34
+ return entry.context;
35
+ };
36
+ const contextCacheSet = (entry) => {
37
+ contextCache.set(entry.key, entry);
38
+ if (contextCache.size <= contextCacheMaxEntries) {
39
+ return;
40
+ }
41
+ const overflow = contextCache.size - contextCacheMaxEntries;
42
+ const keys = Array.from(contextCache.keys()).slice(0, overflow);
43
+ keys.forEach((key) => contextCache.delete(key));
44
+ };
3
45
  export const buildContextPackage = async (vaultPath, query, limit, maxTokens, agentId, mode) => {
46
+ const cacheKey = toCacheKey(vaultPath, query, limit, maxTokens, agentId, mode);
47
+ const indexMtimeMs = await readIndexMtimeMs(vaultPath);
48
+ const cached = contextCacheGet(cacheKey, indexMtimeMs);
49
+ if (cached) {
50
+ return cached;
51
+ }
4
52
  const results = await searchKnowledge(vaultPath, query, limit, agentId, mode);
5
53
  const sections = selectContextSections(results, maxTokens);
6
- return {
54
+ const context = {
7
55
  query,
8
56
  sections,
9
57
  content: formatContextPackage(query, sections)
10
58
  };
59
+ contextCacheSet({
60
+ key: cacheKey,
61
+ createdAt: Date.now(),
62
+ indexMtimeMs,
63
+ context
64
+ });
65
+ return context;
11
66
  };
12
67
  export const buildContext = async (vaultPath, query, limit, maxTokens, agentId, mode) => {
13
68
  const contextPackage = await buildContextPackage(vaultPath, query, limit, maxTokens, agentId, mode);
@@ -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 = () => {
@@ -207,6 +207,10 @@ const commandExists = (command) => {
207
207
  }
208
208
  };
209
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
+ ];
210
214
  const resolveSwiftExecutable = () => {
211
215
  const directSwift = '/usr/bin/swift';
212
216
  if (existsSync(directSwift)) {
@@ -295,16 +299,47 @@ const openGraphInAppWindow = (url) => {
295
299
  }
296
300
  if (platform() === 'win32') {
297
301
  const appArgument = `--app=${url}`;
298
- return (spawnDetached('cmd', ['/c', 'start', '', 'chrome', appArgument, '--new-window']) ||
299
- spawnDetached('cmd', ['/c', 'start', '', 'msedge', appArgument, '--new-window']) ||
300
- 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
+ ]);
301
308
  }
302
309
  const appArgument = `--app=${url}`;
303
- return (spawnDetached('google-chrome', [appArgument, '--new-window']) ||
304
- spawnDetached('chromium-browser', [appArgument, '--new-window']) ||
305
- spawnDetached('chromium', [appArgument, '--new-window']) ||
306
- spawnDetached('microsoft-edge', [appArgument, '--new-window']) ||
307
- 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);
308
343
  };
309
344
  const openUrlInUi = (url, parentPid) => {
310
345
  const openDisabled = process.env.BRAINLINK_NO_BROWSER === '1' ||
@@ -326,6 +361,9 @@ const openUrlInUi = (url, parentPid) => {
326
361
  if (platform() === 'darwin') {
327
362
  return { opened: spawnDetached('open', [url]), mode: 'browser' };
328
363
  }
364
+ if (openGraphInDetectedBrowser(url)) {
365
+ return { opened: true, mode: 'browser' };
366
+ }
329
367
  if (platform() === 'win32') {
330
368
  return { opened: spawnDetached('cmd', ['/c', 'start', '', url]), mode: 'browser' };
331
369
  }
@@ -1,7 +1,9 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
1
+ import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { cosineSimilarity } from '../domain/embeddings.js';
4
4
  const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
5
+ const indexCacheMaxEntries = 16;
6
+ const indexCache = new Map();
5
7
  const emptyIndex = () => ({
6
8
  version: 1,
7
9
  updatedAt: new Date().toISOString(),
@@ -11,18 +13,44 @@ const emptyIndex = () => ({
11
13
  });
12
14
  export const indexStoragePath = (vaultPath) => join(vaultPath, '.brainlink', 'index.json');
13
15
  const readIndex = async (vaultPath) => {
16
+ const path = indexStoragePath(vaultPath);
17
+ let stats = null;
14
18
  try {
15
- const parsed = JSON.parse(await readFile(indexStoragePath(vaultPath), 'utf8'));
16
- return {
19
+ const fileStats = await stat(path);
20
+ stats = { mtimeMs: fileStats.mtimeMs, size: fileStats.size };
21
+ }
22
+ catch (error) {
23
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
24
+ indexCache.delete(path);
25
+ return emptyIndex();
26
+ }
27
+ return emptyIndex();
28
+ }
29
+ const cached = indexCache.get(path);
30
+ if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
31
+ return cached.index;
32
+ }
33
+ try {
34
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
35
+ const loaded = {
17
36
  version: 1,
18
37
  updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
19
38
  documents: Array.isArray(parsed.documents) ? parsed.documents : [],
20
39
  chunks: Array.isArray(parsed.chunks) ? parsed.chunks : [],
21
40
  links: Array.isArray(parsed.links) ? parsed.links : []
22
41
  };
42
+ indexCache.set(path, { ...stats, index: loaded });
43
+ if (indexCache.size > indexCacheMaxEntries) {
44
+ const oldest = indexCache.keys().next().value;
45
+ if (typeof oldest === 'string') {
46
+ indexCache.delete(oldest);
47
+ }
48
+ }
49
+ return loaded;
23
50
  }
24
51
  catch (error) {
25
52
  if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
53
+ indexCache.delete(path);
26
54
  return emptyIndex();
27
55
  }
28
56
  return emptyIndex();
@@ -34,6 +62,12 @@ const writeIndex = async (vaultPath, index) => {
34
62
  await mkdir(dirname(target), { recursive: true, mode: 0o700 });
35
63
  await writeFile(temp, `${JSON.stringify(index)}\n`, { encoding: 'utf8', mode: 0o600 });
36
64
  await rename(temp, target);
65
+ const fileStats = await stat(target);
66
+ indexCache.set(target, {
67
+ mtimeMs: fileStats.mtimeMs,
68
+ size: fileStats.size,
69
+ index
70
+ });
37
71
  };
38
72
  const normalizeToken = (value) => value
39
73
  .normalize('NFKD')
@@ -555,6 +555,7 @@ Without `--vault`, the graph UI serves `$HOME/.brainlink/vault`.
555
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.
556
556
 
557
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.
558
559
 
559
560
  The command reindexes by default, then serves:
560
561
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.36",
3
+ "version": "0.1.0-beta.38",
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",