@andespindola/brainlink 1.0.1 → 1.0.3

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
@@ -633,7 +633,7 @@ blink server --vault ./vault --no-index
633
633
 
634
634
  ## HTTP API
635
635
 
636
- The HTTP API is read-only and exists only to power the graph UI and local inspection workflows.
636
+ The HTTP API is mostly read-oriented and exists to power the graph UI, local inspection workflows and the local document-import modal.
637
637
 
638
638
  The server always refuses non-loopback hosts. Brainlink HTTP only runs on localhost.
639
639
 
@@ -654,6 +654,7 @@ Routes:
654
654
  - `GET /api/broken-links`
655
655
  - `GET /api/orphans`
656
656
  - `GET /api/validate`
657
+ - `POST /api/import-file` with multipart field `file`
657
658
 
658
659
  Read routes accept `agent=<agent-id>`:
659
660
 
@@ -768,6 +769,18 @@ Imports durable memory from a legacy SQLite database into Markdown notes (`agent
768
769
  When `--db` is omitted, Brainlink auto-detects common legacy paths such as `<vault>/.brainlink/brainlink.db`.
769
770
  Use `--agent <id>` to force all imported rows into one namespace, `--limit` for incremental imports, `--dry-run` to preview without writing files, and `--no-index` to defer reindexing.
770
771
 
772
+ ### `import-file`
773
+
774
+ ```bash
775
+ blink import-file ./report.pdf --vault ./team-vault
776
+ blink import-file ./report.pdf --vault ./team-vault --agent coding-agent
777
+ blink import-file ./report.pdf --vault ./team-vault --title "Quarterly Report"
778
+ blink import-file ./report.pdf --vault ./team-vault --no-auto-index
779
+ ```
780
+
781
+ Converts a document with Docling and imports the converted Markdown as a durable note. The `docling` executable must be installed and available in `PATH`.
782
+ The graph UI exposes the same flow through the upload button in the toolbar.
783
+
771
784
  ### `init`
772
785
 
773
786
  ```bash
@@ -1000,7 +1013,7 @@ blink server --vault ./vault --no-open
1000
1013
  blink server --vault ./vault --no-watch
1001
1014
  ```
1002
1015
 
1003
- Starts the local read-only graph UI and HTTP API.
1016
+ Starts the local graph UI and HTTP API.
1004
1017
  Watch mode is enabled by default for Markdown changes in local filesystem vaults. Use `--no-watch` to run without the watcher.
1005
1018
  By default, it tries to open a native desktop GUI window for the graph URL.
1006
1019
  On Linux, native GUI is disabled by default; enable it with `BRAINLINK_LINUX_NATIVE_GUI=1`.
@@ -379,6 +379,133 @@ li small {
379
379
  display: none;
380
380
  }
381
381
 
382
+ .upload-dialog {
383
+ position: absolute;
384
+ z-index: 7;
385
+ top: 88px;
386
+ right: 14px;
387
+ width: min(420px, calc(100vw - 28px));
388
+ border: 1px solid var(--line);
389
+ border-radius: 8px;
390
+ background: var(--panel);
391
+ color: var(--text);
392
+ box-shadow: 0 24px 80px rgba(1, 6, 13, 0.48);
393
+ overflow: hidden;
394
+ }
395
+
396
+ .upload-dialog[hidden] {
397
+ display: none;
398
+ }
399
+
400
+ .upload-dialog form {
401
+ display: grid;
402
+ gap: 14px;
403
+ }
404
+
405
+ .upload-dialog header,
406
+ .upload-dialog footer {
407
+ display: flex;
408
+ align-items: center;
409
+ justify-content: space-between;
410
+ gap: 14px;
411
+ padding: 16px;
412
+ }
413
+
414
+ .upload-dialog header {
415
+ border-bottom: 1px solid var(--line);
416
+ }
417
+
418
+ .upload-dialog h2,
419
+ .upload-dialog p {
420
+ margin: 0;
421
+ }
422
+
423
+ .upload-dialog h2 {
424
+ margin-top: 4px;
425
+ font-size: 18px;
426
+ line-height: 1.2;
427
+ }
428
+
429
+ #uploadClose,
430
+ #uploadSubmit {
431
+ border: 1px solid var(--line);
432
+ border-radius: 8px;
433
+ background: var(--panel-strong);
434
+ color: var(--text);
435
+ cursor: pointer;
436
+ }
437
+
438
+ #uploadClose {
439
+ flex: 0 0 auto;
440
+ width: 38px;
441
+ height: 38px;
442
+ padding: 0;
443
+ font-size: 22px;
444
+ line-height: 1;
445
+ }
446
+
447
+ #uploadSubmit {
448
+ min-width: 98px;
449
+ height: 38px;
450
+ padding: 0 14px;
451
+ }
452
+
453
+ #uploadSubmit:disabled {
454
+ cursor: progress;
455
+ opacity: 0.62;
456
+ }
457
+
458
+ #uploadClose:hover,
459
+ #uploadClose:focus,
460
+ #uploadSubmit:hover,
461
+ #uploadSubmit:focus {
462
+ border-color: var(--accent);
463
+ color: var(--accent);
464
+ }
465
+
466
+ .upload-field {
467
+ display: grid;
468
+ gap: 7px;
469
+ padding: 0 16px;
470
+ }
471
+
472
+ .upload-field span,
473
+ .upload-check span {
474
+ color: var(--muted);
475
+ font-size: 12px;
476
+ }
477
+
478
+ .upload-field input[type="text"],
479
+ .upload-field input[type="file"] {
480
+ width: 100%;
481
+ min-height: 40px;
482
+ border: 1px solid var(--line);
483
+ border-radius: 8px;
484
+ background: rgba(12, 24, 36, 0.94);
485
+ color: var(--text);
486
+ padding: 8px 10px;
487
+ }
488
+
489
+ .upload-check {
490
+ display: flex;
491
+ align-items: center;
492
+ gap: 9px;
493
+ padding: 0 16px;
494
+ }
495
+
496
+ .upload-check input {
497
+ width: 16px;
498
+ height: 16px;
499
+ }
500
+
501
+ #uploadStatus {
502
+ min-height: 16px;
503
+ color: var(--muted);
504
+ font-size: 12px;
505
+ line-height: 1.35;
506
+ overflow-wrap: anywhere;
507
+ }
508
+
382
509
  .content-dialog article {
383
510
  display: grid;
384
511
  grid-template-rows: auto auto minmax(0, 1fr);
@@ -559,6 +686,13 @@ li small {
559
686
  max-height: none;
560
687
  }
561
688
 
689
+ .upload-dialog {
690
+ top: 8px;
691
+ right: 8px;
692
+ left: 8px;
693
+ width: auto;
694
+ }
695
+
562
696
  .content-dialog header {
563
697
  align-items: flex-start;
564
698
  gap: 12px;
@@ -23,10 +23,6 @@ export const createClientHtml = () => `<!doctype html>
23
23
  <strong id="edgeCount">0</strong>
24
24
  <small>Links</small>
25
25
  </div>
26
- <div class="metric-chip">
27
- <strong id="tagCount">0</strong>
28
- <small>Tags</small>
29
- </div>
30
26
  </div>
31
27
  <label class="search">
32
28
  <input id="search" type="search" placeholder="Filter notes, tags or paths" autocomplete="off" />
@@ -39,6 +35,7 @@ export const createClientHtml = () => `<!doctype html>
39
35
  <select id="context"></select>
40
36
  </label>
41
37
  <div class="toolbar" aria-label="Graph controls">
38
+ <button id="uploadOpen" type="button" title="Import document">⇧</button>
42
39
  <button id="zoomIn" type="button" title="Zoom in">+</button>
43
40
  <button id="zoomOut" type="button" title="Zoom out">-</button>
44
41
  <button id="fit" type="button" title="Focus central hub">◎</button>
@@ -52,6 +49,33 @@ export const createClientHtml = () => `<!doctype html>
52
49
  <div id="graphLabels" class="graph-labels" aria-hidden="true"></div>
53
50
  <div id="graphTooltip" class="graph-tooltip" role="tooltip" hidden></div>
54
51
  <canvas id="miniMap" class="mini-map" aria-label="Graph overview"></canvas>
52
+ <aside id="uploadDialog" class="upload-dialog" role="dialog" aria-labelledby="uploadTitle" hidden>
53
+ <form id="uploadForm">
54
+ <header>
55
+ <div>
56
+ <span class="eyebrow">Context import</span>
57
+ <h2 id="uploadTitle">Upload document</h2>
58
+ </div>
59
+ <button id="uploadClose" type="button" aria-label="Close upload dialog" title="Close upload dialog">&times;</button>
60
+ </header>
61
+ <label class="upload-field">
62
+ <span>File</span>
63
+ <input id="uploadFile" name="file" type="file" required />
64
+ </label>
65
+ <label class="upload-field">
66
+ <span>Title</span>
67
+ <input id="uploadTitleInput" name="title" type="text" placeholder="Use filename" autocomplete="off" />
68
+ </label>
69
+ <label class="upload-check">
70
+ <input id="uploadAllowSensitive" name="allowSensitive" type="checkbox" />
71
+ <span>Allow sensitive-looking content</span>
72
+ </label>
73
+ <footer>
74
+ <p id="uploadStatus" role="status"></p>
75
+ <button id="uploadSubmit" type="submit">Import</button>
76
+ </footer>
77
+ </form>
78
+ </aside>
55
79
  <aside id="contentDialog" class="content-dialog" role="dialog" aria-labelledby="contentTitle" hidden>
56
80
  <article>
57
81
  <header>
@@ -7,12 +7,20 @@ const elements = {
7
7
  context: byId('context'),
8
8
  nodeCount: byId('nodeCount'),
9
9
  edgeCount: byId('edgeCount'),
10
- tagCount: byId('tagCount'),
11
10
  zoomIn: byId('zoomIn'),
12
11
  zoomOut: byId('zoomOut'),
13
12
  fit: byId('fit'),
14
13
  releaseNode: byId('releaseNode'),
15
14
  reset: byId('reset'),
15
+ uploadOpen: byId('uploadOpen'),
16
+ uploadDialog: byId('uploadDialog'),
17
+ uploadForm: byId('uploadForm'),
18
+ uploadFile: byId('uploadFile'),
19
+ uploadTitleInput: byId('uploadTitleInput'),
20
+ uploadAllowSensitive: byId('uploadAllowSensitive'),
21
+ uploadClose: byId('uploadClose'),
22
+ uploadSubmit: byId('uploadSubmit'),
23
+ uploadStatus: byId('uploadStatus'),
16
24
  labels: byId('graphLabels'),
17
25
  tooltip: byId('graphTooltip'),
18
26
  miniMap: byId('miniMap'),
@@ -594,10 +602,6 @@ const updateTotals = () => {
594
602
  elements.edgeCount.textContent = String(state.totals.edges)
595
603
  }
596
604
 
597
- const updateTagCount = () => {
598
- elements.tagCount.textContent = state.graphMode === 'far' ? 'clusters' : state.graphMode
599
- }
600
-
601
605
  const updateWorkerCamera = () => {
602
606
  updateGraphOverlays()
603
607
  if (!state.renderWorker || !state.workerReady) {
@@ -1073,7 +1077,6 @@ const fetchChunk = async ({ fit } = { fit: false }) => {
1073
1077
  }
1074
1078
 
1075
1079
  updateTotals()
1076
- updateTagCount()
1077
1080
 
1078
1081
  if (fit) {
1079
1082
  fitFromChunk()
@@ -1374,6 +1377,10 @@ const setupInput = () => {
1374
1377
  })
1375
1378
 
1376
1379
  window.addEventListener('keydown', (event) => {
1380
+ if (event.key === 'Escape' && !elements.uploadDialog.hidden) {
1381
+ closeUploadDialog()
1382
+ return
1383
+ }
1377
1384
  if (event.key === 'Escape' && !elements.contentDialog.hidden) {
1378
1385
  closeContentDialog()
1379
1386
  return
@@ -1392,6 +1399,95 @@ const setupInput = () => {
1392
1399
  })
1393
1400
  }
1394
1401
 
1402
+ const openUploadDialog = () => {
1403
+ elements.uploadDialog.hidden = false
1404
+ elements.uploadStatus.textContent = ''
1405
+ elements.uploadTitleInput.value = ''
1406
+ elements.uploadFile.value = ''
1407
+ elements.uploadAllowSensitive.checked = false
1408
+ window.setTimeout(() => elements.uploadFile.focus(), 0)
1409
+ }
1410
+
1411
+ const closeUploadDialog = () => {
1412
+ elements.uploadDialog.hidden = true
1413
+ }
1414
+
1415
+ const setUploadBusy = (busy) => {
1416
+ elements.uploadSubmit.disabled = busy
1417
+ elements.uploadFile.disabled = busy
1418
+ elements.uploadTitleInput.disabled = busy
1419
+ elements.uploadAllowSensitive.disabled = busy
1420
+ }
1421
+
1422
+ const uploadImportUrl = () => {
1423
+ const params = new URLSearchParams()
1424
+ if (state.agentId) {
1425
+ params.set('agent', state.agentId)
1426
+ }
1427
+ const query = params.toString()
1428
+
1429
+ return '/api/import-file' + (query ? '?' + query : '')
1430
+ }
1431
+
1432
+ const submitUpload = async (event) => {
1433
+ event.preventDefault()
1434
+ const file = elements.uploadFile.files?.[0]
1435
+ if (!file) {
1436
+ elements.uploadStatus.textContent = 'Choose a file to import.'
1437
+ return
1438
+ }
1439
+
1440
+ const form = new FormData()
1441
+ form.append('file', file)
1442
+ const title = elements.uploadTitleInput.value.trim()
1443
+ if (title) {
1444
+ form.append('title', title)
1445
+ }
1446
+ if (elements.uploadAllowSensitive.checked) {
1447
+ form.append('allowSensitive', 'true')
1448
+ }
1449
+
1450
+ setUploadBusy(true)
1451
+ elements.uploadStatus.textContent = 'Importing...'
1452
+
1453
+ try {
1454
+ const response = await fetch(uploadImportUrl(), {
1455
+ method: 'POST',
1456
+ body: form
1457
+ })
1458
+ const payload = await response.json().catch(() => ({}))
1459
+ if (!response.ok) {
1460
+ throw new Error(payload?.error || 'Import failed')
1461
+ }
1462
+
1463
+ elements.uploadStatus.textContent = 'Imported "' + String(payload?.title || file.name) + '".'
1464
+ await loadAgents()
1465
+ await loadContexts()
1466
+ scheduleChunkFetch({ fit: true })
1467
+ window.setTimeout(closeUploadDialog, 700)
1468
+ } catch (error) {
1469
+ elements.uploadStatus.textContent = error instanceof Error ? error.message : String(error)
1470
+ } finally {
1471
+ setUploadBusy(false)
1472
+ }
1473
+ }
1474
+
1475
+ const setupUploadDialog = () => {
1476
+ elements.uploadOpen.addEventListener('click', openUploadDialog)
1477
+ elements.uploadClose.addEventListener('click', closeUploadDialog)
1478
+ elements.uploadForm.addEventListener('submit', (event) => {
1479
+ submitUpload(event).catch((error) => {
1480
+ elements.uploadStatus.textContent = error instanceof Error ? error.message : String(error)
1481
+ setUploadBusy(false)
1482
+ })
1483
+ })
1484
+ elements.uploadDialog.addEventListener('click', (event) => {
1485
+ if (event.target === elements.uploadDialog) {
1486
+ closeUploadDialog()
1487
+ }
1488
+ })
1489
+ }
1490
+
1395
1491
  const setupControls = () => {
1396
1492
  elements.zoomIn.addEventListener('click', () => {
1397
1493
  zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
@@ -1635,6 +1731,7 @@ const bootstrap = async () => {
1635
1731
  setupRenderWorker()
1636
1732
  setupInput()
1637
1733
  setupControls()
1734
+ setupUploadDialog()
1638
1735
  setupContextControl()
1639
1736
  wireNodeLinkClicks()
1640
1737
 
@@ -1646,7 +1743,6 @@ const bootstrap = async () => {
1646
1743
  await loadAgents()
1647
1744
  await loadContexts()
1648
1745
  updateTotals()
1649
- updateTagCount()
1650
1746
 
1651
1747
  scheduleChunkFetch({ fit: true })
1652
1748
  }
@@ -0,0 +1,45 @@
1
+ import { basename, extname } from 'node:path';
2
+ import { sharedAgentId } from '../domain/agents.js';
3
+ import { convertDocumentWithDocling } from '../infrastructure/docling.js';
4
+ import { addNoteWithMetadata } from './add-note.js';
5
+ import { indexVault } from './index-vault.js';
6
+ const titleFromSourceName = (sourceName) => {
7
+ const name = basename(sourceName, extname(sourceName))
8
+ .replace(/[_-]+/g, ' ')
9
+ .replace(/\s+/g, ' ')
10
+ .trim();
11
+ return name.length > 0 ? name : 'Imported Document';
12
+ };
13
+ const buildImportedContent = (sourceName, markdown) => [
14
+ `Imported from \`${sourceName}\` with Docling. #import #document`,
15
+ '',
16
+ '## Extracted Content',
17
+ '',
18
+ markdown.trim()
19
+ ].join('\n');
20
+ const toResult = (title, sourceName, added, index) => ({
21
+ title,
22
+ sourceName,
23
+ path: added.path,
24
+ writeConnectivity: {
25
+ autoLinked: added.autoLinked,
26
+ linkTarget: added.linkTarget,
27
+ context: added.context,
28
+ hubCreated: added.hubCreated,
29
+ guaranteedEdge: added.autoLinked
30
+ },
31
+ ...(index ? { index } : {})
32
+ });
33
+ export const importFile = async (input) => {
34
+ const sourceName = input.originalName?.trim() || basename(input.filePath);
35
+ const title = input.title?.trim() || titleFromSourceName(sourceName);
36
+ const converter = input.converter ?? convertDocumentWithDocling;
37
+ const converted = await converter({ path: input.filePath });
38
+ const content = buildImportedContent(sourceName, converted.markdown);
39
+ const added = await addNoteWithMetadata(input.vaultPath, title, content, input.agentId ?? sharedAgentId, {
40
+ allowSensitive: input.allowSensitive,
41
+ autoContextLinks: input.autoContextLinks
42
+ });
43
+ const index = input.autoIndex === false ? undefined : await indexVault(input.vaultPath);
44
+ return toResult(title, sourceName, added, index);
45
+ };
@@ -0,0 +1,80 @@
1
+ const readRequestBuffer = async (request, limitBytes) => {
2
+ const chunks = [];
3
+ let size = 0;
4
+ for await (const chunk of request) {
5
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
6
+ size += buffer.byteLength;
7
+ if (size > limitBytes) {
8
+ throw Object.assign(new Error('Request body too large'), { statusCode: 413 });
9
+ }
10
+ chunks.push(buffer);
11
+ }
12
+ return Buffer.concat(chunks);
13
+ };
14
+ const parseDisposition = (value) => value
15
+ .split(';')
16
+ .slice(1)
17
+ .reduce((params, entry) => {
18
+ const [rawKey, ...rawValue] = entry.trim().split('=');
19
+ const key = rawKey?.trim();
20
+ if (!key) {
21
+ return params;
22
+ }
23
+ params[key] = rawValue.join('=').trim().replace(/^"|"$/g, '');
24
+ return params;
25
+ }, {});
26
+ const parseHeaders = (value) => value.split('\r\n').reduce((headers, line) => {
27
+ const separator = line.indexOf(':');
28
+ if (separator < 0) {
29
+ return headers;
30
+ }
31
+ const key = line.slice(0, separator).trim().toLowerCase();
32
+ const headerValue = line.slice(separator + 1).trim();
33
+ if (key) {
34
+ headers[key] = headerValue;
35
+ }
36
+ return headers;
37
+ }, {});
38
+ const stripPartTerminator = (value) => value.endsWith('\r\n') ? value.slice(0, -2) : value;
39
+ export const parseMultipartForm = async (request, limitBytes = 50 * 1024 * 1024) => {
40
+ const contentType = Array.isArray(request.headers['content-type'])
41
+ ? request.headers['content-type'][0]
42
+ : request.headers['content-type'];
43
+ const boundary = contentType?.match(/boundary=(?:"([^"]+)"|([^;]+))/)?.[1] ?? contentType?.match(/boundary=(?:"([^"]+)"|([^;]+))/)?.[2];
44
+ if (!boundary) {
45
+ throw Object.assign(new Error('Missing multipart boundary'), { statusCode: 400 });
46
+ }
47
+ const body = await readRequestBuffer(request, limitBytes);
48
+ const bodyText = body.toString('binary');
49
+ const parts = bodyText.split(`--${boundary}`);
50
+ const fields = {};
51
+ const files = [];
52
+ for (const rawPart of parts) {
53
+ if (!rawPart || rawPart === '--\r\n' || rawPart === '--') {
54
+ continue;
55
+ }
56
+ const part = rawPart.startsWith('\r\n') ? rawPart.slice(2) : rawPart;
57
+ const separator = part.indexOf('\r\n\r\n');
58
+ if (separator < 0) {
59
+ continue;
60
+ }
61
+ const headers = parseHeaders(part.slice(0, separator));
62
+ const content = stripPartTerminator(part.slice(separator + 4));
63
+ const disposition = parseDisposition(headers['content-disposition'] ?? '');
64
+ const fieldName = disposition.name ?? '';
65
+ if (!fieldName) {
66
+ continue;
67
+ }
68
+ if (disposition.filename != null && disposition.filename.length > 0) {
69
+ files.push({
70
+ fieldName,
71
+ filename: disposition.filename,
72
+ contentType: headers['content-type'] ?? 'application/octet-stream',
73
+ data: Buffer.from(content, 'binary')
74
+ });
75
+ continue;
76
+ }
77
+ fields[fieldName] = Buffer.from(content, 'binary').toString('utf8');
78
+ }
79
+ return { fields, files };
80
+ };
@@ -1,3 +1,6 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
1
4
  import { getBrokenLinksReport, getOrphansReport, getStats, validateVault } from '../analyze-vault.js';
2
5
  import { buildContextPackage } from '../build-context.js';
3
6
  import { getGraph } from '../get-graph.js';
@@ -7,6 +10,7 @@ import { getGraphLayout } from '../get-graph-layout.js';
7
10
  import { getGraphView } from '../get-graph-view.js';
8
11
  import { getGraphStreamChunk } from '../get-graph-stream-chunk.js';
9
12
  import { deleteGraphViewState, getGraphViewState, saveGraphViewState } from '../graph-view-state.js';
13
+ import { importFile } from '../import-file.js';
10
14
  import { listAgents } from '../list-agents.js';
11
15
  import { listBacklinks, listLinks } from '../list-links.js';
12
16
  import { searchGraphNodeIds } from '../search-graph-node-ids.js';
@@ -18,6 +22,7 @@ import { createClientJs } from '../frontend/client-js.js';
18
22
  import { createClientWorkerJs } from '../frontend/client-worker-js.js';
19
23
  import { createClientRenderWorkerJs } from '../frontend/client-render-worker-js.js';
20
24
  import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
25
+ import { parseMultipartForm } from './multipart.js';
21
26
  const readSearchMode = async (url) => {
22
27
  const config = await loadBrainlinkConfig();
23
28
  const defaults = resolveAgentRuntimeDefaults(config, readAgentQuery(url));
@@ -95,6 +100,27 @@ const readGraphViewStateInput = (url) => ({
95
100
  agentId: readAgentQuery(url),
96
101
  context: readContextQuery(url)
97
102
  });
103
+ const booleanField = (value) => value === '1' || value === 'true' || value === 'on';
104
+ const writeUploadTempFile = async (filename, data) => {
105
+ const directory = await mkdtemp(join(tmpdir(), 'brainlink-upload-'));
106
+ const safeName = filename.replace(/[\\/]/g, '-').trim() || 'upload.bin';
107
+ const path = join(directory, safeName);
108
+ await writeFile(path, data);
109
+ return { directory, path };
110
+ };
111
+ const isSameOriginWrite = (request, url) => {
112
+ const originHeader = Array.isArray(request.headers.origin) ? request.headers.origin[0] : request.headers.origin;
113
+ if (!originHeader) {
114
+ return true;
115
+ }
116
+ try {
117
+ const origin = new URL(originHeader);
118
+ return origin.protocol === url.protocol && origin.host === url.host;
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ };
98
124
  const compactGraphLayoutThreshold = 12_000;
99
125
  const compactGraphLayoutEdgeLimit = 60_000;
100
126
  const graphLayoutBodyCacheLimit = 8;
@@ -345,6 +371,34 @@ export const route = async (request, url, vaultPath) => {
345
371
  }
346
372
  return createResponse(createJsonResponse(await deleteGraphViewState(vaultPath, input)), 200, contentTypes['.json']);
347
373
  }
374
+ if (request.method === 'POST' && url.pathname === '/api/import-file') {
375
+ if (!isSameOriginWrite(request, url)) {
376
+ return createResponse(createJsonResponse({ error: 'Cross-origin imports are not allowed' }), 403, contentTypes['.json']);
377
+ }
378
+ const config = await loadBrainlinkConfig();
379
+ const form = await parseMultipartForm(request);
380
+ const file = form.files.find((candidate) => candidate.fieldName === 'file') ?? form.files[0];
381
+ if (!file || file.data.byteLength === 0) {
382
+ return createResponse(createJsonResponse({ error: 'Missing uploaded file' }), 400, contentTypes['.json']);
383
+ }
384
+ const temp = await writeUploadTempFile(file.filename, file.data);
385
+ try {
386
+ const result = await importFile({
387
+ vaultPath,
388
+ filePath: temp.path,
389
+ originalName: file.filename,
390
+ title: form.fields.title,
391
+ agentId: readAgentQuery(url) ?? form.fields.agent,
392
+ allowSensitive: booleanField(form.fields.allowSensitive),
393
+ autoContextLinks: config.autoCanonicalContextLinks,
394
+ autoIndex: config.autoIndexOnWrite
395
+ });
396
+ return createResponse(createJsonResponse(result), 200, contentTypes['.json']);
397
+ }
398
+ finally {
399
+ await rm(temp.directory, { recursive: true, force: true });
400
+ }
401
+ }
348
402
  if (isReadMethod(request) && url.pathname === '/api/graph-node') {
349
403
  const id = url.searchParams.get('id')?.trim() ?? '';
350
404
  if (!id) {
@@ -8,6 +8,7 @@ import { buildContextPackage } from '../../application/build-context.js';
8
8
  import { deleteNote } from '../../application/delete-note.js';
9
9
  import { resolveDuplicateNotes, scanDuplicateNotes } from '../../application/dedupe-notes.js';
10
10
  import { importLegacySqliteDatabase } from '../../application/import-legacy-sqlite.js';
11
+ import { importFile } from '../../application/import-file.js';
11
12
  import { indexVault, indexVaultWithOptions } from '../../application/index-vault.js';
12
13
  import { migrateContextLinks } from '../../application/migrate-context-links.js';
13
14
  import { canonicalizeContextLinks } from '../../application/canonical-context-links.js';
@@ -874,6 +875,38 @@ export const registerWriteCommands = (program) => {
874
875
  return `Created note at ${added.path}.${linkMessage}${duplicateMessage}`;
875
876
  });
876
877
  });
878
+ program
879
+ .command('import-file')
880
+ .argument('<file>', 'file to convert and import into the vault')
881
+ .option('-t, --title <title>', 'note title; defaults to the source filename')
882
+ .option('-v, --vault <vault>', 'vault directory')
883
+ .option('-a, --agent <agent>', 'agent memory namespace')
884
+ .option('--allow-sensitive', 'allow writing converted content that looks like a secret')
885
+ .option('--no-auto-context-links', 'skip canonical Context Links for this imported note')
886
+ .option('--no-auto-index', 'skip reindexing after import')
887
+ .option('--json', 'print machine-readable JSON')
888
+ .description('convert a document with Docling and import it as a Markdown note')
889
+ .action(async (file, options) => {
890
+ const resolved = await resolveOptions(options);
891
+ const result = await importFile({
892
+ vaultPath: resolved.vault,
893
+ filePath: resolve(file),
894
+ title: options.title,
895
+ agentId: resolved.agent,
896
+ allowSensitive: Boolean(options.allowSensitive),
897
+ autoContextLinks: options.autoContextLinks !== false && resolved.config.autoCanonicalContextLinks,
898
+ autoIndex: options.autoIndex !== false && resolved.config.autoIndexOnWrite
899
+ });
900
+ print(options.json, {
901
+ vault: resolved.vault,
902
+ agent: resolved.agent ?? 'shared',
903
+ ...result
904
+ }, () => {
905
+ const linkMessage = result.writeConnectivity.autoLinked ? ` Linked to [[${result.writeConnectivity.linkTarget}]].` : '';
906
+ const indexMessage = result.index ? ` Indexed ${result.index.documentCount} documents.` : '';
907
+ return `Imported ${result.sourceName} as "${result.title}" at ${result.path}.${linkMessage}${indexMessage}`;
908
+ });
909
+ });
877
910
  program
878
911
  .command('delete-note')
879
912
  .option('-v, --vault <vault>', 'vault directory')
@@ -0,0 +1,40 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, extname, join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ const execFileAsync = promisify(execFile);
7
+ const readConvertedMarkdown = async (outputDirectory, sourcePath) => {
8
+ const sourceName = basename(sourcePath, extname(sourcePath));
9
+ const files = await readdir(outputDirectory);
10
+ const exact = files.find((file) => file === `${sourceName}.md`);
11
+ const fallback = files.find((file) => extname(file).toLowerCase() === '.md');
12
+ const markdownFile = exact ?? fallback;
13
+ if (!markdownFile) {
14
+ throw new Error('Docling did not produce a Markdown output file.');
15
+ }
16
+ return readFile(join(outputDirectory, markdownFile), 'utf8');
17
+ };
18
+ const toDoclingError = (error) => {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ const notFound = 'code' in Object(error) && Object(error).code === 'ENOENT';
21
+ return new Error(notFound
22
+ ? 'Docling executable not found. Install the Python package and make the `docling` command available in PATH.'
23
+ : `Docling conversion failed: ${message}`);
24
+ };
25
+ export const convertDocumentWithDocling = async (input) => {
26
+ const outputDirectory = await mkdtemp(join(tmpdir(), 'brainlink-docling-'));
27
+ try {
28
+ await execFileAsync('docling', ['--to', 'md', '--output', outputDirectory, input.path], {
29
+ maxBuffer: 1024 * 1024 * 50
30
+ });
31
+ const markdown = await readConvertedMarkdown(outputDirectory, input.path);
32
+ return { markdown };
33
+ }
34
+ catch (error) {
35
+ throw toDoclingError(error);
36
+ }
37
+ finally {
38
+ await rm(outputDirectory, { recursive: true, force: true });
39
+ }
40
+ };
@@ -430,6 +430,17 @@ blink db-import --vault ./team-vault --db ./legacy/brainlink.db --table legacy_n
430
430
  Without `--db`, Brainlink auto-detects common legacy database paths.
431
431
  Use `--agent` to force namespace, `--limit` for staged migration, `--dry-run` to preview writes, and `--no-index` to postpone indexing.
432
432
 
433
+ ### Import A Document File
434
+
435
+ ```bash
436
+ blink import-file ./report.pdf --vault ./team-vault
437
+ blink import-file ./report.pdf --vault ./team-vault --agent coding-agent
438
+ blink import-file ./report.pdf --vault ./team-vault --title "Quarterly Report"
439
+ ```
440
+
441
+ `import-file` converts a source document with Docling, writes the converted Markdown as a durable note, and indexes the result by default.
442
+ The `docling` executable must be installed and available in `PATH`. The graph UI exposes the same import path through its upload modal.
443
+
433
444
  ### Install Agent Integration
434
445
 
435
446
  ```bash
@@ -636,7 +647,7 @@ blink server --vault ./vault --host 127.0.0.1 --port 4321
636
647
  blink server --vault ./vault --host 127.0.0.1 --port 4321 --no-open
637
648
  ```
638
649
 
639
- This starts a local frontend for inspecting the knowledge graph.
650
+ This starts a local frontend for inspecting the knowledge graph and importing document files into the selected vault.
640
651
  By default it tries to open the graph in a native desktop GUI window:
641
652
  - macOS: Swift + WebKit
642
653
  - Windows: PowerShell WinForms WebBrowser
@@ -764,9 +775,10 @@ GET /api/stats
764
775
  GET /api/broken-links
765
776
  GET /api/orphans
766
777
  GET /api/validate
778
+ POST /api/import-file
767
779
  ```
768
780
 
769
- The HTTP API is read-only. Use the CLI for writes and indexing.
781
+ The HTTP API is read-oriented, with `POST /api/import-file` reserved for the local upload modal. Use the CLI for other writes and indexing.
770
782
 
771
783
  Indexing writes private encrypted search packs at `.brainlink/search-packs/*.blpk` for resilient retrieval and portability.
772
784
  Pack search now uses compressed-space prefiltering (token bloom index per pack) before decrypting/reading pack payloads.
@@ -165,9 +165,10 @@ server command
165
165
  -> /api/graph-layout derives a cauliflower hub layout from indexed graph data
166
166
  -> optional context query narrows the layout to a segment-scoped cauliflower subgraph
167
167
  -> browser renders graph canvas
168
+ -> /api/import-file converts uploaded documents and writes Markdown notes
168
169
  ```
169
170
 
170
- The graph UI is intentionally read-only. Markdown remains the write interface and index artifacts remain derived data.
171
+ The graph UI is primarily an inspection surface. Its upload modal is the narrow write path: uploaded files are converted to Markdown notes through the same application import use case used by the CLI. Markdown remains the source of truth and index artifacts remain derived data.
171
172
  The cauliflower layout is a visual projection over the indexed graph. Indexed weighted wiki-link edges remain unchanged for backlinks, ranking, graph reads and context traversal. The browser layout renders a simplified hierarchy instead: primary hub -> segment hubs -> local context nodes. This avoids unstable cross-context visual edges while preserving the real relationship model outside the render layer. Zoomed-out graph streams summarize those lobes as segment clusters before revealing individual nodes on zoom-in.
172
173
 
173
174
  ## HTTP API Flow
@@ -181,7 +182,7 @@ HTTP request
181
182
  ```
182
183
 
183
184
  The HTTP API is local-first and unauthenticated. It is meant for local agents, browser UI, and development workflows.
184
- The route adapter caches generated frontend assets (`/`, `/styles.css`, `/app.js`, `/app-worker.js`) and graph-layout JSON payloads by layout signature to reduce repeated CPU and allocation pressure during navigation.
185
+ The route adapter caches generated frontend assets (`/`, `/styles.css`, `/app.js`, `/app-worker.js`) and graph-layout JSON payloads by layout signature to reduce repeated CPU and allocation pressure during navigation. File upload conversion is intentionally routed through the import-file use case instead of writing vault files directly in the HTTP adapter.
185
186
 
186
187
  ## MCP Flow
187
188
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",