@andespindola/brainlink 1.0.2 → 1.0.4

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
@@ -534,9 +534,14 @@ Available tools:
534
534
  - `brainlink_context`: read indexed context for a task or question; pass `strategy: "rag"` for fresh retrieval assembly, `strategy: "cag"` for persisted context packs or `strategy: "auto"` for CAG hits with RAG fallback.
535
535
  - `brainlink_context_packs`: list or clear persisted CAG context packs.
536
536
  - `brainlink_search`: search indexed notes.
537
+ - `brainlink_explain`: explain why indexed notes matched a query.
537
538
  - `brainlink_dedupe`: detect duplicate candidates using exact hash + semantic similarity scores.
538
539
  - `brainlink_resolve_duplicate`: resolve duplicate pairs (`merge`, `link`, `ignore`) with connectivity-safe fallback edges.
539
540
  - `brainlink_add_note`: write durable Markdown memory and reindex.
541
+ - `brainlink_remember`: capture durable memory with inferred title, tags and Context Links; supports dry-run.
542
+ - `brainlink_inbox_add`: capture a quick untriaged memory item.
543
+ - `brainlink_inbox_list`: list untriaged inbox memory items.
544
+ - `brainlink_inbox_process`: suggest titles, tags and links for inbox items.
540
545
  - `brainlink_delete_note`: delete a durable Markdown note by title or path after explicit confirmation and reindex.
541
546
  - `brainlink_add_file`: ingest a local file as a note and reindex.
542
547
  - `brainlink_canonicalize_context_links`: ensure existing notes link to inferred context hubs.
@@ -544,14 +549,20 @@ Available tools:
544
549
  - `brainlink_volatile_clear`: clear temporary memory for the current vault/agent namespace.
545
550
  - `brainlink_index`: rebuild the vault index. Pass `full=true` for a complete source reindex.
546
551
  - `brainlink_stats`: read indexed vault statistics.
552
+ - `brainlink_doctor_actions`: return vault checks plus prioritized executable next actions.
547
553
  - `brainlink_validate`: validate broken links and orphan notes.
548
554
  - `brainlink_sync`: run index, stats, validation, broken-link and orphan checks in one call.
549
555
  - `brainlink_graph`: read indexed graph nodes and weighted links.
550
556
  - `brainlink_graph_contexts`: list the visual graph contexts used by the local server.
551
557
  - `brainlink_broken_links`: list unresolved wiki links.
558
+ - `brainlink_suggest_links`: suggest Context Links for content or fixes for unresolved wiki links.
559
+ - `brainlink_repair_links`: repair unresolved wiki links by retargeting safe matches or creating placeholder target notes.
552
560
  - `brainlink_orphans`: list disconnected notes.
561
+ - `brainlink_session_close`: write or preview a session handoff note.
562
+ - `brainlink_project_init`: seed project memory from local project docs.
553
563
 
554
564
  For the most automatic workflow, start MCP sessions with `brainlink_bootstrap` (optionally with `query`) and then continue with `brainlink_context`/`brainlink_add_note`.
565
+ MCP is kept in parity with practical Brainlink CLI workflows when a feature is safe and meaningful for tool clients.
555
566
  By default, Brainlink enforces context-first for MCP reads (`enforceContextFirst=true`): non-context read tools return preflight until `brainlink_context` is called for the vault/agent session.
556
567
  By default, MCP startup already runs bootstrap on the configured default vault/agent (`autoBootstrapOnStartup=true`), so sessions begin warm.
557
568
  By default, Brainlink enforces bootstrap and auto-runs it for read tools when session state is missing or stale (`autoBootstrapOnRead=true`).
@@ -633,7 +644,7 @@ blink server --vault ./vault --no-index
633
644
 
634
645
  ## HTTP API
635
646
 
636
- The HTTP API is read-only and exists only to power the graph UI and local inspection workflows.
647
+ The HTTP API is mostly read-oriented and exists to power the graph UI, local inspection workflows and the local document-import modal.
637
648
 
638
649
  The server always refuses non-loopback hosts. Brainlink HTTP only runs on localhost.
639
650
 
@@ -654,6 +665,7 @@ Routes:
654
665
  - `GET /api/broken-links`
655
666
  - `GET /api/orphans`
656
667
  - `GET /api/validate`
668
+ - `POST /api/import-file` with multipart field `file`
657
669
 
658
670
  Read routes accept `agent=<agent-id>`:
659
671
 
@@ -700,6 +712,27 @@ blink quickstart --vault ./team-vault --mcp-only --json
700
712
  Runs index + doctor + stats + validation, refreshes bootstrap session readiness, optionally returns context for a query, and (by default) upgrades local agent integration for plug-and-play MCP usage.
701
713
  When `--mode`, `--limit` or `--tokens` are omitted, quickstart uses agent profile defaults when available.
702
714
 
715
+ ### Practical Memory Workflows
716
+
717
+ ```bash
718
+ blink remember --content "Keep Markdown as the source of truth. #architecture"
719
+ blink remember --content-file ./handoff.md --dry-run
720
+ blink inbox add --content "Quick note to triage later"
721
+ blink inbox list
722
+ blink inbox process --json
723
+ blink session-close --content "Validated release flow"
724
+ blink daily --dry-run
725
+ blink suggest-links --content "Architecture and release flow"
726
+ blink suggest-links --broken
727
+ blink repair-links
728
+ blink search "architecture" --explain
729
+ blink explain "architecture"
730
+ blink doctor --actionable
731
+ blink project init --path .
732
+ ```
733
+
734
+ `remember` infers a title, tags and Context Links before writing a durable note. `inbox` captures quick untriaged memory and later suggests titles, tags and links. `session-close`/`daily` writes a handoff note with vault health and git status. `suggest-links` proposes Context Links or likely fixes for unresolved wiki links. `repair-links` retargets high-confidence broken links and creates `#triage` placeholder notes for unresolved targets that cannot be safely retargeted. `doctor --actionable` returns prioritized commands instead of only checks. `project init` seeds memory from project docs such as `AGENTS.md`, `README.md` and architecture docs.
735
+
703
736
  ### `config`
704
737
 
705
738
  ```bash
@@ -768,6 +801,18 @@ Imports durable memory from a legacy SQLite database into Markdown notes (`agent
768
801
  When `--db` is omitted, Brainlink auto-detects common legacy paths such as `<vault>/.brainlink/brainlink.db`.
769
802
  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
803
 
804
+ ### `import-file`
805
+
806
+ ```bash
807
+ blink import-file ./report.pdf --vault ./team-vault
808
+ blink import-file ./report.pdf --vault ./team-vault --agent coding-agent
809
+ blink import-file ./report.pdf --vault ./team-vault --title "Quarterly Report"
810
+ blink import-file ./report.pdf --vault ./team-vault --no-auto-index
811
+ ```
812
+
813
+ Converts a document with Docling and imports the converted Markdown as a durable note. The `docling` executable must be installed and available in `PATH`.
814
+ The graph UI exposes the same flow through the upload button in the toolbar.
815
+
771
816
  ### `init`
772
817
 
773
818
  ```bash
@@ -1000,7 +1045,7 @@ blink server --vault ./vault --no-open
1000
1045
  blink server --vault ./vault --no-watch
1001
1046
  ```
1002
1047
 
1003
- Starts the local read-only graph UI and HTTP API.
1048
+ Starts the local graph UI and HTTP API.
1004
1049
  Watch mode is enabled by default for Markdown changes in local filesystem vaults. Use `--no-watch` to run without the watcher.
1005
1050
  By default, it tries to open a native desktop GUI window for the graph URL.
1006
1051
  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);
@@ -501,6 +628,40 @@ li small {
501
628
  grid-column: span 2;
502
629
  }
503
630
 
631
+ .content-actions {
632
+ grid-column: span 2;
633
+ }
634
+
635
+ .node-actions {
636
+ display: flex;
637
+ flex-wrap: wrap;
638
+ gap: 8px;
639
+ }
640
+
641
+ .node-actions button {
642
+ width: auto;
643
+ min-height: 34px;
644
+ padding: 0 10px;
645
+ border: 1px solid var(--line);
646
+ border-radius: 8px;
647
+ background: var(--panel-strong);
648
+ color: var(--text);
649
+ cursor: pointer;
650
+ }
651
+
652
+ .node-actions button:hover,
653
+ .node-actions button:focus {
654
+ border-color: var(--accent);
655
+ color: var(--accent);
656
+ }
657
+
658
+ #contentActionStatus {
659
+ min-height: 16px;
660
+ margin: 0;
661
+ color: var(--muted);
662
+ font-size: 12px;
663
+ }
664
+
504
665
  .content-dialog .note-content {
505
666
  max-height: none;
506
667
  min-height: 0;
@@ -559,6 +720,13 @@ li small {
559
720
  max-height: none;
560
721
  }
561
722
 
723
+ .upload-dialog {
724
+ top: 8px;
725
+ right: 8px;
726
+ left: 8px;
727
+ width: auto;
728
+ }
729
+
562
730
  .content-dialog header {
563
731
  align-items: flex-start;
564
732
  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">&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>
51
79
  <aside id="contentDialog" class="content-dialog" role="dialog" aria-labelledby="contentTitle" hidden>
52
80
  <article>
53
81
  <header>
@@ -79,6 +107,15 @@ export const createClientHtml = () => `<!doctype html>
79
107
  <h3>Backlinks</h3>
80
108
  <ul id="contentIncoming"></ul>
81
109
  </section>
110
+ <section class="content-meta-section content-actions">
111
+ <h3>Actions</h3>
112
+ <div class="node-actions">
113
+ <button id="copyWikiLink" type="button">Copy wiki link</button>
114
+ <button id="suggestNodeLinks" type="button">Suggest links</button>
115
+ </div>
116
+ <p id="contentActionStatus"></p>
117
+ <ul id="contentLinkSuggestions"></ul>
118
+ </section>
82
119
  </div>
83
120
  <pre id="contentBody" class="note-content"></pre>
84
121
  </article>
@@ -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'),
@@ -24,7 +33,11 @@ const elements = {
24
33
  contentOutgoing: byId('contentOutgoing'),
25
34
  contentIncoming: byId('contentIncoming'),
26
35
  contentBody: byId('contentBody'),
27
- contentClose: byId('contentClose')
36
+ contentClose: byId('contentClose'),
37
+ copyWikiLink: byId('copyWikiLink'),
38
+ suggestNodeLinks: byId('suggestNodeLinks'),
39
+ contentActionStatus: byId('contentActionStatus'),
40
+ contentLinkSuggestions: byId('contentLinkSuggestions')
28
41
  }
29
42
 
30
43
  const state = {
@@ -917,6 +930,59 @@ const closeContentDialog = () => {
917
930
  elements.contentDialog.hidden = true
918
931
  }
919
932
 
933
+ const selectedNode = () => {
934
+ if (!state.selectedNodeId) {
935
+ return null
936
+ }
937
+
938
+ const packed = nodeByIdFromChunk().get(state.selectedNodeId)
939
+ if (!packed) {
940
+ return null
941
+ }
942
+
943
+ return {
944
+ id: packed[0],
945
+ title: packed[1],
946
+ path: packed[4] || ''
947
+ }
948
+ }
949
+
950
+ const copySelectedWikiLink = async () => {
951
+ const node = selectedNode()
952
+ if (!node) {
953
+ elements.contentActionStatus.textContent = 'Select a note first.'
954
+ return
955
+ }
956
+
957
+ const value = '[[' + node.title + ']]'
958
+ try {
959
+ await navigator.clipboard.writeText(value)
960
+ elements.contentActionStatus.textContent = 'Copied ' + value
961
+ } catch {
962
+ elements.contentActionStatus.textContent = value
963
+ }
964
+ }
965
+
966
+ const loadSelectedLinkSuggestions = async () => {
967
+ const content = elements.contentBody.textContent || ''
968
+ if (!content.trim()) {
969
+ elements.contentActionStatus.textContent = 'Selected note has no content.'
970
+ return
971
+ }
972
+
973
+ elements.contentActionStatus.textContent = 'Loading suggestions...'
974
+ const response = await fetch('/api/suggest-links?limit=5&content=' + encodeURIComponent(content.slice(0, 2000)) + scopeQuery('&'))
975
+ if (!response.ok) {
976
+ throw new Error('Failed to load link suggestions')
977
+ }
978
+ const payload = await response.json()
979
+ const suggestions = Array.isArray(payload.suggestions) ? payload.suggestions : []
980
+ elements.contentLinkSuggestions.innerHTML = suggestions.length > 0
981
+ ? suggestions.map((item) => '<li><button type="button" data-title="' + escapeHtml(item.title) + '">[[' + escapeHtml(item.title) + ']]</button></li>').join('')
982
+ : '<li>No strong suggestions</li>'
983
+ elements.contentActionStatus.textContent = suggestions.length > 0 ? 'Suggested Context Links' : 'No strong suggestions found.'
984
+ }
985
+
920
986
  const loadNodeDetails = async (nodeId) => {
921
987
  if (!nodeId) {
922
988
  return
@@ -956,6 +1022,8 @@ const loadNodeDetails = async (nodeId) => {
956
1022
  elements.contentOutgoing.innerHTML = list(related.outgoing)
957
1023
  elements.contentIncoming.innerHTML = list(related.incoming)
958
1024
  elements.contentBody.textContent = typeof node.content === 'string' ? node.content : ''
1025
+ elements.contentActionStatus.textContent = ''
1026
+ elements.contentLinkSuggestions.innerHTML = ''
959
1027
 
960
1028
  openContentDialog()
961
1029
  }
@@ -1368,6 +1436,10 @@ const setupInput = () => {
1368
1436
  })
1369
1437
 
1370
1438
  window.addEventListener('keydown', (event) => {
1439
+ if (event.key === 'Escape' && !elements.uploadDialog.hidden) {
1440
+ closeUploadDialog()
1441
+ return
1442
+ }
1371
1443
  if (event.key === 'Escape' && !elements.contentDialog.hidden) {
1372
1444
  closeContentDialog()
1373
1445
  return
@@ -1386,6 +1458,95 @@ const setupInput = () => {
1386
1458
  })
1387
1459
  }
1388
1460
 
1461
+ const openUploadDialog = () => {
1462
+ elements.uploadDialog.hidden = false
1463
+ elements.uploadStatus.textContent = ''
1464
+ elements.uploadTitleInput.value = ''
1465
+ elements.uploadFile.value = ''
1466
+ elements.uploadAllowSensitive.checked = false
1467
+ window.setTimeout(() => elements.uploadFile.focus(), 0)
1468
+ }
1469
+
1470
+ const closeUploadDialog = () => {
1471
+ elements.uploadDialog.hidden = true
1472
+ }
1473
+
1474
+ const setUploadBusy = (busy) => {
1475
+ elements.uploadSubmit.disabled = busy
1476
+ elements.uploadFile.disabled = busy
1477
+ elements.uploadTitleInput.disabled = busy
1478
+ elements.uploadAllowSensitive.disabled = busy
1479
+ }
1480
+
1481
+ const uploadImportUrl = () => {
1482
+ const params = new URLSearchParams()
1483
+ if (state.agentId) {
1484
+ params.set('agent', state.agentId)
1485
+ }
1486
+ const query = params.toString()
1487
+
1488
+ return '/api/import-file' + (query ? '?' + query : '')
1489
+ }
1490
+
1491
+ const submitUpload = async (event) => {
1492
+ event.preventDefault()
1493
+ const file = elements.uploadFile.files?.[0]
1494
+ if (!file) {
1495
+ elements.uploadStatus.textContent = 'Choose a file to import.'
1496
+ return
1497
+ }
1498
+
1499
+ const form = new FormData()
1500
+ form.append('file', file)
1501
+ const title = elements.uploadTitleInput.value.trim()
1502
+ if (title) {
1503
+ form.append('title', title)
1504
+ }
1505
+ if (elements.uploadAllowSensitive.checked) {
1506
+ form.append('allowSensitive', 'true')
1507
+ }
1508
+
1509
+ setUploadBusy(true)
1510
+ elements.uploadStatus.textContent = 'Importing...'
1511
+
1512
+ try {
1513
+ const response = await fetch(uploadImportUrl(), {
1514
+ method: 'POST',
1515
+ body: form
1516
+ })
1517
+ const payload = await response.json().catch(() => ({}))
1518
+ if (!response.ok) {
1519
+ throw new Error(payload?.error || 'Import failed')
1520
+ }
1521
+
1522
+ elements.uploadStatus.textContent = 'Imported "' + String(payload?.title || file.name) + '".'
1523
+ await loadAgents()
1524
+ await loadContexts()
1525
+ scheduleChunkFetch({ fit: true })
1526
+ window.setTimeout(closeUploadDialog, 700)
1527
+ } catch (error) {
1528
+ elements.uploadStatus.textContent = error instanceof Error ? error.message : String(error)
1529
+ } finally {
1530
+ setUploadBusy(false)
1531
+ }
1532
+ }
1533
+
1534
+ const setupUploadDialog = () => {
1535
+ elements.uploadOpen.addEventListener('click', openUploadDialog)
1536
+ elements.uploadClose.addEventListener('click', closeUploadDialog)
1537
+ elements.uploadForm.addEventListener('submit', (event) => {
1538
+ submitUpload(event).catch((error) => {
1539
+ elements.uploadStatus.textContent = error instanceof Error ? error.message : String(error)
1540
+ setUploadBusy(false)
1541
+ })
1542
+ })
1543
+ elements.uploadDialog.addEventListener('click', (event) => {
1544
+ if (event.target === elements.uploadDialog) {
1545
+ closeUploadDialog()
1546
+ }
1547
+ })
1548
+ }
1549
+
1389
1550
  const setupControls = () => {
1390
1551
  elements.zoomIn.addEventListener('click', () => {
1391
1552
  zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
@@ -1416,6 +1577,31 @@ const setupControls = () => {
1416
1577
  closeContentDialog()
1417
1578
  })
1418
1579
 
1580
+ elements.copyWikiLink.addEventListener('click', () => {
1581
+ copySelectedWikiLink().catch((error) => {
1582
+ elements.contentActionStatus.textContent = error instanceof Error ? error.message : String(error)
1583
+ })
1584
+ })
1585
+
1586
+ elements.suggestNodeLinks.addEventListener('click', () => {
1587
+ loadSelectedLinkSuggestions().catch((error) => {
1588
+ elements.contentActionStatus.textContent = error instanceof Error ? error.message : String(error)
1589
+ })
1590
+ })
1591
+
1592
+ elements.contentLinkSuggestions.addEventListener('click', (event) => {
1593
+ const button = event.target.closest('button[data-title]')
1594
+ if (!button) {
1595
+ return
1596
+ }
1597
+ const value = '[[' + button.dataset.title + ']]'
1598
+ navigator.clipboard.writeText(value).then(() => {
1599
+ elements.contentActionStatus.textContent = 'Copied ' + value
1600
+ }).catch(() => {
1601
+ elements.contentActionStatus.textContent = value
1602
+ })
1603
+ })
1604
+
1419
1605
  elements.contentDialog.addEventListener('click', (event) => {
1420
1606
  if (event.target === elements.contentDialog) {
1421
1607
  closeContentDialog()
@@ -1629,6 +1815,7 @@ const bootstrap = async () => {
1629
1815
  setupRenderWorker()
1630
1816
  setupInput()
1631
1817
  setupControls()
1818
+ setupUploadDialog()
1632
1819
  setupContextControl()
1633
1820
  wireNodeLinkClicks()
1634
1821
 
@@ -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,54 @@
1
+ import { relative } from 'node:path';
2
+ import { addNoteWithMetadata } from './add-note.js';
3
+ import { buildRememberSuggestion } from './memory-suggestions.js';
4
+ import { indexVault } from './index-vault.js';
5
+ import { ensureVault, readMarkdownFiles } from '../infrastructure/file-system-vault.js';
6
+ const compactPreview = (content) => content
7
+ .replace(/^---[\s\S]*?\n---\n?/, '')
8
+ .replace(/\s+/g, ' ')
9
+ .trim()
10
+ .slice(0, 180);
11
+ const extractTitle = (content, fallback) => content.match(/^#\s+(.+)$/m)?.[1]?.trim() || fallback;
12
+ export const addInboxItem = async (input) => {
13
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
14
+ const result = await addNoteWithMetadata(input.vaultPath, `Inbox ${timestamp}`, `${input.content.trim()}\n\n#inbox #triage`, input.agentId, {
15
+ autoContextLinks: false
16
+ });
17
+ const index = input.autoIndex === false ? null : await indexVault(input.vaultPath);
18
+ return {
19
+ item: result,
20
+ index
21
+ };
22
+ };
23
+ export const listInboxItems = async (vaultPath, limit = 20) => {
24
+ const absoluteVaultPath = await ensureVault(vaultPath);
25
+ const files = await readMarkdownFiles(absoluteVaultPath);
26
+ return files
27
+ .filter((file) => /(^|\s)#inbox\b/.test(file.content))
28
+ .sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime())
29
+ .slice(0, Math.max(0, limit))
30
+ .map((file) => ({
31
+ title: extractTitle(file.content, 'Inbox item'),
32
+ path: relative(absoluteVaultPath, file.absolutePath),
33
+ updatedAt: file.updatedAt.toISOString(),
34
+ preview: compactPreview(file.content)
35
+ }));
36
+ };
37
+ export const processInboxItems = async (input) => {
38
+ const items = await listInboxItems(input.vaultPath, input.limit ?? 10);
39
+ const suggestions = await Promise.all(items.map(async (item) => {
40
+ const suggestion = await buildRememberSuggestion({
41
+ vaultPath: input.vaultPath,
42
+ content: item.preview,
43
+ agentId: input.agentId,
44
+ linkLimit: 5
45
+ });
46
+ return {
47
+ ...item,
48
+ suggestedTitle: suggestion.title,
49
+ suggestedTags: suggestion.tags,
50
+ suggestedLinks: suggestion.links.map((link) => link.title)
51
+ };
52
+ }));
53
+ return suggestions;
54
+ };