@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 +2 -0
- package/COPYRIGHT.md +1 -1
- package/README.md +4 -1
- package/dist/application/build-context.js +56 -1
- package/dist/application/frontend/client-html.js +2 -2
- package/dist/application/frontend/client-js.js +41 -2
- package/dist/cli/commands/write-commands.js +46 -8
- package/dist/infrastructure/file-index.js +37 -3
- package/docs/AGENT_USAGE.md +1 -0
- package/package.json +2 -2
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
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
|
|
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
|
-
|
|
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="
|
|
50
|
-
<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
|
-
|
|
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 (
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
16
|
-
|
|
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')
|
package/docs/AGENT_USAGE.md
CHANGED
|
@@ -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.
|
|
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": "
|
|
7
|
+
"author": "Substructa",
|
|
8
8
|
"homepage": "https://github.com/andersonflima/brainlink#readme",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|