@andespindola/brainlink 0.1.0-beta.42 → 0.1.0-beta.43

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/AGENTS.md CHANGED
@@ -83,6 +83,8 @@ Use watch mode while editing notes:
83
83
  ```bash
84
84
  npm run dev -- server --vault ./vault --watch
85
85
  npm run dev -- watch --vault ./vault
86
+ npm run dev -- bench --vault ./vault
87
+ npm run dev -- bench --vault ./vault --watch
86
88
  ```
87
89
 
88
90
  Start MCP over stdio:
package/CHANGELOG.md CHANGED
@@ -38,6 +38,7 @@
38
38
  - Improved Linux browser fallback launch stability by auto-applying Chromium compatibility flags (`--ozone-platform=x11`, `--disable-gpu`, `--disable-features=Vulkan,VaapiVideoDecoder`, `--disable-background-networking`) for app-window/browser modes.
39
39
  - Improved massive-graph UI responsiveness with stricter render budgets, adaptive heavy-graph frame throttling, reduced interaction hit-test frequency, and URL-first agent selection on initial graph load.
40
40
  - Improved 50k+ graph LOD behavior so zoomed-out views render lightweight cluster overviews and progressively reveal nodes/edges only as zoom increases.
41
+ - Added `blink bench` with realtime index phase telemetry and per-run compressed-pack analysis (input/output bytes, ratio, saved space, rebuild reason and duration), including continuous watch mode.
41
42
 
42
43
  ## 0.1.0-beta.3
43
44
 
package/README.md CHANGED
@@ -760,6 +760,25 @@ blink index --vault ./vault
760
760
 
761
761
  Rebuilds the local index from Markdown files.
762
762
 
763
+ ### `bench`
764
+
765
+ ```bash
766
+ blink bench --vault ./vault
767
+ blink bench --vault ./vault --watch
768
+ blink bench --vault ./vault --watch --debounce 500
769
+ blink bench --vault ./vault --json
770
+ ```
771
+
772
+ Runs indexing with realtime phase telemetry (`start`, `scan`, `parse`, `embed`, `persist`, `packs`, `complete`) and prints a benchmark summary at the end of each run.
773
+
774
+ Summary includes compression behavior for `.blpk` packs when rebuild happens:
775
+ - pack rebuild reason
776
+ - pack count and pack build duration
777
+ - uncompressed input bytes vs compressed output bytes
778
+ - saved percentage
779
+
780
+ Use `--watch` to keep benchmarking incremental reindex runs after Markdown changes (local filesystem vaults only).
781
+
763
782
  ### `agents`
764
783
 
765
784
  ```bash
@@ -97,12 +97,33 @@ const readChangedDocuments = async (absoluteVaultPath, changedSummaries) => {
97
97
  return new Map(parsed.map((document) => [document.path, document]));
98
98
  };
99
99
  export const indexVault = async (vaultPath) => {
100
+ return indexVaultWithOptions(vaultPath, {});
101
+ };
102
+ export const indexVaultWithOptions = async (vaultPath, options) => {
103
+ const startedAt = process.hrtime.bigint();
104
+ const elapsedMs = () => Number(process.hrtime.bigint() - startedAt) / 1_000_000;
105
+ const emit = (phase, status, message, details) => {
106
+ options.onProgress?.({
107
+ phase,
108
+ status,
109
+ message,
110
+ elapsedMs: elapsedMs(),
111
+ timestamp: new Date().toISOString(),
112
+ details
113
+ });
114
+ };
115
+ emit('start', 'start', 'Indexing started');
100
116
  const absoluteVaultPath = await ensureVault(vaultPath);
101
117
  const config = await loadBrainlinkConfig();
118
+ emit('scan', 'start', 'Scanning markdown files');
102
119
  const [summaries, previousState] = await Promise.all([
103
120
  readMarkdownFileSummaries(absoluteVaultPath),
104
121
  readIndexState(absoluteVaultPath)
105
122
  ]);
123
+ emit('scan', 'finish', 'Scan complete', {
124
+ markdownFiles: summaries.length,
125
+ hasPreviousState: previousState != null
126
+ });
106
127
  const index = openFileIndex(absoluteVaultPath);
107
128
  try {
108
129
  const existingIndexedDocuments = await index.getIndexedDocuments();
@@ -133,10 +154,28 @@ export const indexVault = async (vaultPath) => {
133
154
  !hasDeletes &&
134
155
  existingIndexedDocuments.length === summaries.length &&
135
156
  previousState != null) {
136
- return toIndexResult(existingIndexedDocuments);
157
+ const result = {
158
+ ...toIndexResult(existingIndexedDocuments),
159
+ elapsedMs: elapsedMs(),
160
+ changedDocumentCount: 0,
161
+ packs: {
162
+ rebuilt: false,
163
+ reason: 'No changes detected'
164
+ }
165
+ };
166
+ emit('complete', 'skip', 'Index skipped: no changes detected', {
167
+ elapsedMs: result.elapsedMs
168
+ });
169
+ return result;
137
170
  }
138
171
  const changedSummaries = summaries.filter((summary) => changedPaths.has(summary.relativePath));
172
+ emit('parse', 'start', 'Parsing changed markdown files', {
173
+ changedFiles: changedSummaries.length
174
+ });
139
175
  const changedDocumentsByPath = await readChangedDocuments(absoluteVaultPath, changedSummaries);
176
+ emit('parse', 'finish', 'Parse complete', {
177
+ changedDocuments: changedDocumentsByPath.size
178
+ });
140
179
  const documents = summaries.flatMap((summary) => {
141
180
  const changed = changedDocumentsByPath.get(summary.relativePath);
142
181
  if (changed) {
@@ -146,9 +185,15 @@ export const indexVault = async (vaultPath) => {
146
185
  return existing ? [existing.document] : [];
147
186
  });
148
187
  const titleMaps = createTitleMaps(documents);
188
+ emit('embed', 'start', 'Embedding changed chunks', {
189
+ changedDocuments: changedDocumentsByPath.size
190
+ });
149
191
  const changedIndexedDocuments = changedDocumentsByPath.size > 0
150
192
  ? await embedIndexedDocuments(Array.from(changedDocumentsByPath.values()).map((document) => createIndexedDocument(document, createScopedTitleResolver(document, titleMaps), config.chunkSize)), config.embeddingProvider)
151
193
  : [];
194
+ emit('embed', changedDocumentsByPath.size > 0 ? 'finish' : 'skip', changedDocumentsByPath.size > 0 ? 'Embedding complete' : 'Embedding skipped', {
195
+ changedIndexedDocuments: changedIndexedDocuments.length
196
+ });
152
197
  const changedIndexedByPath = new Map(changedIndexedDocuments.map((document) => [document.document.path, document]));
153
198
  const needsRelink = settingsChanged || hasDeletes || changedPaths.size > 0;
154
199
  const indexedDocuments = documents.map((document) => {
@@ -162,8 +207,12 @@ export const indexVault = async (vaultPath) => {
162
207
  }
163
208
  return needsRelink ? relinkIndexedDocument(existing, titleMaps) : existing;
164
209
  });
210
+ emit('persist', 'start', 'Persisting index');
165
211
  await index.reset();
166
212
  await index.saveDocuments(indexedDocuments);
213
+ emit('persist', 'finish', 'Index persisted', {
214
+ indexedDocuments: indexedDocuments.length
215
+ });
167
216
  const existingPackManifest = await hasPackManifest(absoluteVaultPath);
168
217
  const changedCount = changedPaths.size;
169
218
  const documentCount = Math.max(indexedDocuments.length, 1);
@@ -176,21 +225,78 @@ export const indexVault = async (vaultPath) => {
176
225
  changedCount >= 400 ||
177
226
  changeRatio >= 0.04 ||
178
227
  pendingPackChanges >= 1200;
228
+ let packResult;
229
+ const packReason = !existingPackManifest
230
+ ? 'Missing pack manifest'
231
+ : settingsChanged
232
+ ? 'Index settings changed'
233
+ : hasDeletes
234
+ ? 'Document deletions detected'
235
+ : changedCount >= 400
236
+ ? 'Changed file count threshold reached'
237
+ : changeRatio >= 0.04
238
+ ? 'Change ratio threshold reached'
239
+ : pendingPackChanges >= 1200
240
+ ? 'Pending pack changes threshold reached'
241
+ : 'Pack rebuild skipped';
179
242
  if (shouldRebuildPacks) {
243
+ emit('packs', 'start', 'Rebuilding compressed search packs', {
244
+ reason: packReason
245
+ });
180
246
  try {
181
- await buildSearchPacks(absoluteVaultPath, indexedDocuments);
247
+ packResult = await buildSearchPacks(absoluteVaultPath, indexedDocuments);
248
+ emit('packs', 'finish', 'Compressed packs rebuilt', {
249
+ reason: packReason,
250
+ packCount: packResult.packCount,
251
+ recordCount: packResult.recordCount,
252
+ durationMs: packResult.durationMs,
253
+ compressionRatio: packResult.compression.ratio
254
+ });
182
255
  }
183
256
  catch {
184
257
  // Pack generation is best-effort. The JSON index remains the primary path.
258
+ emit('packs', 'skip', 'Pack rebuild failed; continuing with JSON index', {
259
+ reason: packReason
260
+ });
185
261
  }
186
262
  }
263
+ else {
264
+ emit('packs', 'skip', 'Pack rebuild not required', {
265
+ reason: packReason
266
+ });
267
+ }
268
+ const packsRebuilt = packResult != null;
269
+ const packResultReason = shouldRebuildPacks && !packsRebuilt ? `${packReason} (failed)` : packReason;
187
270
  await writeIndexState(absoluteVaultPath, {
188
271
  chunkSize: config.chunkSize,
189
272
  embeddingProvider: config.embeddingProvider,
190
273
  files: currentSnapshot,
191
- pendingPackChanges: shouldRebuildPacks ? 0 : pendingPackChanges
274
+ pendingPackChanges: packsRebuilt ? 0 : pendingPackChanges
275
+ });
276
+ const result = {
277
+ ...toIndexResult(indexedDocuments),
278
+ elapsedMs: elapsedMs(),
279
+ changedDocumentCount: changedDocumentsByPath.size,
280
+ packs: {
281
+ rebuilt: packsRebuilt,
282
+ reason: packResultReason,
283
+ ...(packResult
284
+ ? {
285
+ packCount: packResult.packCount,
286
+ recordCount: packResult.recordCount,
287
+ durationMs: packResult.durationMs,
288
+ compression: packResult.compression
289
+ }
290
+ : {})
291
+ }
292
+ };
293
+ emit('complete', 'finish', 'Indexing complete', {
294
+ documentCount: result.documentCount,
295
+ chunkCount: result.chunkCount,
296
+ linkCount: result.linkCount,
297
+ elapsedMs: result.elapsedMs
192
298
  });
193
- return toIndexResult(indexedDocuments);
299
+ return result;
194
300
  }
195
301
  finally {
196
302
  index.close();
@@ -1,5 +1,5 @@
1
1
  import { watch } from 'node:fs';
2
- import { indexVault } from './index-vault.js';
2
+ import { indexVaultWithOptions } from './index-vault.js';
3
3
  import { isBucketVaultPath, resolveVaultPath } from '../infrastructure/file-system-vault.js';
4
4
  const shouldIgnore = (filename) => {
5
5
  if (!filename) {
@@ -14,6 +14,27 @@ export const startVaultWatcher = (input) => {
14
14
  const absoluteVaultPath = resolveVaultPath(input.vaultPath);
15
15
  const debounceMs = input.debounceMs ?? 350;
16
16
  let timeout = null;
17
+ let running = false;
18
+ let pending = false;
19
+ const runIndex = () => {
20
+ if (running) {
21
+ pending = true;
22
+ return;
23
+ }
24
+ running = true;
25
+ indexVaultWithOptions(absoluteVaultPath, {
26
+ onProgress: input.onProgress
27
+ })
28
+ .then(input.onIndex)
29
+ .catch(input.onError)
30
+ .finally(() => {
31
+ running = false;
32
+ if (pending) {
33
+ pending = false;
34
+ runIndex();
35
+ }
36
+ });
37
+ };
17
38
  const schedule = (filename) => {
18
39
  if (shouldIgnore(filename)) {
19
40
  return;
@@ -22,7 +43,7 @@ export const startVaultWatcher = (input) => {
22
43
  clearTimeout(timeout);
23
44
  }
24
45
  timeout = setTimeout(() => {
25
- indexVault(absoluteVaultPath).then(input.onIndex).catch(input.onError);
46
+ runIndex();
26
47
  }, debounceMs);
27
48
  };
28
49
  const watcher = watch(absoluteVaultPath, { recursive: true }, (_eventType, filename) => {
@@ -7,7 +7,7 @@ import { addNoteWithMetadata } from '../../application/add-note.js';
7
7
  import { buildContextPackage } from '../../application/build-context.js';
8
8
  import { resolveDuplicateNotes, scanDuplicateNotes } from '../../application/dedupe-notes.js';
9
9
  import { importLegacySqliteDatabase } from '../../application/import-legacy-sqlite.js';
10
- import { indexVault } from '../../application/index-vault.js';
10
+ import { indexVault, indexVaultWithOptions } from '../../application/index-vault.js';
11
11
  import { migrateVaultContent, planVaultMigration, previewVaultMigration, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
12
12
  import { startServer } from '../../application/start-server.js';
13
13
  import { startVaultWatcher } from '../../application/watch-vault.js';
@@ -37,6 +37,52 @@ const parseScore = (value, fallback) => {
37
37
  }
38
38
  return parsed;
39
39
  };
40
+ const formatBytes = (bytes) => {
41
+ if (!Number.isFinite(bytes) || bytes == null) {
42
+ return 'n/a';
43
+ }
44
+ if (bytes < 1024)
45
+ return `${bytes} B`;
46
+ const units = ['KB', 'MB', 'GB', 'TB'];
47
+ let value = bytes / 1024;
48
+ let unitIndex = 0;
49
+ while (value >= 1024 && unitIndex < units.length - 1) {
50
+ value /= 1024;
51
+ unitIndex += 1;
52
+ }
53
+ return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
54
+ };
55
+ const formatMs = (value) => Number.isFinite(value) && value != null ? `${value.toFixed(value >= 100 ? 0 : 1)}ms` : 'n/a';
56
+ const benchEventLabel = (event) => `${event.phase}:${event.status}`;
57
+ const printBenchRealtimeEvent = (json, event) => {
58
+ print(json, {
59
+ event: 'bench-progress',
60
+ ...event
61
+ }, () => `[bench] ${benchEventLabel(event)} ${event.message} (${formatMs(event.elapsedMs)})`);
62
+ };
63
+ const printBenchSummary = (json, trigger, vault, result) => {
64
+ print(json, {
65
+ event: 'bench-result',
66
+ trigger,
67
+ vault,
68
+ result
69
+ }, () => {
70
+ const packs = result.packs;
71
+ const compression = packs?.compression;
72
+ const savedPercent = compression && compression.inputBytes > 0
73
+ ? `${((1 - compression.ratio) * 100).toFixed(1)}%`
74
+ : 'n/a';
75
+ return [
76
+ `[bench] trigger=${trigger}`,
77
+ `documents=${result.documentCount} chunks=${result.chunkCount} links=${result.linkCount}`,
78
+ `changedDocuments=${result.changedDocumentCount ?? 0} totalElapsed=${formatMs(result.elapsedMs)}`,
79
+ `packsRebuilt=${packs?.rebuilt ? 'yes' : 'no'} reason=${packs?.reason ?? 'n/a'}`,
80
+ packs?.rebuilt
81
+ ? `packCount=${packs.packCount ?? 0} packDuration=${formatMs(packs.durationMs)} input=${formatBytes(compression?.inputBytes)} output=${formatBytes(compression?.outputBytes)} saved=${savedPercent}`
82
+ : 'packCompression=n/a'
83
+ ].join('\n');
84
+ });
85
+ };
40
86
  const spawnDetached = (command, args, envOverrides) => {
41
87
  try {
42
88
  const child = spawn(command, args, {
@@ -634,6 +680,58 @@ export const registerWriteCommands = (program) => {
634
680
  const result = await indexVault(resolved.vault);
635
681
  print(options.json, result, () => `Indexed ${result.documentCount} documents, ${result.chunkCount} chunks and ${result.linkCount} links`);
636
682
  });
683
+ program
684
+ .command('bench')
685
+ .option('-v, --vault <vault>', 'vault directory')
686
+ .option('-w, --watch', 'watch markdown changes and re-run benchmark in realtime')
687
+ .option('--debounce <ms>', 'watch debounce in milliseconds', '350')
688
+ .option('--json', 'print machine-readable JSON events')
689
+ .description('benchmark indexing in realtime, including compressed pack behavior')
690
+ .action(async (options) => {
691
+ const resolved = await resolveOptions(options);
692
+ const emitProgress = (event) => {
693
+ printBenchRealtimeEvent(options.json, event);
694
+ };
695
+ const printBenchError = (error) => {
696
+ const message = error instanceof Error ? error.message : String(error);
697
+ print(options.json, { event: 'bench-error', message }, () => `[bench] error ${message}`);
698
+ };
699
+ const runAndPrint = async (trigger) => {
700
+ const result = await indexVaultWithOptions(resolved.vault, {
701
+ onProgress: emitProgress
702
+ });
703
+ printBenchSummary(options.json, trigger, resolved.vault, result);
704
+ return result;
705
+ };
706
+ if (!options.watch) {
707
+ await runAndPrint('manual');
708
+ return;
709
+ }
710
+ const debounceMs = parsePositiveInteger(options.debounce ?? '350', 350);
711
+ await runAndPrint('manual');
712
+ print(options.json, {
713
+ event: 'bench-watching',
714
+ vault: resolved.vault,
715
+ debounceMs
716
+ }, () => `[bench] watching ${resolved.vault} (debounce=${debounceMs}ms)`);
717
+ const watcher = startVaultWatcher({
718
+ vaultPath: resolved.vault,
719
+ debounceMs,
720
+ onProgress: emitProgress,
721
+ onIndex: (result) => {
722
+ printBenchSummary(options.json, 'watch', resolved.vault, result);
723
+ },
724
+ onError: printBenchError
725
+ });
726
+ await new Promise((resolveSignal) => {
727
+ const shutdown = () => {
728
+ watcher.close();
729
+ resolveSignal();
730
+ };
731
+ process.once('SIGINT', shutdown);
732
+ process.once('SIGTERM', shutdown);
733
+ });
734
+ });
637
735
  program
638
736
  .command('doctor')
639
737
  .option('-v, --vault <vault>', 'vault directory')
@@ -221,6 +221,7 @@ const sortedPackFiles = async (vaultPath) => {
221
221
  }
222
222
  };
223
223
  const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting) => {
224
+ const startedAt = process.hrtime.bigint();
224
225
  const directory = toPackDirectory(vaultPath);
225
226
  await mkdir(directory, { recursive: true });
226
227
  if (clearExisting) {
@@ -231,6 +232,8 @@ const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting) => {
231
232
  }
232
233
  const chunks = chunkRows(rows, rowChunkSize);
233
234
  const packIndex = [];
235
+ let inputBytes = 0;
236
+ let outputBytes = 0;
234
237
  for (let index = 0; index < chunks.length; index += 1) {
235
238
  const chunk = chunks[index];
236
239
  const fileName = `pack-${String(index + 1).padStart(4, '0')}.blpk`;
@@ -238,6 +241,8 @@ const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting) => {
238
241
  const compressed = await encodePrivatePack(vaultPath, Buffer.from(serialized, 'utf8'));
239
242
  const tokenBloomB64 = bloomToBase64(bloomFromRows(chunk));
240
243
  await writeFile(join(directory, fileName), compressed);
244
+ inputBytes += Buffer.byteLength(serialized, 'utf8');
245
+ outputBytes += compressed.byteLength;
241
246
  packIndex.push({
242
247
  fileName,
243
248
  recordCount: chunk.length,
@@ -253,9 +258,19 @@ const writeRowsAsPrivatePacks = async (vaultPath, rows, clearExisting) => {
253
258
  format: 'private-v2',
254
259
  packIndex
255
260
  });
261
+ const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
262
+ const safeInput = Math.max(inputBytes, 1);
263
+ const savedBytes = Math.max(inputBytes - outputBytes, 0);
256
264
  return {
257
265
  packCount: chunks.length,
258
- recordCount: rows.length
266
+ recordCount: rows.length,
267
+ compression: {
268
+ inputBytes,
269
+ outputBytes,
270
+ ratio: outputBytes / safeInput,
271
+ savedBytes
272
+ },
273
+ durationMs
259
274
  };
260
275
  };
261
276
  const selectCandidatePackFiles = async (vaultPath, tokens, agentId) => {
@@ -477,6 +477,24 @@ This scans Markdown files and rebuilds:
477
477
  - links
478
478
  - full-text search records
479
479
 
480
+ ### Benchmark Indexing Realtime
481
+
482
+ ```bash
483
+ blink bench --vault ./vault
484
+ blink bench --vault ./vault --watch
485
+ blink bench --vault ./vault --watch --debounce 500
486
+ blink bench --vault ./vault --json
487
+ ```
488
+
489
+ `bench` runs indexing with realtime phase events and prints a run summary with:
490
+
491
+ - indexed totals (documents, chunks, links)
492
+ - elapsed time and changed document count
493
+ - pack rebuild status and reason
494
+ - pack compression metrics (`inputBytes`, `outputBytes`, ratio/saved percentage)
495
+
496
+ Use `--watch` for continuous benchmark runs while editing notes. Watch mode is supported only for local filesystem vaults.
497
+
480
498
  ### Search Knowledge
481
499
 
482
500
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.42",
3
+ "version": "0.1.0-beta.43",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",