@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 +47 -2
- package/dist/application/frontend/client-css.js +168 -0
- package/dist/application/frontend/client-html.js +37 -0
- package/dist/application/frontend/client-js.js +188 -1
- package/dist/application/import-file.js +45 -0
- package/dist/application/inbox.js +54 -0
- package/dist/application/memory-suggestions.js +220 -0
- package/dist/application/operational-workflows.js +153 -0
- package/dist/application/repair-broken-links.js +157 -0
- package/dist/application/server/multipart.js +80 -0
- package/dist/application/server/routes.js +62 -0
- package/dist/cli/commands/practical-commands.js +278 -0
- package/dist/cli/commands/read-commands.js +13 -0
- package/dist/cli/commands/write-commands.js +46 -0
- package/dist/cli/main.js +2 -0
- package/dist/infrastructure/docling.js +40 -0
- package/dist/mcp/server.js +51 -1
- package/dist/mcp/tools.js +264 -1
- package/docs/AGENT_USAGE.md +25 -2
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/QUICKSTART.md +13 -0
- package/package.json +1 -1
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-
|
|
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
|
|
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">×</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
|
+
};
|