@andespindola/brainlink 0.1.0-beta.6 → 0.1.0-beta.8

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
@@ -561,9 +561,11 @@ Every command works with either `brainlink` or `blink`.
561
561
  ```bash
562
562
  blink init
563
563
  blink init ./vault
564
+ blink init ./team-vault --migrate-from ~/.brainlink/vault
564
565
  ```
565
566
 
566
567
  Initializes vault metadata. Without an argument, Brainlink initializes the default vault at `$HOME/.brainlink/vault`.
568
+ When initializing an empty custom vault, existing Markdown content from the default vault is copied into it and reindexed so context is not left behind. Use `--no-migrate-existing` to start with an empty custom vault, or `--migrate-from <vault>` to copy from a specific source. Existing target files are never overwritten; conflicting source files are preserved with a `.conflict-<timestamp>` suffix.
567
569
 
568
570
  ### `add`
569
571
 
@@ -140,7 +140,7 @@ select {
140
140
 
141
141
  .inspector {
142
142
  display: grid;
143
- grid-template-rows: auto auto auto auto auto 1fr 1fr;
143
+ grid-template-rows: auto auto auto minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
144
144
  gap: 22px;
145
145
  min-width: 0;
146
146
  height: 100%;
@@ -253,7 +253,7 @@ li small {
253
253
  }
254
254
 
255
255
  .note-content {
256
- max-height: 32svh;
256
+ max-height: min(68svh, 760px);
257
257
  margin: 0;
258
258
  padding: 12px;
259
259
  border: 1px solid var(--line);
@@ -267,6 +267,80 @@ li small {
267
267
  line-height: 1.5;
268
268
  }
269
269
 
270
+ .content-dialog {
271
+ width: min(920px, calc(100vw - 32px));
272
+ max-height: calc(100svh - 32px);
273
+ padding: 0;
274
+ border: 1px solid var(--line);
275
+ border-radius: 8px;
276
+ background: var(--panel);
277
+ color: var(--text);
278
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.48);
279
+ }
280
+
281
+ .content-dialog::backdrop {
282
+ background: rgba(4, 7, 10, 0.72);
283
+ backdrop-filter: blur(4px);
284
+ }
285
+
286
+ .content-dialog article {
287
+ display: grid;
288
+ grid-template-rows: auto minmax(0, 1fr);
289
+ max-height: calc(100svh - 34px);
290
+ }
291
+
292
+ .content-dialog header {
293
+ display: flex;
294
+ align-items: flex-start;
295
+ justify-content: space-between;
296
+ gap: 18px;
297
+ padding: 22px;
298
+ border-bottom: 1px solid var(--line);
299
+ }
300
+
301
+ .content-dialog h2,
302
+ .content-dialog p {
303
+ margin: 0;
304
+ }
305
+
306
+ .content-dialog h2 {
307
+ margin-top: 6px;
308
+ font-size: 24px;
309
+ line-height: 1.15;
310
+ overflow-wrap: anywhere;
311
+ }
312
+
313
+ .content-dialog p {
314
+ margin-top: 8px;
315
+ color: var(--muted);
316
+ overflow-wrap: anywhere;
317
+ }
318
+
319
+ .content-dialog button {
320
+ flex: 0 0 auto;
321
+ height: 38px;
322
+ padding: 0 14px;
323
+ border: 1px solid var(--line);
324
+ border-radius: 8px;
325
+ background: var(--panel-strong);
326
+ color: var(--text);
327
+ cursor: pointer;
328
+ }
329
+
330
+ .content-dialog button:hover,
331
+ .content-dialog button:focus {
332
+ border-color: var(--accent);
333
+ color: var(--accent);
334
+ }
335
+
336
+ .content-dialog .note-content {
337
+ max-height: none;
338
+ min-height: 0;
339
+ border: 0;
340
+ border-radius: 0;
341
+ padding: 22px;
342
+ }
343
+
270
344
  @media (max-width: 860px) {
271
345
  .shell {
272
346
  grid-template-columns: 1fr;
@@ -291,4 +365,9 @@ li small {
291
365
  .agent-filter {
292
366
  width: 100%;
293
367
  }
368
+
369
+ .content-dialog header {
370
+ align-items: stretch;
371
+ flex-direction: column;
372
+ }
294
373
  }`;
@@ -47,10 +47,6 @@ export const createClientHtml = () => `<!doctype html>
47
47
  <h2>Notes</h2>
48
48
  <ul id="notes"></ul>
49
49
  </section>
50
- <section>
51
- <h2>Content</h2>
52
- <pre id="content" class="note-content"></pre>
53
- </section>
54
50
  <section>
55
51
  <h2>Outgoing</h2>
56
52
  <ul id="outgoing"></ul>
@@ -61,6 +57,19 @@ export const createClientHtml = () => `<!doctype html>
61
57
  </section>
62
58
  </aside>
63
59
  </main>
60
+ <dialog id="contentDialog" class="content-dialog" aria-labelledby="contentTitle">
61
+ <article>
62
+ <header>
63
+ <div>
64
+ <span class="eyebrow">Markdown content</span>
65
+ <h2 id="contentTitle">Selected note</h2>
66
+ <p id="contentPath"></p>
67
+ </div>
68
+ <button id="contentClose" type="button">Close</button>
69
+ </header>
70
+ <pre id="contentBody" class="note-content"></pre>
71
+ </article>
72
+ </dialog>
64
73
  <script src="/app.js"></script>
65
74
  </body>
66
75
  </html>`;
@@ -31,7 +31,6 @@ const elements = {
31
31
  path: byId('path'),
32
32
  tags: byId('tags'),
33
33
  notes: byId('notes'),
34
- content: byId('content'),
35
34
  outgoing: byId('outgoing'),
36
35
  incoming: byId('incoming'),
37
36
  nodeCount: byId('nodeCount'),
@@ -39,7 +38,12 @@ const elements = {
39
38
  tagCount: byId('tagCount'),
40
39
  zoomIn: byId('zoomIn'),
41
40
  zoomOut: byId('zoomOut'),
42
- reset: byId('reset')
41
+ reset: byId('reset'),
42
+ contentDialog: byId('contentDialog'),
43
+ contentTitle: byId('contentTitle'),
44
+ contentPath: byId('contentPath'),
45
+ contentBody: byId('contentBody'),
46
+ contentClose: byId('contentClose')
43
47
  }
44
48
 
45
49
  const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
@@ -128,7 +132,7 @@ const encodeEntityTag = (value) => {
128
132
  binary += String.fromCharCode(utf8[index])
129
133
  }
130
134
 
131
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
135
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
132
136
  }
133
137
 
134
138
  const graphSignature = graph => JSON.stringify({
@@ -278,14 +282,23 @@ const allNotesList = () => state.nodes.length
278
282
  ? state.nodes.map(node => '<li><button type="button" data-node-id="' + escapeHtml(node.id) + '">' + escapeHtml(node.title) + '</button><small>' + escapeHtml(node.path) + '</small></li>').join('')
279
283
  : '<li><small>No notes indexed.</small></li>'
280
284
 
281
- const selectNode = node => {
285
+ const openContentDialog = node => {
286
+ if (!node) return
287
+ elements.contentTitle.textContent = node.title
288
+ elements.contentPath.textContent = node.path
289
+ elements.contentBody.textContent = node.content
290
+ if (!elements.contentDialog.open) {
291
+ elements.contentDialog.showModal()
292
+ }
293
+ }
294
+
295
+ const selectNode = (node, options = { openContent: false }) => {
282
296
  state.selected = node
283
297
  if (!node) {
284
298
  elements.title.textContent = 'Graph Overview'
285
299
  elements.path.textContent = state.nodes.length + ' notes and ' + state.graph.edges.length + ' links indexed.'
286
300
  elements.tags.innerHTML = ''
287
301
  elements.notes.innerHTML = allNotesList()
288
- elements.content.textContent = 'Selecione uma nota no grafo ou na lista para ver o Markdown completo, backlinks e links de saida.'
289
302
  elements.outgoing.innerHTML = '<li><small>Select a note to inspect outgoing links.</small></li>'
290
303
  elements.incoming.innerHTML = '<li><small>Select a note to inspect backlinks.</small></li>'
291
304
  return
@@ -311,14 +324,14 @@ const selectNode = node => {
311
324
  ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
312
325
  : '<span>No tags</span>'
313
326
  elements.notes.innerHTML = allNotesList()
314
- elements.content.textContent = node.content
315
327
  elements.outgoing.innerHTML = list(outgoing)
316
328
  elements.incoming.innerHTML = list(incoming)
329
+ if (options.openContent) openContentDialog(node)
317
330
  }
318
331
 
319
332
  const selectNodeById = id => {
320
333
  const node = state.nodes.find(item => item.id === id)
321
- if (node) selectNode(node)
334
+ if (node) selectNode(node, { openContent: true })
322
335
  }
323
336
 
324
337
  const zoom = factor => {
@@ -344,6 +357,10 @@ const bindEvents = () => {
344
357
  elements.zoomIn.addEventListener('click', () => zoom(1.18))
345
358
  elements.zoomOut.addEventListener('click', () => zoom(0.84))
346
359
  elements.reset.addEventListener('click', resetView)
360
+ elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
361
+ elements.contentDialog.addEventListener('click', event => {
362
+ if (event.target === elements.contentDialog) elements.contentDialog.close()
363
+ })
347
364
  ;[elements.notes, elements.outgoing, elements.incoming].forEach(element => {
348
365
  element.addEventListener('click', event => {
349
366
  const target = event.target
@@ -384,8 +401,8 @@ const bindEvents = () => {
384
401
  state.transform.y += dy
385
402
  })
386
403
  canvas.addEventListener('pointerup', event => {
387
- if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode)
388
- if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered)
404
+ if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
405
+ if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
389
406
  state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
390
407
  canvas.releasePointerCapture(event.pointerId)
391
408
  })
@@ -0,0 +1,61 @@
1
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, extname, isAbsolute, join, relative } from 'node:path';
3
+ import { ensureVault, listVaultFiles, resolveVaultPath } from '../infrastructure/file-system-vault.js';
4
+ const directoryMode = 0o700;
5
+ const fileMode = 0o600;
6
+ const timestamp = () => new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
7
+ const isPathInside = (parent, child) => {
8
+ const path = relative(parent, child);
9
+ return path === '' || (!path.startsWith('..') && !isAbsolute(path));
10
+ };
11
+ const conflictPath = (targetPath) => {
12
+ const extension = extname(targetPath);
13
+ const base = extension ? targetPath.slice(0, -extension.length) : targetPath;
14
+ return `${base}.conflict-${timestamp()}${extension}`;
15
+ };
16
+ const writePreservedFile = async (absolutePath, content) => {
17
+ await mkdir(dirname(absolutePath), { recursive: true, mode: directoryMode });
18
+ await writeFile(absolutePath, content, { mode: fileMode });
19
+ await chmod(absolutePath, fileMode);
20
+ };
21
+ export const migrateVaultContent = async (sourceVault, targetVault) => {
22
+ const source = await ensureVault(sourceVault);
23
+ const target = await ensureVault(targetVault);
24
+ if (source === target) {
25
+ return { source, target, copied: 0, unchanged: 0, conflicted: 0 };
26
+ }
27
+ const sourceFiles = await listVaultFiles(source);
28
+ const migrated = await sourceFiles.reduce(async (statePromise, sourceFile) => {
29
+ const state = await statePromise;
30
+ const targetFile = join(target, relative(source, sourceFile));
31
+ if (!isPathInside(target, targetFile)) {
32
+ return state;
33
+ }
34
+ const sourceContent = await readFile(sourceFile);
35
+ try {
36
+ const targetContent = await readFile(targetFile);
37
+ if (sourceContent.equals(targetContent)) {
38
+ return { ...state, unchanged: state.unchanged + 1 };
39
+ }
40
+ await writePreservedFile(conflictPath(targetFile), sourceContent);
41
+ return { ...state, conflicted: state.conflicted + 1 };
42
+ }
43
+ catch (error) {
44
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
45
+ throw error;
46
+ }
47
+ await writePreservedFile(targetFile, sourceContent);
48
+ return { ...state, copied: state.copied + 1 };
49
+ }
50
+ }, Promise.resolve({ source, target, copied: 0, unchanged: 0, conflicted: 0 }));
51
+ return migrated;
52
+ };
53
+ export const shouldMigrateDefaultVault = async (sourceVault, targetVault) => {
54
+ const source = resolveVaultPath(sourceVault);
55
+ const target = resolveVaultPath(targetVault);
56
+ if (source === target) {
57
+ return false;
58
+ }
59
+ const [sourceFiles, targetFiles] = await Promise.all([listVaultFiles(source), listVaultFiles(target)]);
60
+ return sourceFiles.length > 0 && targetFiles.length === 0;
61
+ };
@@ -1,9 +1,11 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { addNote } from '../../application/add-note.js';
3
3
  import { indexVault } from '../../application/index-vault.js';
4
+ import { migrateVaultContent, shouldMigrateDefaultVault } from '../../application/migrate-vault.js';
4
5
  import { startServer } from '../../application/start-server.js';
5
6
  import { startVaultWatcher } from '../../application/watch-vault.js';
6
7
  import { doctorVault } from '../../application/analyze-vault.js';
8
+ import { defaultBrainlinkConfig } from '../../infrastructure/config.js';
7
9
  import { loadBrainlinkConfig } from '../../infrastructure/config.js';
8
10
  import { assertVaultAllowed, ensureVault } from '../../infrastructure/file-system-vault.js';
9
11
  import { parsePositiveInteger, print, resolveOptions } from '../runtime.js';
@@ -20,12 +22,26 @@ export const registerWriteCommands = (program) => {
20
22
  program
21
23
  .command('init')
22
24
  .argument('[vault]', 'vault directory')
25
+ .option('--migrate-from <vault>', 'copy existing vault content into the initialized vault')
26
+ .option('--no-migrate-existing', 'skip automatic migration from the default Brainlink vault into an empty custom vault')
23
27
  .option('--json', 'print machine-readable JSON')
24
28
  .description('initialize a Brainlink vault')
25
29
  .action(async (vault, options) => {
26
30
  const config = await loadBrainlinkConfig();
27
- const path = await ensureVault(assertVaultAllowed(vault ?? config.vault, config.allowedVaults));
28
- print(options.json, { path }, () => `Initialized Brainlink vault at ${path}`);
31
+ const targetVault = assertVaultAllowed(vault ?? config.vault, config.allowedVaults);
32
+ const path = await ensureVault(targetVault);
33
+ const explicitSource = options.migrateFrom ? assertVaultAllowed(options.migrateFrom, config.allowedVaults) : undefined;
34
+ const shouldAutoMigrate = explicitSource === undefined &&
35
+ options.migrateExisting !== false &&
36
+ (await shouldMigrateDefaultVault(defaultBrainlinkConfig.vault, targetVault));
37
+ const migration = explicitSource || shouldAutoMigrate ? await migrateVaultContent(explicitSource ?? defaultBrainlinkConfig.vault, targetVault) : undefined;
38
+ const index = migration && migration.copied + migration.conflicted > 0 ? await indexVault(targetVault) : undefined;
39
+ print(options.json, { path, ...(migration ? { migration } : {}), ...(index ? { index } : {}) }, () => {
40
+ const migrated = migration
41
+ ? ` Migrated ${migration.copied} files, preserved ${migration.conflicted} conflicts and kept ${migration.unchanged} unchanged files.`
42
+ : '';
43
+ return `Initialized Brainlink vault at ${path}.${migrated}`;
44
+ });
29
45
  });
30
46
  program
31
47
  .command('add')
@@ -16,6 +16,17 @@ const walkMarkdownFiles = async (directory) => {
16
16
  }));
17
17
  return nested.flat();
18
18
  };
19
+ const walkVaultFiles = async (directory) => {
20
+ const entries = await readdir(directory, { withFileTypes: true });
21
+ const nested = await Promise.all(entries.map(async (entry) => {
22
+ const absolutePath = join(directory, entry.name);
23
+ if (entry.isDirectory()) {
24
+ return excludedDirectories.has(entry.name) ? [] : walkVaultFiles(absolutePath);
25
+ }
26
+ return entry.isFile() ? [absolutePath] : [];
27
+ }));
28
+ return nested.flat();
29
+ };
19
30
  export const resolveVaultPath = (vaultPath) => isBucketVaultUri(vaultPath) ? getBucketVaultCachePath(vaultPath) : resolvePath(vaultPath);
20
31
  export const isBucketVaultPath = (vaultPath) => isBucketVaultUri(vaultPath);
21
32
  const isPathInside = (parent, child) => {
@@ -65,6 +76,10 @@ export const readMarkdownFiles = async (vaultPath) => {
65
76
  };
66
77
  }));
67
78
  };
79
+ export const listVaultFiles = async (vaultPath) => {
80
+ const absoluteVaultPath = await ensureVault(vaultPath);
81
+ return walkVaultFiles(absoluteVaultPath);
82
+ };
68
83
  export const writeMarkdownFile = async (vaultPath, filename, content) => {
69
84
  if (isBucketVaultUri(vaultPath)) {
70
85
  return writeBucketMarkdownFile(vaultPath, filename, content);
@@ -341,7 +341,7 @@ $HOME/.brainlink/vault/
341
341
  .brainlink/
342
342
  ```
343
343
 
344
- `blink init ./vault` creates a custom vault instead.
344
+ `blink init ./vault` creates a custom vault instead. If the custom vault is empty and the default `$HOME/.brainlink/vault` already has Markdown memory, Brainlink copies that content into the custom vault and reindexes it. Use `blink init ./vault --no-migrate-existing` to intentionally start empty, or `blink init ./vault --migrate-from <old-vault>` to migrate from a specific previous vault. Existing target files are not overwritten; conflicting source files are preserved with a `.conflict-<timestamp>` suffix.
345
345
 
346
346
  ### Add A Note
347
347
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.6",
3
+ "version": "0.1.0-beta.8",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",