@andespindola/brainlink 0.1.0-beta.10 → 0.1.0-beta.11

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 CHANGED
@@ -58,6 +58,7 @@ LLMs do not have infinite context. Brainlink gives agents an external memory lay
58
58
 
59
59
  Markdown is the source of truth. `.brainlink/brainlink.db` is only a rebuildable index.
60
60
  Brainlink now keeps an automatic rollback snapshot at `.brainlink/brainlink.db.backup`. If the main SQLite file is corrupted, Brainlink automatically restores from snapshot (or recreates a clean index when no snapshot exists).
61
+ After each index run, Brainlink also writes compressed search packs at `.brainlink/search-packs/*.jsonl.gz`. If SQLite is unavailable, search falls back to these packs automatically.
61
62
 
62
63
  ## Features
63
64
 
@@ -563,6 +564,7 @@ The graph UI shows:
563
564
  - graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
564
565
  - wheel zoom anchored to cursor position for faster navigation in large graphs
565
566
  - floating graph totals (notes, links, tags) below the Brainlink title
567
+ - large-graph rendering safeguards (edge draw caps, lower redraw rate, zoom-aware interaction)
566
568
 
567
569
  The server indexes before starting by default. Use `--no-index` to skip that step:
568
570
 
@@ -1,9 +1,14 @@
1
1
  export const createClientJs = () => `const canvas = document.getElementById('graph')
2
2
  const ctx = canvas.getContext('2d')
3
+ const largeGraphNodeThreshold = 4000
4
+ const largeGraphEdgeRenderLimit = 16000
3
5
  const state = {
4
6
  graph: { nodes: [], edges: [] },
5
7
  nodes: [],
6
8
  edges: [],
9
+ visibleNodes: [],
10
+ visibleEdges: [],
11
+ nodeDegrees: new Map(),
7
12
  selected: null,
8
13
  hovered: null,
9
14
  query: '',
@@ -102,11 +107,18 @@ const filteredNodes = () => {
102
107
  return localFilteredNodes(query)
103
108
  }
104
109
 
105
- const visibleIds = () => new Set(filteredNodes().map(node => node.id))
106
-
107
- const visibleEdges = () => {
108
- const ids = visibleIds()
109
- return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
110
+ const recomputeVisibility = () => {
111
+ const nodes = filteredNodes()
112
+ const ids = new Set(nodes.map(node => node.id))
113
+ const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
114
+ const limitedEdges = state.nodes.length > largeGraphNodeThreshold
115
+ ? [...edges]
116
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left))
117
+ .slice(0, largeGraphEdgeRenderLimit)
118
+ : edges
119
+
120
+ state.visibleNodes = nodes
121
+ state.visibleEdges = limitedEdges
110
122
  }
111
123
 
112
124
  const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
@@ -205,6 +217,7 @@ const resetContentFilter = () => {
205
217
  token: state.contentFilter.token + 1,
206
218
  timer: null
207
219
  }
220
+ recomputeVisibility()
208
221
  }
209
222
 
210
223
  const syncContentFilter = async (query, token) => {
@@ -228,6 +241,7 @@ const syncContentFilter = async (query, token) => {
228
241
 
229
242
  state.contentFilter.query = query
230
243
  state.contentFilter.ids = new Set(nodeIds)
244
+ recomputeVisibility()
231
245
  }
232
246
 
233
247
  const scheduleContentFilterSync = () => {
@@ -253,9 +267,11 @@ const scheduleContentFilterSync = () => {
253
267
  }
254
268
 
255
269
  const tick = delta => {
256
- const nodes = filteredNodes()
257
- const ids = new Set(nodes.map(node => node.id))
258
- const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
270
+ const nodes = state.visibleNodes
271
+ const edges = state.visibleEdges
272
+ if (nodes.length > 1200) {
273
+ return
274
+ }
259
275
  const strength = Math.min(delta / 16, 2)
260
276
 
261
277
  edges.forEach(edge => {
@@ -314,7 +330,11 @@ const worldPoint = event => {
314
330
  }
315
331
 
316
332
  const hitNode = point => {
317
- const nodes = filteredNodes()
333
+ if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
334
+ return null
335
+ }
336
+
337
+ const nodes = state.visibleNodes
318
338
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
319
339
  const node = nodes[index]
320
340
  const radius = nodeRadius(node)
@@ -324,13 +344,18 @@ const hitNode = point => {
324
344
  }
325
345
 
326
346
  const nodeRadius = node => {
327
- const degree = state.edges.filter(edge => edge.source === node.id || edge.target === node.id).length
347
+ const degree = state.nodeDegrees.get(node.id) ?? 0
328
348
  return 9 + Math.min(degree, 8) * 1.6
329
349
  }
330
350
 
331
351
  const render = now => {
332
352
  const delta = now - state.last
333
353
  state.last = now
354
+ const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 180 : 16
355
+ if (delta < minFrameIntervalMs) {
356
+ requestAnimationFrame(render)
357
+ return
358
+ }
334
359
  const rect = canvas.getBoundingClientRect()
335
360
  const width = Math.max(rect.width, 320)
336
361
  const height = Math.max(rect.height, 320)
@@ -347,7 +372,10 @@ const render = now => {
347
372
  ctx.translate(state.transform.x, state.transform.y)
348
373
  ctx.scale(state.transform.scale, state.transform.scale)
349
374
 
350
- visibleEdges().forEach(edge => {
375
+ tick(delta)
376
+ const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
377
+ if (drawEdges) {
378
+ state.visibleEdges.forEach(edge => {
351
379
  const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
352
380
  ctx.beginPath()
353
381
  ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
@@ -355,9 +383,10 @@ const render = now => {
355
383
  ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
356
384
  ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
357
385
  ctx.stroke()
358
- })
386
+ })
387
+ }
359
388
 
360
- filteredNodes().forEach(node => {
389
+ state.visibleNodes.forEach(node => {
361
390
  const radius = nodeRadius(node)
362
391
  const isSelected = state.selected?.id === node.id
363
392
  const isHovered = state.hovered?.id === node.id
@@ -373,7 +402,11 @@ const render = now => {
373
402
  ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
374
403
  ctx.stroke()
375
404
 
376
- if (isSelected || isHovered || state.transform.scale > 1.18 || state.nodes.length <= 25) {
405
+ const shouldDrawLabels =
406
+ isSelected ||
407
+ isHovered ||
408
+ (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
409
+ if (shouldDrawLabels) {
377
410
  ctx.fillStyle = graphTheme.label
378
411
  ctx.font = '12px Inter, system-ui, sans-serif'
379
412
  ctx.textAlign = 'center'
@@ -483,6 +516,7 @@ const bindEvents = () => {
483
516
  window.addEventListener('resize', resize)
484
517
  elements.search.addEventListener('input', event => {
485
518
  state.query = event.target.value
519
+ recomputeVisibility()
486
520
  scheduleContentFilterSync()
487
521
  })
488
522
  elements.agent.addEventListener('change', event => {
@@ -490,6 +524,7 @@ const bindEvents = () => {
490
524
  state.selected = null
491
525
  state.nodeDetails = new Map()
492
526
  resetContentFilter()
527
+ recomputeVisibility()
493
528
  scheduleContentFilterSync()
494
529
  loadGraph({ reset: true }).catch(error => {
495
530
  console.error(error)
@@ -504,9 +539,13 @@ const bindEvents = () => {
504
539
  zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.84)
505
540
  })
506
541
  if (elements.fit) {
507
- elements.fit.addEventListener('click', () => fitView({ useFiltered: true }))
542
+ elements.fit.addEventListener('click', () => {
543
+ fitView({ useFiltered: true })
544
+ })
508
545
  }
509
- elements.reset.addEventListener('click', resetView)
546
+ elements.reset.addEventListener('click', () => {
547
+ resetView()
548
+ })
510
549
  elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
511
550
  elements.contentDialog.addEventListener('click', event => {
512
551
  const target = event.target
@@ -602,8 +641,16 @@ const loadGraph = async (options = { reset: false }) => {
602
641
  state.graph = graph
603
642
  state.nodes = layout.nodes
604
643
  state.edges = layout.edges
644
+ state.nodeDegrees = state.edges.reduce((degrees, edge) => {
645
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
646
+ if (edge.target) {
647
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
648
+ }
649
+ return degrees
650
+ }, new Map())
605
651
  state.nodeDetails = new Map()
606
652
  resetContentFilter()
653
+ recomputeVisibility()
607
654
  scheduleContentFilterSync()
608
655
  const tags = new Set(graph.nodes.flatMap(node => node.tags))
609
656
  setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
@@ -1,7 +1,18 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
2
4
  import { createCauliflowerGraphLayout } from '../domain/graph-layout.js';
3
5
  import { getGraphSummary } from './get-graph-summary.js';
4
6
  const graphLayoutCache = new Map();
7
+ const readDatabaseSignature = async (vaultPath) => {
8
+ try {
9
+ const info = await stat(join(vaultPath, '.brainlink', 'brainlink.db'));
10
+ return `${Math.floor(info.mtimeMs)}:${info.size}`;
11
+ }
12
+ catch {
13
+ return '0:0';
14
+ }
15
+ };
5
16
  const createGraphSignature = (graph) => {
6
17
  const nodesSignature = graph.nodes.map((node) => `${node.id}|${node.agentId}|${node.title}|${node.path}`).join('\n');
7
18
  const edgesSignature = graph.edges
@@ -12,18 +23,19 @@ const createGraphSignature = (graph) => {
12
23
  .digest('hex');
13
24
  };
14
25
  export const getGraphLayout = async (vaultPath, agentId) => {
15
- const graph = await getGraphSummary(vaultPath, agentId);
16
- const signature = createGraphSignature(graph);
26
+ const databaseSignature = await readDatabaseSignature(vaultPath);
17
27
  const cacheKey = `${vaultPath}:${agentId ?? ''}`;
18
28
  const cached = graphLayoutCache.get(cacheKey);
19
- if (cached?.signature === signature) {
29
+ if (cached?.databaseSignature === databaseSignature) {
20
30
  return {
21
- signature,
31
+ signature: cached.signature,
22
32
  layout: cached.layout
23
33
  };
24
34
  }
35
+ const graph = await getGraphSummary(vaultPath, agentId);
36
+ const signature = createGraphSignature(graph);
25
37
  const layout = createCauliflowerGraphLayout(graph);
26
- graphLayoutCache.set(cacheKey, { signature, layout });
38
+ graphLayoutCache.set(cacheKey, { databaseSignature, signature, layout });
27
39
  return {
28
40
  signature,
29
41
  layout
@@ -3,6 +3,7 @@ import { sharedAgentId } from '../domain/agents.js';
3
3
  import { createEmbeddingProvider } from '../domain/embeddings.js';
4
4
  import { loadBrainlinkConfig } from '../infrastructure/config.js';
5
5
  import { ensureVault, readMarkdownFiles } from '../infrastructure/file-system-vault.js';
6
+ import { buildSearchPacks } from '../infrastructure/search-packs.js';
6
7
  import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
7
8
  const toTitleKey = (title) => title.toLowerCase();
8
9
  const appendTitleEntry = (map, document) => {
@@ -63,6 +64,12 @@ export const indexVault = async (vaultPath) => {
63
64
  try {
64
65
  index.reset();
65
66
  index.saveDocuments(indexedDocuments);
67
+ try {
68
+ await buildSearchPacks(absoluteVaultPath, indexedDocuments);
69
+ }
70
+ catch {
71
+ // Pack generation is best-effort. SQLite index remains the primary path.
72
+ }
66
73
  return {
67
74
  documentCount: indexedDocuments.length,
68
75
  chunkCount: indexedDocuments.reduce((total, document) => total + document.chunks.length, 0),
@@ -1,6 +1,7 @@
1
1
  import { stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { ensureVault } from '../infrastructure/file-system-vault.js';
4
+ import { searchInPacks } from '../infrastructure/search-packs.js';
4
5
  import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
5
6
  import { createEmbeddingProvider } from '../domain/embeddings.js';
6
7
  import { loadBrainlinkConfig, sanitizeSearchMode } from '../infrastructure/config.js';
@@ -55,20 +56,34 @@ export const searchKnowledge = async (vaultPath, query, limit, agentId, mode) =>
55
56
  const provider = createEmbeddingProvider(config.embeddingProvider);
56
57
  const shouldEmbedQuery = searchMode !== 'fts' && provider.name !== 'none';
57
58
  const queryEmbedding = shouldEmbedQuery ? (await provider.embed([query]))[0] ?? [] : [];
58
- const index = openSqliteIndex(absoluteVaultPath);
59
59
  try {
60
- const results = index.search(query, limit, agentId, searchMode, queryEmbedding);
60
+ const index = openSqliteIndex(absoluteVaultPath);
61
+ try {
62
+ const results = index.search(query, limit, agentId, searchMode, queryEmbedding);
63
+ if (cacheKey) {
64
+ cacheSet({
65
+ key: cacheKey,
66
+ createdAt: Date.now(),
67
+ indexMtimeMs,
68
+ results
69
+ });
70
+ }
71
+ return results;
72
+ }
73
+ finally {
74
+ index.close();
75
+ }
76
+ }
77
+ catch {
78
+ const fallbackResults = await searchInPacks(absoluteVaultPath, query, limit, agentId);
61
79
  if (cacheKey) {
62
80
  cacheSet({
63
81
  key: cacheKey,
64
82
  createdAt: Date.now(),
65
83
  indexMtimeMs,
66
- results
84
+ results: fallbackResults
67
85
  });
68
86
  }
69
- return results;
70
- }
71
- finally {
72
- index.close();
87
+ return fallbackResults;
73
88
  }
74
89
  };
@@ -0,0 +1,151 @@
1
+ import { gunzipSync, gzipSync } from 'node:zlib';
2
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ const packsDirectoryName = 'search-packs';
5
+ const manifestFileName = 'manifest.json';
6
+ const rowChunkSize = 5_000;
7
+ const queryTokenPattern = /[\p{L}\p{N}_-]+/gu;
8
+ const toPackDirectory = (vaultPath) => join(vaultPath, '.brainlink', packsDirectoryName);
9
+ const toManifestPath = (vaultPath) => join(toPackDirectory(vaultPath), manifestFileName);
10
+ const parseRowsFromPack = (content) => gunzipSync(content)
11
+ .toString('utf8')
12
+ .split('\n')
13
+ .map((line) => line.trim())
14
+ .filter((line) => line.length > 0)
15
+ .map((line) => JSON.parse(line));
16
+ const toRows = (documents) => documents.flatMap((document) => document.chunks.map((chunk) => ({
17
+ documentId: document.document.id,
18
+ agentId: document.document.agentId,
19
+ title: document.document.title,
20
+ path: document.document.path,
21
+ chunkId: chunk.id,
22
+ content: chunk.content,
23
+ tags: document.document.tags
24
+ })));
25
+ const writeManifest = async (vaultPath, manifest) => {
26
+ await writeFile(toManifestPath(vaultPath), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
27
+ };
28
+ const chunkRows = (rows, size) => {
29
+ const chunks = [];
30
+ for (let index = 0; index < rows.length; index += size) {
31
+ chunks.push(rows.slice(index, index + size));
32
+ }
33
+ return chunks;
34
+ };
35
+ const normalizeToken = (value) => value
36
+ .normalize('NFKD')
37
+ .replace(/\p{Diacritic}/gu, '')
38
+ .toLowerCase();
39
+ const tokenize = (query) => query
40
+ .match(queryTokenPattern)
41
+ ?.map(normalizeToken)
42
+ .filter((token) => token.length > 1) ?? [];
43
+ const countOccurrences = (text, token) => {
44
+ let hits = 0;
45
+ let start = 0;
46
+ while (start < text.length) {
47
+ const index = text.indexOf(token, start);
48
+ if (index < 0) {
49
+ break;
50
+ }
51
+ hits += 1;
52
+ start = index + token.length;
53
+ }
54
+ return hits;
55
+ };
56
+ const computeTextScore = (row, tokens) => {
57
+ if (tokens.length === 0) {
58
+ return 0;
59
+ }
60
+ const title = normalizeToken(row.title);
61
+ const path = normalizeToken(row.path);
62
+ const content = normalizeToken(row.content);
63
+ const tags = normalizeToken(row.tags.join(' '));
64
+ return tokens.reduce((score, token) => {
65
+ const titleHits = countOccurrences(title, token);
66
+ const tagHits = countOccurrences(tags, token);
67
+ const pathHits = countOccurrences(path, token);
68
+ const contentHits = countOccurrences(content, token);
69
+ return score + titleHits * 5 + tagHits * 4 + pathHits * 2 + Math.min(contentHits, 5);
70
+ }, 0);
71
+ };
72
+ const toSearchResult = (row, score) => ({
73
+ documentId: row.documentId,
74
+ agentId: row.agentId,
75
+ title: row.title,
76
+ path: row.path,
77
+ chunkId: row.chunkId,
78
+ content: row.content,
79
+ score,
80
+ textScore: score,
81
+ semanticScore: 0,
82
+ searchMode: 'fts',
83
+ tags: row.tags
84
+ });
85
+ const sortedPackFiles = async (vaultPath) => {
86
+ try {
87
+ const files = await readdir(toPackDirectory(vaultPath));
88
+ return files
89
+ .filter((file) => file.endsWith('.jsonl.gz'))
90
+ .sort((left, right) => left.localeCompare(right));
91
+ }
92
+ catch (error) {
93
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
94
+ return [];
95
+ }
96
+ throw error;
97
+ }
98
+ };
99
+ export const buildSearchPacks = async (vaultPath, documents) => {
100
+ const directory = toPackDirectory(vaultPath);
101
+ const rows = toRows(documents);
102
+ await mkdir(directory, { recursive: true });
103
+ const current = await readdir(directory);
104
+ await Promise.all(current
105
+ .filter((name) => name.endsWith('.jsonl.gz') || name === manifestFileName)
106
+ .map((name) => rm(join(directory, name), { force: true })));
107
+ const chunks = chunkRows(rows, rowChunkSize);
108
+ await Promise.all(chunks.map(async (chunk, index) => {
109
+ const fileName = `pack-${String(index + 1).padStart(4, '0')}.jsonl.gz`;
110
+ const serialized = `${chunk.map((row) => JSON.stringify(row)).join('\n')}\n`;
111
+ const compressed = gzipSync(Buffer.from(serialized, 'utf8'), { level: 6 });
112
+ await writeFile(join(directory, fileName), compressed);
113
+ }));
114
+ await writeManifest(vaultPath, {
115
+ version: 1,
116
+ createdAt: new Date().toISOString(),
117
+ packCount: chunks.length,
118
+ recordCount: rows.length
119
+ });
120
+ return {
121
+ packCount: chunks.length,
122
+ recordCount: rows.length
123
+ };
124
+ };
125
+ export const searchInPacks = async (vaultPath, query, limit, agentId) => {
126
+ const normalizedAgent = agentId?.trim();
127
+ const tokens = tokenize(query);
128
+ if (limit <= 0 || tokens.length === 0) {
129
+ return [];
130
+ }
131
+ const files = await sortedPackFiles(vaultPath);
132
+ if (files.length === 0) {
133
+ return [];
134
+ }
135
+ const scored = [];
136
+ for (const file of files) {
137
+ const rows = parseRowsFromPack(await readFile(join(toPackDirectory(vaultPath), file)));
138
+ rows.forEach((row) => {
139
+ if (normalizedAgent && row.agentId !== normalizedAgent) {
140
+ return;
141
+ }
142
+ const score = computeTextScore(row, tokens);
143
+ if (score > 0) {
144
+ scored.push(toSearchResult(row, score));
145
+ }
146
+ });
147
+ }
148
+ return scored
149
+ .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
150
+ .slice(0, limit);
151
+ };
@@ -635,6 +635,7 @@ GET /api/validate
635
635
  The HTTP API is read-only. Use the CLI for writes and indexing.
636
636
 
637
637
  Brainlink maintains an automatic SQLite rollback snapshot at `.brainlink/brainlink.db.backup`. When `.brainlink/brainlink.db` is corrupted, Brainlink restores from snapshot automatically or recreates a clean index if no snapshot exists yet.
638
+ Indexing also writes compressed search packs at `.brainlink/search-packs/*.jsonl.gz`; when SQLite cannot be opened, Brainlink falls back to pack-based search automatically.
638
639
 
639
640
  ## Agent Integration Contract
640
641
 
@@ -301,6 +301,7 @@ Markdown keeps the system portable, inspectable, Git-friendly, and compatible wi
301
301
  SQLite gives fast local search, local vector storage and rebuildable retrieval without forcing users to run external infrastructure.
302
302
  Hybrid retrieval also uses a short-lived in-memory cache keyed by vault/query/agent and invalidated by index file mtime to reduce repeated query latency.
303
303
  Brainlink also writes a local rollback snapshot (`.brainlink/brainlink.db.backup`) after successful indexing. On corruption detection (`quick_check`/SQLite malformed errors), Brainlink restores from snapshot automatically before reopening the index.
304
+ Indexing additionally exports compressed pack files (`.brainlink/search-packs/*.jsonl.gz`) from indexed chunks. Search falls back to these packs when SQLite is unavailable, preserving retrieval continuity in degraded mode.
304
305
 
305
306
  ### CLI First
306
307
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.10",
3
+ "version": "0.1.0-beta.11",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",