@andespindola/brainlink 0.1.0-beta.140 → 0.1.0-beta.142
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/README.md +5 -4
- package/dist/application/auto-migrate-configured-vault.js +37 -0
- package/dist/application/frontend/client-js.js +16 -21
- package/dist/cli/runtime.js +10 -2
- package/dist/infrastructure/config.js +79 -4
- package/dist/infrastructure/vault-migration-state.js +69 -0
- package/docs/AGENT_USAGE.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
|
|
|
82
82
|
- Built-in MCP stdio server for agent tool integration.
|
|
83
83
|
- Local HTTP API.
|
|
84
84
|
- Realtime graph UI with agent selector and colored knowledge groups.
|
|
85
|
-
- Graph renderer keeps the full filtered graph visible during zoom/pan, rendering every visible node and edge without viewport culling.
|
|
85
|
+
- Graph renderer keeps the full filtered graph visible during zoom/pan, rendering every visible node and edge without viewport culling or edge caps in the main view.
|
|
86
86
|
- Canvas graph rendering uses the same batched node and edge pipeline for every graph size, reducing per-frame draw calls while keeping selected and hovered items highlighted.
|
|
87
87
|
- WebGL acceleration is used when available for dense node and edge drawing, with Canvas 2D preserved as the interaction and fallback layer.
|
|
88
88
|
- Large graph layout API automatically uses compact payload encoding with link-coverage-aware edge selection to reduce initial client load without hiding major relationships.
|
|
@@ -91,7 +91,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
|
|
|
91
91
|
- Zoomed-out graph keeps the same flat graph scene and preserves complete filtered relationships without switching to nested subgraphs.
|
|
92
92
|
- Graph reset fits the full graph scene instead of starting in a separate macro overview mode.
|
|
93
93
|
- Graph filtering runs in a dedicated browser worker to keep the UI thread responsive during heavy datasets.
|
|
94
|
-
-
|
|
94
|
+
- Node titles are shown as the user zooms closer, while labels remain bounded to visible on-screen nodes in very large graphs.
|
|
95
95
|
|
|
96
96
|
## Install
|
|
97
97
|
|
|
@@ -603,10 +603,10 @@ The graph UI shows:
|
|
|
603
603
|
- keyboard shortcuts: `+` zoom in, `-` zoom out, `0` reset fit
|
|
604
604
|
- click on a node opens its details panel; double-click on empty canvas zooms in at cursor position
|
|
605
605
|
- floating graph totals (notes, links, tags) below the Brainlink title
|
|
606
|
-
- graph rendering safeguards (batched canvas drawing across graph sizes,
|
|
606
|
+
- graph rendering safeguards (batched canvas drawing across graph sizes, lower redraw rate, zoom-aware interaction)
|
|
607
607
|
- adaptive CPU safeguards for large graphs: idle frame pacing, throttled background physics updates and cached viewport dimensions to reduce redraw/layout overhead while preserving interaction responsiveness
|
|
608
608
|
- WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
|
|
609
|
-
- large graph view keeps a single-level graph model across zoom levels
|
|
609
|
+
- large graph view keeps a single-level graph model across zoom levels, renders the full filtered scene instead of viewport-sampled subsets, and shows node titles as zoom approaches readable scale
|
|
610
610
|
|
|
611
611
|
The server indexes before starting by default. Use `--no-index` to skip that step:
|
|
612
612
|
|
|
@@ -694,6 +694,7 @@ blink config set-vault "s3://my-memory-bucket/brainlink" --global
|
|
|
694
694
|
|
|
695
695
|
`config set-vault` writes configuration through CLI (no manual file edits required).
|
|
696
696
|
By default it writes local config (`./brainlink.config.json`), appends the vault to `allowedVaults`, and migrates Markdown memory from the current configured vault when the target is empty.
|
|
697
|
+
When the configured default vault is changed manually in config files, Brainlink also performs automatic migration on the next command that uses the configured vault (without explicit `--vault`).
|
|
697
698
|
Use `--global` to write to `$BRAINLINK_HOME/brainlink.config.json`, `--no-migrate` to skip migration, and `--no-index` to skip post-migration indexing.
|
|
698
699
|
`config doctor` is dry-run by default; use `--fix` to apply safe config normalization and allowlist fixes.
|
|
699
700
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { indexVault } from './index-vault.js';
|
|
2
|
+
import { migrateVaultContent } from './migrate-vault.js';
|
|
3
|
+
import { getLastConfiguredVaultForKey, setLastConfiguredVaultForKey } from '../infrastructure/vault-migration-state.js';
|
|
4
|
+
export const autoMigrateConfiguredVaultIfChanged = async (input) => {
|
|
5
|
+
const configKey = input.configKey.trim();
|
|
6
|
+
const configuredVault = input.configuredVault.trim();
|
|
7
|
+
if (configKey.length === 0 || configuredVault.length === 0) {
|
|
8
|
+
return {
|
|
9
|
+
changed: false,
|
|
10
|
+
migrated: false
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const previousVault = await getLastConfiguredVaultForKey(configKey);
|
|
14
|
+
if (!previousVault) {
|
|
15
|
+
await setLastConfiguredVaultForKey(configKey, configuredVault);
|
|
16
|
+
return {
|
|
17
|
+
changed: false,
|
|
18
|
+
migrated: false
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (previousVault === configuredVault) {
|
|
22
|
+
return {
|
|
23
|
+
changed: false,
|
|
24
|
+
migrated: false
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const migration = await migrateVaultContent(previousVault, configuredVault);
|
|
28
|
+
const shouldIndex = migration.copied + migration.conflicted > 0;
|
|
29
|
+
if (shouldIndex) {
|
|
30
|
+
await indexVault(configuredVault);
|
|
31
|
+
}
|
|
32
|
+
await setLastConfiguredVaultForKey(configKey, configuredVault);
|
|
33
|
+
return {
|
|
34
|
+
changed: true,
|
|
35
|
+
migrated: shouldIndex
|
|
36
|
+
};
|
|
37
|
+
};
|
|
@@ -3,7 +3,6 @@ const glCanvas = document.getElementById('graphGl')
|
|
|
3
3
|
const ctx = canvas.getContext('2d')
|
|
4
4
|
const largeGraphNodeThreshold = 4000
|
|
5
5
|
const massiveGraphNodeThreshold = 20000
|
|
6
|
-
const largeGraphEdgeRenderLimit = 120000
|
|
7
6
|
const renderNodeBudget = 1000
|
|
8
7
|
const zoomedMassiveRenderNodeBudget = 2200
|
|
9
8
|
const massiveOverviewRenderNodeBudget = 1800
|
|
@@ -539,16 +538,11 @@ const recomputeVisibility = () => {
|
|
|
539
538
|
const nodes = filteredNodes()
|
|
540
539
|
const ids = new Set(nodes.map(node => node.id))
|
|
541
540
|
const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
542
|
-
const limitedEdges = state.nodes.length > largeGraphNodeThreshold
|
|
543
|
-
? [...edges]
|
|
544
|
-
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
545
|
-
.slice(0, largeGraphEdgeRenderLimit)
|
|
546
|
-
: edges
|
|
547
541
|
|
|
548
542
|
state.visibleNodes = nodes
|
|
549
|
-
state.visibleEdges =
|
|
543
|
+
state.visibleEdges = edges
|
|
550
544
|
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
551
|
-
state.visibleEdgeByNode = createVisibleEdgeLookup(
|
|
545
|
+
state.visibleEdgeByNode = createVisibleEdgeLookup(edges)
|
|
552
546
|
const primaryHub = rankedHubNodes()[0] ?? null
|
|
553
547
|
state.primaryHub = primaryHub
|
|
554
548
|
markRenderDirty()
|
|
@@ -1007,7 +1001,7 @@ const drawGraphEdges = () => {
|
|
|
1007
1001
|
const shouldDrawNodeLabels = (node, isSelected, isHovered) =>
|
|
1008
1002
|
isSelected ||
|
|
1009
1003
|
isHovered ||
|
|
1010
|
-
(state.nodes.length > largeGraphNodeThreshold &&
|
|
1004
|
+
(state.nodes.length > largeGraphNodeThreshold && state.transform.scale >= 0.78 && isNodeVisibleOnScreen(node, state.viewport.width, state.viewport.height)) ||
|
|
1011
1005
|
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
1012
1006
|
|
|
1013
1007
|
const drawSingleNode = (node, options = { drawLabel: true }) => {
|
|
@@ -1157,24 +1151,35 @@ const partitionGraphForAcceleratedRenderer = () => {
|
|
|
1157
1151
|
|
|
1158
1152
|
const drawGraphLabels = nodes => {
|
|
1159
1153
|
const shouldDrawLabels = state.nodes.length > largeGraphNodeThreshold
|
|
1160
|
-
? state.transform.scale >=
|
|
1154
|
+
? state.transform.scale >= 0.78
|
|
1161
1155
|
: state.transform.scale >= 0.62 && state.renderNodes.length <= 1200
|
|
1162
1156
|
|
|
1163
1157
|
if (!shouldDrawLabels) {
|
|
1164
1158
|
return
|
|
1165
1159
|
}
|
|
1166
1160
|
|
|
1161
|
+
const maxLabels = state.nodes.length > largeGraphNodeThreshold
|
|
1162
|
+
? (state.transform.scale >= 1.5 ? 900 : state.transform.scale >= 1.05 ? 520 : 260)
|
|
1163
|
+
: state.renderNodes.length
|
|
1164
|
+
let drawnLabels = 0
|
|
1167
1165
|
ctx.fillStyle = graphTheme.label
|
|
1168
1166
|
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
1169
1167
|
ctx.textAlign = 'center'
|
|
1170
1168
|
ctx.textBaseline = 'top'
|
|
1171
1169
|
for (let index = 0; index < nodes.length; index += 1) {
|
|
1172
1170
|
const node = nodes[index]
|
|
1171
|
+
if (drawnLabels >= maxLabels) {
|
|
1172
|
+
break
|
|
1173
|
+
}
|
|
1174
|
+
if (state.nodes.length > largeGraphNodeThreshold && !isNodeVisibleOnScreen(node, state.viewport.width, state.viewport.height)) {
|
|
1175
|
+
continue
|
|
1176
|
+
}
|
|
1173
1177
|
const x = node.x
|
|
1174
1178
|
const y = node.y
|
|
1175
1179
|
const radius = nodeRadius(node)
|
|
1176
1180
|
ctx.globalAlpha = 1
|
|
1177
1181
|
ctx.fillText(node.title.slice(0, 34), x, y + radius + 8)
|
|
1182
|
+
drawnLabels += 1
|
|
1178
1183
|
}
|
|
1179
1184
|
ctx.globalAlpha = 1
|
|
1180
1185
|
}
|
|
@@ -2875,17 +2880,7 @@ const render = now => {
|
|
|
2875
2880
|
} else {
|
|
2876
2881
|
state.offscreenFrameCount = 0
|
|
2877
2882
|
}
|
|
2878
|
-
const
|
|
2879
|
-
state.nodes.length > massiveGraphNodeThreshold
|
|
2880
|
-
? 0
|
|
2881
|
-
: state.renderNodes.length > 1300
|
|
2882
|
-
? 0.12
|
|
2883
|
-
: state.renderNodes.length > 900
|
|
2884
|
-
? 0.085
|
|
2885
|
-
: state.renderNodes.length > 500
|
|
2886
|
-
? 0.05
|
|
2887
|
-
: 0
|
|
2888
|
-
const drawEdges = state.transform.scale >= minimumEdgeScale
|
|
2883
|
+
const drawEdges = true
|
|
2889
2884
|
if (drawAcceleratedGraph(width, height, drawEdges)) {
|
|
2890
2885
|
// WebGL handles the dense node/edge layer; the 2D canvas remains the interaction overlay.
|
|
2891
2886
|
} else {
|
package/dist/cli/runtime.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { autoMigrateConfiguredVaultIfChanged } from '../application/auto-migrate-configured-vault.js';
|
|
2
|
+
import { loadBrainlinkConfigWithSource, resolveAgentRuntimeDefaults } from '../infrastructure/config.js';
|
|
2
3
|
import { assertVaultAllowed } from '../infrastructure/file-system-vault.js';
|
|
3
4
|
export const parsePositiveInteger = (value, fallback) => {
|
|
4
5
|
const parsed = Number.parseInt(value, 10);
|
|
5
6
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
6
7
|
};
|
|
7
8
|
export const resolveOptions = async (options) => {
|
|
8
|
-
const config = await
|
|
9
|
+
const { config, vaultSource } = await loadBrainlinkConfigWithSource();
|
|
10
|
+
if (options.vault === undefined) {
|
|
11
|
+
const sourceKey = vaultSource.sourcePath ? `${vaultSource.source}:${vaultSource.sourcePath}` : vaultSource.source;
|
|
12
|
+
await autoMigrateConfiguredVaultIfChanged({
|
|
13
|
+
configKey: sourceKey,
|
|
14
|
+
configuredVault: config.vault
|
|
15
|
+
});
|
|
16
|
+
}
|
|
9
17
|
const vault = options.vault ?? config.vault;
|
|
10
18
|
const allowedVault = assertVaultAllowed(vault, config.allowedVaults);
|
|
11
19
|
const agent = options.agent ?? config.defaultAgent;
|
|
@@ -181,12 +181,87 @@ export const resolveAgentRuntimeDefaults = (config, agent) => {
|
|
|
181
181
|
defaultSearchMode: profile?.defaultSearchMode ?? config.defaultSearchMode
|
|
182
182
|
};
|
|
183
183
|
};
|
|
184
|
+
const mergeConfigLayers = (layers) => layers.reduce((state, config) => ({
|
|
185
|
+
...state,
|
|
186
|
+
...config
|
|
187
|
+
}), {});
|
|
188
|
+
export const getVaultConfigSourceDetails = async (cwd = safeCwd()) => {
|
|
189
|
+
const [globalConfig, localConfig, legacyLocalConfig] = await Promise.all([
|
|
190
|
+
loadRawConfig('global', cwd),
|
|
191
|
+
loadRawConfig('local', cwd),
|
|
192
|
+
loadLegacyLocalRawConfig(cwd)
|
|
193
|
+
]);
|
|
194
|
+
if (typeof legacyLocalConfig.vault === 'string' && legacyLocalConfig.vault.trim().length > 0) {
|
|
195
|
+
return {
|
|
196
|
+
source: 'local-legacy',
|
|
197
|
+
sourcePath: resolve(cwd, '.brainlink.json')
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (typeof localConfig.vault === 'string' && localConfig.vault.trim().length > 0) {
|
|
201
|
+
return {
|
|
202
|
+
source: 'local',
|
|
203
|
+
sourcePath: getLocalConfigPath(cwd)
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (typeof globalConfig.vault === 'string' && globalConfig.vault.trim().length > 0) {
|
|
207
|
+
return {
|
|
208
|
+
source: 'global',
|
|
209
|
+
sourcePath: getGlobalConfigPath()
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
source: 'default',
|
|
214
|
+
sourcePath: null
|
|
215
|
+
};
|
|
216
|
+
};
|
|
184
217
|
export const loadBrainlinkConfig = async (cwd = safeCwd()) => {
|
|
185
218
|
const globalConfig = await readJsonConfig(getGlobalConfigPath());
|
|
186
219
|
const localConfigs = await Promise.all(configFilenames.map((filename) => readJsonConfig(resolve(cwd, filename))));
|
|
187
|
-
const merged = [globalConfig, ...localConfigs]
|
|
188
|
-
...state,
|
|
189
|
-
...config
|
|
190
|
-
}), {});
|
|
220
|
+
const merged = mergeConfigLayers([globalConfig, ...localConfigs]);
|
|
191
221
|
return sanitizeConfig(merged);
|
|
192
222
|
};
|
|
223
|
+
export const loadBrainlinkConfigWithSource = async (cwd = safeCwd()) => {
|
|
224
|
+
const globalConfigPath = getGlobalConfigPath();
|
|
225
|
+
const localConfigPath = getLocalConfigPath(cwd);
|
|
226
|
+
const legacyLocalConfigPath = resolve(cwd, '.brainlink.json');
|
|
227
|
+
const [globalConfig, localConfig, legacyLocalConfig] = await Promise.all([
|
|
228
|
+
readJsonConfig(globalConfigPath),
|
|
229
|
+
readJsonConfig(localConfigPath),
|
|
230
|
+
readJsonConfig(legacyLocalConfigPath)
|
|
231
|
+
]);
|
|
232
|
+
const config = sanitizeConfig(mergeConfigLayers([globalConfig, localConfig, legacyLocalConfig]));
|
|
233
|
+
if (typeof legacyLocalConfig.vault === 'string' && legacyLocalConfig.vault.trim().length > 0) {
|
|
234
|
+
return {
|
|
235
|
+
config,
|
|
236
|
+
vaultSource: {
|
|
237
|
+
source: 'local-legacy',
|
|
238
|
+
sourcePath: legacyLocalConfigPath
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (typeof localConfig.vault === 'string' && localConfig.vault.trim().length > 0) {
|
|
243
|
+
return {
|
|
244
|
+
config,
|
|
245
|
+
vaultSource: {
|
|
246
|
+
source: 'local',
|
|
247
|
+
sourcePath: localConfigPath
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (typeof globalConfig.vault === 'string' && globalConfig.vault.trim().length > 0) {
|
|
252
|
+
return {
|
|
253
|
+
config,
|
|
254
|
+
vaultSource: {
|
|
255
|
+
source: 'global',
|
|
256
|
+
sourcePath: globalConfigPath
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
config,
|
|
262
|
+
vaultSource: {
|
|
263
|
+
source: 'default',
|
|
264
|
+
sourcePath: null
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { getBrainlinkHomePath } from './paths.js';
|
|
4
|
+
const defaultState = {
|
|
5
|
+
byConfigKey: {}
|
|
6
|
+
};
|
|
7
|
+
const statePath = () => join(getBrainlinkHomePath(), 'vault-migration-state.json');
|
|
8
|
+
const sanitizeState = (value) => {
|
|
9
|
+
if (typeof value !== 'object' || value === null) {
|
|
10
|
+
return defaultState;
|
|
11
|
+
}
|
|
12
|
+
const record = value;
|
|
13
|
+
const byConfigKeyRecord = typeof record.byConfigKey === 'object' && record.byConfigKey !== null ? record.byConfigKey : {};
|
|
14
|
+
const byConfigKey = Object.entries(byConfigKeyRecord).reduce((state, [key, vault]) => {
|
|
15
|
+
if (typeof key !== 'string' || key.trim().length === 0) {
|
|
16
|
+
return state;
|
|
17
|
+
}
|
|
18
|
+
if (typeof vault !== 'string' || vault.trim().length === 0) {
|
|
19
|
+
return state;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
...state,
|
|
23
|
+
[key]: vault.trim()
|
|
24
|
+
};
|
|
25
|
+
}, {});
|
|
26
|
+
return {
|
|
27
|
+
byConfigKey
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
const readState = async () => {
|
|
31
|
+
try {
|
|
32
|
+
const raw = await readFile(statePath(), 'utf8');
|
|
33
|
+
return sanitizeState(JSON.parse(raw));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
37
|
+
return defaultState;
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const writeState = async (state) => {
|
|
43
|
+
const path = statePath();
|
|
44
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
45
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
46
|
+
};
|
|
47
|
+
export const getVaultMigrationStatePath = () => statePath();
|
|
48
|
+
export const getLastConfiguredVaultForKey = async (configKey) => {
|
|
49
|
+
const state = await readState();
|
|
50
|
+
const value = state.byConfigKey[configKey];
|
|
51
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
52
|
+
};
|
|
53
|
+
export const setLastConfiguredVaultForKey = async (configKey, vault) => {
|
|
54
|
+
const key = configKey.trim();
|
|
55
|
+
const value = vault.trim();
|
|
56
|
+
if (key.length === 0 || value.length === 0) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const state = await readState();
|
|
60
|
+
if (state.byConfigKey[key] === value) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
await writeState({
|
|
64
|
+
byConfigKey: {
|
|
65
|
+
...state.byConfigKey,
|
|
66
|
+
[key]: value
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
};
|
package/docs/AGENT_USAGE.md
CHANGED
|
@@ -607,7 +607,7 @@ Without `--vault`, the graph UI serves `$HOME/.brainlink/vault`.
|
|
|
607
607
|
|
|
608
608
|
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.
|
|
609
609
|
|
|
610
|
-
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 and applied immediately without delayed focus interpolation. Keyboard shortcuts are `+` (zoom in), `-` (zoom out) and `0` (reset fit). Double-click on empty canvas zooms in at cursor position. Clicking a node opens its details panel. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open in a non-modal side panel (tags, outgoing links, backlinks and Markdown content), so zoom and pan remain available during inspection. Vaults above 1000 notes keep the same single graph scene and render the full filtered node/edge set during navigation, without viewport-sampled subgraph switching.
|
|
610
|
+
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 and applied immediately without delayed focus interpolation. Keyboard shortcuts are `+` (zoom in), `-` (zoom out) and `0` (reset fit). Double-click on empty canvas zooms in at cursor position. Clicking a node opens its details panel. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open in a non-modal side panel (tags, outgoing links, backlinks and Markdown content), so zoom and pan remain available during inspection. Vaults above 1000 notes keep the same single graph scene and render the full filtered node/edge set during navigation, without viewport-sampled subgraph switching. Node titles appear as zoom approaches readable scale, limited to on-screen nodes in very large graphs.
|
|
611
611
|
During graph filtering, Brainlink keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) so filtered views still show relationship anchors.
|
|
612
612
|
|
|
613
613
|
The command reindexes by default, then serves:
|
package/package.json
CHANGED