@andespindola/brainlink 1.0.2 → 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 +15 -2
- package/dist/application/frontend/client-css.js +134 -0
- package/dist/application/frontend/client-html.js +28 -0
- package/dist/application/frontend/client-js.js +103 -0
- package/dist/application/import-file.js +45 -0
- package/dist/application/server/multipart.js +80 -0
- package/dist/application/server/routes.js +54 -0
- package/dist/cli/commands/write-commands.js +33 -0
- package/dist/infrastructure/docling.js +40 -0
- package/docs/AGENT_USAGE.md +14 -2
- package/docs/ARCHITECTURE.md +3 -2
- package/package.json +1 -1
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-
|
|
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
|
|
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;
|
|
@@ -35,6 +35,7 @@ export const createClientHtml = () => `<!doctype html>
|
|
|
35
35
|
<select id="context"></select>
|
|
36
36
|
</label>
|
|
37
37
|
<div class="toolbar" aria-label="Graph controls">
|
|
38
|
+
<button id="uploadOpen" type="button" title="Import document">⇧</button>
|
|
38
39
|
<button id="zoomIn" type="button" title="Zoom in">+</button>
|
|
39
40
|
<button id="zoomOut" type="button" title="Zoom out">-</button>
|
|
40
41
|
<button id="fit" type="button" title="Focus central hub">◎</button>
|
|
@@ -48,6 +49,33 @@ export const createClientHtml = () => `<!doctype html>
|
|
|
48
49
|
<div id="graphLabels" class="graph-labels" aria-hidden="true"></div>
|
|
49
50
|
<div id="graphTooltip" class="graph-tooltip" role="tooltip" hidden></div>
|
|
50
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">×</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>
|
|
51
79
|
<aside id="contentDialog" class="content-dialog" role="dialog" aria-labelledby="contentTitle" hidden>
|
|
52
80
|
<article>
|
|
53
81
|
<header>
|
|
@@ -12,6 +12,15 @@ const elements = {
|
|
|
12
12
|
fit: byId('fit'),
|
|
13
13
|
releaseNode: byId('releaseNode'),
|
|
14
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'),
|
|
15
24
|
labels: byId('graphLabels'),
|
|
16
25
|
tooltip: byId('graphTooltip'),
|
|
17
26
|
miniMap: byId('miniMap'),
|
|
@@ -1368,6 +1377,10 @@ const setupInput = () => {
|
|
|
1368
1377
|
})
|
|
1369
1378
|
|
|
1370
1379
|
window.addEventListener('keydown', (event) => {
|
|
1380
|
+
if (event.key === 'Escape' && !elements.uploadDialog.hidden) {
|
|
1381
|
+
closeUploadDialog()
|
|
1382
|
+
return
|
|
1383
|
+
}
|
|
1371
1384
|
if (event.key === 'Escape' && !elements.contentDialog.hidden) {
|
|
1372
1385
|
closeContentDialog()
|
|
1373
1386
|
return
|
|
@@ -1386,6 +1399,95 @@ const setupInput = () => {
|
|
|
1386
1399
|
})
|
|
1387
1400
|
}
|
|
1388
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
|
+
|
|
1389
1491
|
const setupControls = () => {
|
|
1390
1492
|
elements.zoomIn.addEventListener('click', () => {
|
|
1391
1493
|
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
|
|
@@ -1629,6 +1731,7 @@ const bootstrap = async () => {
|
|
|
1629
1731
|
setupRenderWorker()
|
|
1630
1732
|
setupInput()
|
|
1631
1733
|
setupControls()
|
|
1734
|
+
setupUploadDialog()
|
|
1632
1735
|
setupContextControl()
|
|
1633
1736
|
wireNodeLinkClicks()
|
|
1634
1737
|
|
|
@@ -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
|
+
};
|
package/docs/AGENT_USAGE.md
CHANGED
|
@@ -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-
|
|
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.
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
|
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