@andespindola/brainlink 0.1.0-alpha.9 → 0.1.0-beta.0
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/AGENTS.md +2 -0
- package/README.md +83 -7
- package/SECURITY.md +14 -2
- package/dist/application/frontend/client-js.js +80 -18
- package/dist/application/get-graph-layout.js +26 -1
- package/dist/application/index-vault.js +11 -3
- package/dist/application/server/host-security.js +3 -3
- package/dist/application/server/routes.js +45 -1
- package/dist/application/start-server.js +2 -2
- package/dist/application/watch-vault.js +4 -1
- package/dist/cli/commands/read-commands.js +10 -10
- package/dist/cli/commands/write-commands.js +4 -6
- package/dist/cli/runtime.js +2 -1
- package/dist/domain/agents.js +2 -1
- package/dist/domain/graph-layout.js +90 -29
- package/dist/domain/markdown.js +80 -3
- package/dist/infrastructure/bucket-vault.js +171 -0
- package/dist/infrastructure/config.js +5 -0
- package/dist/infrastructure/file-system-vault.js +21 -3
- package/dist/infrastructure/sqlite/document-writer.js +4 -3
- package/dist/infrastructure/sqlite/graph-reader.js +22 -10
- package/dist/infrastructure/sqlite/schema.js +12 -1
- package/dist/infrastructure/sqlite-index.js +6 -1
- package/dist/mcp/server.js +13 -3
- package/dist/mcp/tools.js +100 -42
- package/docs/AGENT_USAGE.md +64 -1
- package/docs/ARCHITECTURE.md +22 -1
- package/docs/RELEASE.md +1 -1
- package/docs/templates/agent-namespace-bootstrap.md +27 -0
- package/docs/templates/agent-note-template.md +31 -0
- package/package.json +5 -2
package/AGENTS.md
CHANGED
|
@@ -37,6 +37,8 @@ Use this loop when using Brainlink as memory:
|
|
|
37
37
|
|
|
38
38
|
When an agent adds durable memory, it should connect the new note to at least one existing concept unless the note is intentionally a root concept. Prefer exact note titles in links, for example `[[Architecture]]`, and run `broken-links`, `orphans` or `validate` when the graph looks disconnected.
|
|
39
39
|
|
|
40
|
+
Agents can mark important relationships by placing priority hints on the same line as a wiki link, for example `[[Architecture]] priority: high`, `[[Incident Runbook]] #important` or `[[Incident Runbook]] #critical`. Indexed graph edges expose `weight` and `priority` so agents can sort related notes by importance.
|
|
41
|
+
|
|
40
42
|
## Commands
|
|
41
43
|
|
|
42
44
|
```bash
|
package/README.md
CHANGED
|
@@ -62,10 +62,12 @@ Markdown is the source of truth. `.brainlink/brainlink.db` is only a rebuildable
|
|
|
62
62
|
|
|
63
63
|
- Local-first Markdown vault.
|
|
64
64
|
- Obsidian-compatible `[[wiki links]]` and `#tags`.
|
|
65
|
+
- Weighted graph edges so agents can rank relationship importance and priority.
|
|
65
66
|
- Backlinks, broken-link reports, orphan detection and validation.
|
|
66
67
|
- Full-text, semantic and hybrid retrieval modes.
|
|
67
68
|
- SQLite-backed semantic candidate buckets for larger vaults.
|
|
68
69
|
- Agent namespaces under `agents/<agent-id>/`.
|
|
70
|
+
- S3-compatible bucket vaults through `s3://bucket/prefix` URIs.
|
|
69
71
|
- CLI with machine-readable `--json` output.
|
|
70
72
|
- Short CLI alias: `blink`.
|
|
71
73
|
- Built-in MCP stdio server for agent tool integration.
|
|
@@ -256,6 +258,36 @@ http://127.0.0.1:4321
|
|
|
256
258
|
|
|
257
259
|
When `--vault` is omitted, commands use the default vault at `$HOME/.brainlink/vault`. Pass `--vault` or configure `vault` in `brainlink.config.json` when you want a custom project-local vault.
|
|
258
260
|
|
|
261
|
+
## Bucket Vaults
|
|
262
|
+
|
|
263
|
+
Brainlink can use an S3-compatible bucket as the Markdown source of truth:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
export AWS_REGION="us-east-1"
|
|
267
|
+
export AWS_ACCESS_KEY_ID="..."
|
|
268
|
+
export AWS_SECRET_ACCESS_KEY="..."
|
|
269
|
+
|
|
270
|
+
blink add "Architecture" \
|
|
271
|
+
--vault "s3://my-memory-bucket/brainlink" \
|
|
272
|
+
--content "Bucket Markdown is the source of truth. #architecture"
|
|
273
|
+
|
|
274
|
+
blink index --vault "s3://my-memory-bucket/brainlink"
|
|
275
|
+
blink context "architecture" --vault "s3://my-memory-bucket/brainlink"
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
For Cloudflare R2, MinIO or another S3-compatible endpoint:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
export BRAINLINK_S3_ENDPOINT="https://<account-id>.r2.cloudflarestorage.com"
|
|
282
|
+
export BRAINLINK_S3_FORCE_PATH_STYLE=1
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Bucket vaults mirror Markdown into a local cache under
|
|
286
|
+
`$BRAINLINK_HOME/bucket-cache`. The bucket remains canonical; the local
|
|
287
|
+
`.brainlink/brainlink.db` stays a disposable index. Run `index` after remote
|
|
288
|
+
bucket changes before relying on `search`, `context`, graph or validation
|
|
289
|
+
commands. Watch mode is only supported for local filesystem vaults.
|
|
290
|
+
|
|
259
291
|
## Core Model
|
|
260
292
|
|
|
261
293
|
```txt
|
|
@@ -359,12 +391,21 @@ Available tools:
|
|
|
359
391
|
- `brainlink_add_note`: write durable Markdown memory and reindex.
|
|
360
392
|
- `brainlink_index`: rebuild the vault index.
|
|
361
393
|
- `brainlink_validate`: validate broken links and orphan notes.
|
|
362
|
-
- `brainlink_graph`: read indexed graph nodes and links.
|
|
394
|
+
- `brainlink_graph`: read indexed graph nodes and weighted links.
|
|
363
395
|
- `brainlink_broken_links`: list unresolved wiki links.
|
|
364
396
|
- `brainlink_orphans`: list disconnected notes.
|
|
365
397
|
|
|
366
398
|
The same linking rule applies through MCP: `brainlink_context` is read-only, and real graph links require Markdown notes with explicit `[[wiki links]]` followed by indexing.
|
|
367
399
|
|
|
400
|
+
Agents can raise the importance of a relationship by putting priority markers on the same line as a wiki link:
|
|
401
|
+
|
|
402
|
+
```md
|
|
403
|
+
- [ ] Review [[Architecture]] priority: high
|
|
404
|
+
Related: [[Incident Runbook]] #critical
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Indexed edges expose `weight` and `priority` (`low`, `normal`, `high`, `critical`) through CLI JSON, HTTP graph APIs and `brainlink_graph`.
|
|
408
|
+
|
|
368
409
|
## Graph UI
|
|
369
410
|
|
|
370
411
|
Start the local frontend:
|
|
@@ -378,7 +419,7 @@ By default, the server uses `$HOME/.brainlink/vault`. Pass `--vault ./vault` onl
|
|
|
378
419
|
The graph UI shows:
|
|
379
420
|
|
|
380
421
|
- notes as nodes
|
|
381
|
-
- `[[wiki links]]` as edges
|
|
422
|
+
- `[[wiki links]]` as weighted edges
|
|
382
423
|
- backlinks and outgoing links
|
|
383
424
|
- full Markdown content for the selected note
|
|
384
425
|
- neutral graph nodes with segment/group metadata
|
|
@@ -395,7 +436,7 @@ blink server --vault ./vault --no-index
|
|
|
395
436
|
|
|
396
437
|
The HTTP API is read-only and exists only to power the graph UI and local inspection workflows.
|
|
397
438
|
|
|
398
|
-
The server refuses non-loopback hosts
|
|
439
|
+
The server always refuses non-loopback hosts. Brainlink HTTP only runs on localhost.
|
|
399
440
|
|
|
400
441
|
Routes:
|
|
401
442
|
|
|
@@ -492,7 +533,7 @@ blink links --vault ./vault
|
|
|
492
533
|
blink links --vault ./vault --agent coding-agent
|
|
493
534
|
```
|
|
494
535
|
|
|
495
|
-
Lists indexed wiki links.
|
|
536
|
+
Lists indexed wiki links. JSON output includes `weight` and `priority` for each relationship.
|
|
496
537
|
|
|
497
538
|
### `backlinks`
|
|
498
539
|
|
|
@@ -501,7 +542,7 @@ blink backlinks "Architecture" --vault ./vault
|
|
|
501
542
|
blink backlinks "Architecture" --vault ./vault --agent coding-agent
|
|
502
543
|
```
|
|
503
544
|
|
|
504
|
-
Lists notes pointing to a target title.
|
|
545
|
+
Lists notes pointing to a target title, ordered by strongest relationship first. JSON output includes `weight` and `priority`.
|
|
505
546
|
|
|
506
547
|
### `graph`
|
|
507
548
|
|
|
@@ -510,7 +551,7 @@ blink graph --vault ./vault --json
|
|
|
510
551
|
blink graph --vault ./vault --agent coding-agent --json
|
|
511
552
|
```
|
|
512
553
|
|
|
513
|
-
Prints indexed graph data.
|
|
554
|
+
Prints indexed graph data. Edges include `weight` and `priority` so agents can categorize importance.
|
|
514
555
|
|
|
515
556
|
### `stats`
|
|
516
557
|
|
|
@@ -570,7 +611,7 @@ blink server --vault ./vault --watch
|
|
|
570
611
|
|
|
571
612
|
Starts the local read-only graph UI and HTTP API.
|
|
572
613
|
|
|
573
|
-
|
|
614
|
+
The HTTP server only binds to loopback hosts such as `127.0.0.1`, `localhost` or `::1`.
|
|
574
615
|
|
|
575
616
|
## Machine-Readable Output
|
|
576
617
|
|
|
@@ -596,22 +637,39 @@ Brainlink reads `brainlink.config.json` or `.brainlink.json` from the current wo
|
|
|
596
637
|
"host": "127.0.0.1",
|
|
597
638
|
"port": 4321,
|
|
598
639
|
"allowedVaults": [".brainlink-vault"],
|
|
640
|
+
"defaultAgent": "shared",
|
|
599
641
|
"defaultSearchLimit": 10,
|
|
600
642
|
"defaultContextTokens": 2000,
|
|
601
643
|
"embeddingProvider": "local",
|
|
602
644
|
"defaultSearchMode": "hybrid",
|
|
603
645
|
"chunkSize": 1200
|
|
604
646
|
}
|
|
647
|
+
|
|
648
|
+
`defaultAgent` is optional. When set, CLI and MCP calls that omit `--agent`/`agent` use this value automatically. If not set, behavior remains as before.
|
|
605
649
|
```
|
|
606
650
|
|
|
607
651
|
Use `"embeddingProvider": "none"` when you want FTS-only indexing.
|
|
608
652
|
|
|
653
|
+
For local security checks, set your Snyk token in the environment:
|
|
654
|
+
|
|
655
|
+
```bash
|
|
656
|
+
export SNYK_TOKEN="snyk_..."
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
For GitHub Actions, add a repository secret `SNYK_TOKEN` and the CI/publish workflows will consume it automatically during build/test.
|
|
660
|
+
|
|
609
661
|
Set `BRAINLINK_ALLOWED_VAULTS` for external wrappers, including MCP servers, so a tool cannot pass arbitrary `--vault` paths:
|
|
610
662
|
|
|
611
663
|
```bash
|
|
612
664
|
export BRAINLINK_ALLOWED_VAULTS="/absolute/path/to/project-vault,/absolute/path/to/team-vault"
|
|
613
665
|
```
|
|
614
666
|
|
|
667
|
+
Bucket vaults can be allowlisted with the same variable:
|
|
668
|
+
|
|
669
|
+
```bash
|
|
670
|
+
export BRAINLINK_ALLOWED_VAULTS="s3://my-memory-bucket/brainlink"
|
|
671
|
+
```
|
|
672
|
+
|
|
615
673
|
## Note Format
|
|
616
674
|
|
|
617
675
|
Brainlink supports Markdown with optional frontmatter:
|
|
@@ -718,6 +776,7 @@ The alpha includes local semantic retrieval. Remote embedding providers, remote
|
|
|
718
776
|
Brainlink is local-first by default.
|
|
719
777
|
|
|
720
778
|
- Do not expose the HTTP server publicly without authentication.
|
|
779
|
+
- Brainlink HTTP is localhost-only and refuses non-loopback hosts.
|
|
721
780
|
- Brainlink blocks common secret patterns by default when adding notes. Use `--allow-sensitive` only for intentional, protected vaults.
|
|
722
781
|
- Do not store secrets, credentials, API keys or regulated personal data unless the vault is protected by your own storage controls.
|
|
723
782
|
- Treat `.brainlink/brainlink.db` as disposable derived data.
|
|
@@ -731,3 +790,20 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
731
790
|
## License
|
|
732
791
|
|
|
733
792
|
MIT. See [LICENSE](LICENSE).
|
|
793
|
+
|
|
794
|
+
### Memory Optimization Loop (1-7)
|
|
795
|
+
|
|
796
|
+
Use this when your agent work needs consistent memory quality:
|
|
797
|
+
|
|
798
|
+
1. Start with `blink context "<task>" --agent "$BLINK_AGENT" --json`.
|
|
799
|
+
2. Keep notes focused with explicit `[[wiki links]]` and `#tags`.
|
|
800
|
+
3. Route agent-specific knowledge to dedicated namespaces under `agents/<agent-id>/`.
|
|
801
|
+
4. Keep `shared` as a curated global layer only.
|
|
802
|
+
5. Use targeted queries (`--limit`, explicit terms, `--mode hybrid`) before broad scans.
|
|
803
|
+
6. Run the sync command after writing notes:
|
|
804
|
+
|
|
805
|
+
```bash
|
|
806
|
+
npm run brainlink:sync -- --vault ./vault --agent "$BLINK_AGENT"
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
7. Before final response, keep the returned context sources as the grounding baseline.
|
package/SECURITY.md
CHANGED
|
@@ -5,7 +5,7 @@ Brainlink is local-first.
|
|
|
5
5
|
## Defaults
|
|
6
6
|
|
|
7
7
|
- The HTTP server binds to `127.0.0.1` by default.
|
|
8
|
-
- The HTTP server refuses non-loopback hosts
|
|
8
|
+
- The HTTP server always refuses non-loopback hosts.
|
|
9
9
|
- The HTTP server is read-only and does not expose note creation, indexing or update routes.
|
|
10
10
|
- The SQLite database is a derived local index.
|
|
11
11
|
- Markdown files are user-owned source data.
|
|
@@ -14,7 +14,7 @@ Brainlink is local-first.
|
|
|
14
14
|
|
|
15
15
|
## Remote Exposure
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Brainlink HTTP is intentionally localhost-only. It does not support binding to a public interface.
|
|
18
18
|
|
|
19
19
|
## Sensitive Memory
|
|
20
20
|
|
|
@@ -32,4 +32,16 @@ External tool wrappers, including MCP servers, should set `BRAINLINK_ALLOWED_VAU
|
|
|
32
32
|
export BRAINLINK_ALLOWED_VAULTS="/absolute/path/to/project-vault"
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
For bucket vaults, allowlist the S3 URI prefix:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
export BRAINLINK_ALLOWED_VAULTS="s3://my-memory-bucket/brainlink"
|
|
39
|
+
```
|
|
40
|
+
|
|
35
41
|
When the allowlist is set, CLI commands fail if `--vault` points outside the allowed roots.
|
|
42
|
+
|
|
43
|
+
## Bucket Credentials
|
|
44
|
+
|
|
45
|
+
Bucket vaults use the standard AWS SDK credential chain. Prefer short-lived,
|
|
46
|
+
least-privilege credentials scoped to the specific bucket prefix used by
|
|
47
|
+
Brainlink. Do not store bucket credentials in Markdown notes.
|
|
@@ -83,6 +83,8 @@ const visibleEdges = () => {
|
|
|
83
83
|
return state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
87
|
+
|
|
86
88
|
const resetView = () => {
|
|
87
89
|
const rect = canvas.getBoundingClientRect()
|
|
88
90
|
state.transform = { x: Math.max(rect.width, 320) / 2, y: Math.max(rect.height, 320) / 2, scale: 1 }
|
|
@@ -101,9 +103,20 @@ const createLayout = graph => {
|
|
|
101
103
|
return { nodes, edges }
|
|
102
104
|
}
|
|
103
105
|
|
|
106
|
+
const encodeEntityTag = (value) => {
|
|
107
|
+
const utf8 = new TextEncoder().encode(value)
|
|
108
|
+
let binary = ''
|
|
109
|
+
|
|
110
|
+
for (let index = 0; index < utf8.length; index += 1) {
|
|
111
|
+
binary += String.fromCharCode(utf8[index])
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
const graphSignature = graph => JSON.stringify({
|
|
105
118
|
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.content, node.tags]),
|
|
106
|
-
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle])
|
|
119
|
+
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
|
|
107
120
|
})
|
|
108
121
|
|
|
109
122
|
const tick = delta => {
|
|
@@ -207,7 +220,7 @@ const render = now => {
|
|
|
207
220
|
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
208
221
|
ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
|
|
209
222
|
ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
|
|
210
|
-
ctx.lineWidth = selectedEdge ? 1.8 : 1
|
|
223
|
+
ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
|
|
211
224
|
ctx.stroke()
|
|
212
225
|
})
|
|
213
226
|
|
|
@@ -241,7 +254,7 @@ const render = now => {
|
|
|
241
254
|
}
|
|
242
255
|
|
|
243
256
|
const list = items => items.length
|
|
244
|
-
? items.map(item => '<li>' + (item.id ? '<button type="button" data-node-id="' + escapeHtml(item.id) + '">' + escapeHtml(item.title) + '</button>' : escapeHtml(item.title)) + '<small>' + escapeHtml(item.path) + '</small></li>').join('')
|
|
257
|
+
? items.map(item => '<li>' + (item.id ? '<button type="button" data-node-id="' + escapeHtml(item.id) + '">' + escapeHtml(item.title) + '</button>' : escapeHtml(item.title)) + '<small>' + escapeHtml(item.path) + (item.weight ? ' · weight ' + escapeHtml(item.weight) + ' · ' + escapeHtml(item.priority || 'normal') : '') + '</small></li>').join('')
|
|
245
258
|
: '<li><small>No links found.</small></li>'
|
|
246
259
|
|
|
247
260
|
const allNotesList = () => state.nodes.length
|
|
@@ -261,13 +274,18 @@ const selectNode = node => {
|
|
|
261
274
|
return
|
|
262
275
|
}
|
|
263
276
|
const nodeById = new Map(state.nodes.map(item => [item.id, item]))
|
|
277
|
+
const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
|
|
278
|
+
...linkedNode,
|
|
279
|
+
weight: edge.weight,
|
|
280
|
+
priority: edge.priority
|
|
281
|
+
} : null
|
|
264
282
|
const outgoing = state.graph.edges
|
|
265
283
|
.filter(edge => edge.source === node.id)
|
|
266
|
-
.map(edge => edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' })
|
|
284
|
+
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
|
|
267
285
|
.filter(Boolean)
|
|
268
286
|
const incoming = state.graph.edges
|
|
269
287
|
.filter(edge => edge.target === node.id)
|
|
270
|
-
.map(edge => nodeById.get(edge.source))
|
|
288
|
+
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
271
289
|
.filter(Boolean)
|
|
272
290
|
|
|
273
291
|
elements.title.textContent = node.title
|
|
@@ -377,9 +395,21 @@ const loadAgents = async () => {
|
|
|
377
395
|
}
|
|
378
396
|
|
|
379
397
|
const loadGraph = async (options = { reset: false }) => {
|
|
380
|
-
const response = await fetch('/api/graph-layout' + agentQuery()
|
|
381
|
-
|
|
382
|
-
|
|
398
|
+
const response = await fetch('/api/graph-layout' + agentQuery(), {
|
|
399
|
+
headers: state.graphSignature
|
|
400
|
+
? {
|
|
401
|
+
'if-none-match': encodeEntityTag(state.graphSignature)
|
|
402
|
+
}
|
|
403
|
+
: undefined
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
if (response.status === 304) {
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const payload = await response.json()
|
|
411
|
+
const graph = payload?.layout ?? payload
|
|
412
|
+
const signature = payload?.signature ?? graphSignature(graph)
|
|
383
413
|
if (!options.reset && signature === state.graphSignature) return
|
|
384
414
|
const selectedId = state.selected?.id
|
|
385
415
|
const layout = createLayout(graph)
|
|
@@ -402,15 +432,47 @@ requestAnimationFrame(() => {
|
|
|
402
432
|
resize()
|
|
403
433
|
resetView()
|
|
404
434
|
})
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
435
|
+
|
|
436
|
+
const pollIntervalMs = 5000
|
|
437
|
+
let tickCounter = 0
|
|
438
|
+
|
|
439
|
+
const refreshGraphLoop = () => {
|
|
440
|
+
if (document.hidden) {
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
loadGraph().catch((error) => {
|
|
445
|
+
elements.stats.textContent = 'Failed to refresh graph'
|
|
446
|
+
console.error(error)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
tickCounter += 1
|
|
450
|
+
if (tickCounter % 3 === 0) {
|
|
451
|
+
loadAgents().catch((error) => {
|
|
410
452
|
console.error(error)
|
|
411
453
|
})
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
})
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
loadAgents()
|
|
458
|
+
.then(() => loadGraph({ reset: true }))
|
|
459
|
+
.then(() => {
|
|
460
|
+
requestAnimationFrame(render)
|
|
461
|
+
setInterval(refreshGraphLoop, pollIntervalMs)
|
|
462
|
+
})
|
|
463
|
+
.catch(error => {
|
|
464
|
+
elements.stats.textContent = 'Failed to load graph'
|
|
465
|
+
console.error(error)
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
document.addEventListener('visibilitychange', () => {
|
|
469
|
+
if (document.hidden) {
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
loadGraph({ reset: true }).catch(error => {
|
|
474
|
+
elements.stats.textContent = 'Failed to refresh graph'
|
|
475
|
+
console.error(error)
|
|
476
|
+
})
|
|
477
|
+
})
|
|
478
|
+
`;
|
|
@@ -1,3 +1,28 @@
|
|
|
1
1
|
import { createCauliflowerGraphLayout } from '../domain/graph-layout.js';
|
|
2
2
|
import { getGraph } from './get-graph.js';
|
|
3
|
-
|
|
3
|
+
const graphLayoutCache = new Map();
|
|
4
|
+
const createGraphSignature = (graph) => {
|
|
5
|
+
const nodesSignature = graph.nodes.map((node) => `${node.id}|${node.agentId}|${node.title}|${node.path}`).join('\n');
|
|
6
|
+
const edgesSignature = graph.edges
|
|
7
|
+
.map((edge) => `${edge.source}|${edge.target ?? ''}|${edge.targetTitle}|${edge.weight}|${edge.priority}`)
|
|
8
|
+
.join('\n');
|
|
9
|
+
return `${graph.nodes.length}:${nodesSignature}|${graph.edges.length}:${edgesSignature}`;
|
|
10
|
+
};
|
|
11
|
+
export const getGraphLayout = async (vaultPath, agentId) => {
|
|
12
|
+
const graph = await getGraph(vaultPath, agentId);
|
|
13
|
+
const signature = createGraphSignature(graph);
|
|
14
|
+
const cacheKey = `${vaultPath}:${agentId ?? ''}`;
|
|
15
|
+
const cached = graphLayoutCache.get(cacheKey);
|
|
16
|
+
if (cached?.signature === signature) {
|
|
17
|
+
return {
|
|
18
|
+
signature,
|
|
19
|
+
layout: cached.layout
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const layout = createCauliflowerGraphLayout(graph);
|
|
23
|
+
graphLayoutCache.set(cacheKey, { signature, layout });
|
|
24
|
+
return {
|
|
25
|
+
signature,
|
|
26
|
+
layout
|
|
27
|
+
};
|
|
28
|
+
};
|
|
@@ -6,10 +6,18 @@ import { ensureVault, readMarkdownFiles } from '../infrastructure/file-system-va
|
|
|
6
6
|
import { openSqliteIndex } from '../infrastructure/sqlite-index.js';
|
|
7
7
|
const toTitleKey = (title) => title.toLowerCase();
|
|
8
8
|
const appendTitleEntry = (map, document) => {
|
|
9
|
-
|
|
9
|
+
const key = toTitleKey(document.title);
|
|
10
|
+
if (!map.has(key)) {
|
|
11
|
+
map.set(key, {
|
|
12
|
+
id: document.id,
|
|
13
|
+
path: document.path
|
|
14
|
+
});
|
|
15
|
+
}
|
|
10
16
|
return map;
|
|
11
17
|
};
|
|
12
|
-
const createTitleMaps = (documents) => documents
|
|
18
|
+
const createTitleMaps = (documents) => [...documents]
|
|
19
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
20
|
+
.reduce((state, document) => {
|
|
13
21
|
const agentMap = state.byAgent.get(document.agentId) ?? new Map();
|
|
14
22
|
appendTitleEntry(agentMap, document);
|
|
15
23
|
state.byAgent.set(document.agentId, agentMap);
|
|
@@ -22,7 +30,7 @@ const createTitleMaps = (documents) => documents.reduce((state, document) => {
|
|
|
22
30
|
byAgent: new Map()
|
|
23
31
|
});
|
|
24
32
|
const createScopedTitleResolver = (document, titleMaps) => ({
|
|
25
|
-
get: (title) => titleMaps.byAgent.get(document.agentId)?.get(title) ?? titleMaps.shared.get(title)
|
|
33
|
+
get: (title) => titleMaps.byAgent.get(document.agentId)?.get(title)?.id ?? titleMaps.shared.get(title)?.id
|
|
26
34
|
});
|
|
27
35
|
const embedIndexedDocuments = async (documents, providerName) => {
|
|
28
36
|
const provider = createEmbeddingProvider(providerName);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const isLoopbackHost = (host) => host === 'localhost' || host === '::1' || host === '[::1]' || host.startsWith('127.');
|
|
2
|
-
export const
|
|
3
|
-
if (!
|
|
4
|
-
throw new Error(`Refusing to bind Brainlink server to non-loopback host ${host}.
|
|
2
|
+
export const assertLoopbackHost = (host) => {
|
|
3
|
+
if (!isLoopbackHost(host)) {
|
|
4
|
+
throw new Error(`Refusing to bind Brainlink server to non-loopback host ${host}. Brainlink HTTP only runs on localhost.`);
|
|
5
5
|
}
|
|
6
6
|
};
|
|
@@ -26,6 +26,28 @@ const createResponse = (body, statusCode = 200, contentType = 'text/plain; chars
|
|
|
26
26
|
'cache-control': 'no-store'
|
|
27
27
|
}
|
|
28
28
|
});
|
|
29
|
+
const normalizeHeaderToken = (value) => value?.trim().replace(/^"|"$/g, '') ?? '';
|
|
30
|
+
const decodeEntityTag = (candidate) => {
|
|
31
|
+
const token = normalizeHeaderToken(candidate);
|
|
32
|
+
if (!/^[A-Za-z0-9_-]+$/.test(token))
|
|
33
|
+
return token;
|
|
34
|
+
try {
|
|
35
|
+
return Buffer.from(token, 'base64url').toString('utf8');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return token;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const encodeEntityTag = (signature) => JSON.stringify(Buffer.from(signature, 'utf8').toString('base64url'));
|
|
42
|
+
const sameEntityTag = (candidate, signature) => {
|
|
43
|
+
if (Array.isArray(candidate)) {
|
|
44
|
+
return candidate.some((value) => sameEntityTag(value, signature));
|
|
45
|
+
}
|
|
46
|
+
if (candidate === undefined) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return decodeEntityTag(candidate) === signature;
|
|
50
|
+
};
|
|
29
51
|
const readAgentQuery = (url) => url.searchParams.get('agent') ?? undefined;
|
|
30
52
|
export const route = async (request, url, vaultPath) => {
|
|
31
53
|
if (isReadMethod(request) && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
@@ -41,7 +63,29 @@ export const route = async (request, url, vaultPath) => {
|
|
|
41
63
|
return createResponse(createJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
|
|
42
64
|
}
|
|
43
65
|
if (isReadMethod(request) && url.pathname === '/api/graph-layout') {
|
|
44
|
-
|
|
66
|
+
const { signature, layout } = await getGraphLayout(vaultPath, readAgentQuery(url));
|
|
67
|
+
const requestEtags = request.headers['if-none-match'];
|
|
68
|
+
const notModified = sameEntityTag(requestEtags, signature);
|
|
69
|
+
const etag = encodeEntityTag(signature);
|
|
70
|
+
const body = createJsonResponse({ signature, layout });
|
|
71
|
+
const jsonResponse = createResponse(body, 200, contentTypes['.json']);
|
|
72
|
+
const notModifiedResponse = createResponse('', 304, contentTypes['.json']);
|
|
73
|
+
if (notModified) {
|
|
74
|
+
return {
|
|
75
|
+
...notModifiedResponse,
|
|
76
|
+
headers: {
|
|
77
|
+
...notModifiedResponse.headers,
|
|
78
|
+
etag
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
...jsonResponse,
|
|
84
|
+
headers: {
|
|
85
|
+
...jsonResponse.headers,
|
|
86
|
+
etag
|
|
87
|
+
}
|
|
88
|
+
};
|
|
45
89
|
}
|
|
46
90
|
if (isReadMethod(request) && url.pathname === '/api/agents') {
|
|
47
91
|
return createResponse(createJsonResponse({ agents: await listAgents(vaultPath) }), 200, contentTypes['.json']);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { indexVault } from './index-vault.js';
|
|
3
3
|
import { startVaultWatcher } from './watch-vault.js';
|
|
4
|
-
import {
|
|
4
|
+
import { assertLoopbackHost } from './server/host-security.js';
|
|
5
5
|
import { contentTypes, createJsonResponse, isHttpError } from './server/http.js';
|
|
6
6
|
import { route } from './server/routes.js';
|
|
7
7
|
export const startServer = async (input) => {
|
|
8
|
-
|
|
8
|
+
assertLoopbackHost(input.host);
|
|
9
9
|
if (input.shouldIndex) {
|
|
10
10
|
await indexVault(input.vaultPath);
|
|
11
11
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { watch } from 'node:fs';
|
|
2
2
|
import { indexVault } from './index-vault.js';
|
|
3
|
-
import { resolveVaultPath } from '../infrastructure/file-system-vault.js';
|
|
3
|
+
import { isBucketVaultPath, resolveVaultPath } from '../infrastructure/file-system-vault.js';
|
|
4
4
|
const shouldIgnore = (filename) => {
|
|
5
5
|
if (!filename) {
|
|
6
6
|
return false;
|
|
@@ -8,6 +8,9 @@ const shouldIgnore = (filename) => {
|
|
|
8
8
|
return filename.includes('.brainlink') || !filename.endsWith('.md');
|
|
9
9
|
};
|
|
10
10
|
export const startVaultWatcher = (input) => {
|
|
11
|
+
if (isBucketVaultPath(input.vaultPath)) {
|
|
12
|
+
throw new Error('Watch mode is only supported for local filesystem vaults.');
|
|
13
|
+
}
|
|
11
14
|
const absoluteVaultPath = resolveVaultPath(input.vaultPath);
|
|
12
15
|
const debounceMs = input.debounceMs ?? 350;
|
|
13
16
|
let timeout = null;
|
|
@@ -20,8 +20,8 @@ export const registerReadCommands = (program) => {
|
|
|
20
20
|
const resolved = await resolveOptions(options);
|
|
21
21
|
const limit = parsePositiveInteger(options.limit ?? String(resolved.config.defaultSearchLimit), resolved.config.defaultSearchLimit);
|
|
22
22
|
const mode = sanitizeSearchMode(options.mode, resolved.config.defaultSearchMode);
|
|
23
|
-
const results = await searchKnowledge(resolved.vault, query, limit,
|
|
24
|
-
print(options.json, { query, agent:
|
|
23
|
+
const results = await searchKnowledge(resolved.vault, query, limit, resolved.agent, mode);
|
|
24
|
+
print(options.json, { query, agent: resolved.agent, limit, mode, results }, () => results
|
|
25
25
|
.map((result, index) => [`${index + 1}. ${result.title} (${result.path}) score=${result.score.toFixed(3)} mode=${result.searchMode}`, result.content].join('\n'))
|
|
26
26
|
.join('\n\n'));
|
|
27
27
|
});
|
|
@@ -33,7 +33,7 @@ export const registerReadCommands = (program) => {
|
|
|
33
33
|
.description('list indexed wiki links')
|
|
34
34
|
.action(async (options) => {
|
|
35
35
|
const resolved = await resolveOptions(options);
|
|
36
|
-
const links = await listLinks(resolved.vault,
|
|
36
|
+
const links = await listLinks(resolved.vault, resolved.agent);
|
|
37
37
|
print(options.json, { links }, () => links
|
|
38
38
|
.map((link) => {
|
|
39
39
|
const target = link.toPath ? `${link.toTitle} (${link.toPath})` : `${link.toTitle} (unresolved)`;
|
|
@@ -50,7 +50,7 @@ export const registerReadCommands = (program) => {
|
|
|
50
50
|
.description('list notes linking to a target note')
|
|
51
51
|
.action(async (title, options) => {
|
|
52
52
|
const resolved = await resolveOptions(options);
|
|
53
|
-
const backlinks = await listBacklinks(resolved.vault, title,
|
|
53
|
+
const backlinks = await listBacklinks(resolved.vault, title, resolved.agent);
|
|
54
54
|
print(options.json, { title, backlinks }, () => backlinks.map((link) => `${link.fromTitle} (${link.fromPath}) -> ${link.toTitle}`).join('\n'));
|
|
55
55
|
});
|
|
56
56
|
program
|
|
@@ -66,7 +66,7 @@ export const registerReadCommands = (program) => {
|
|
|
66
66
|
.action(async (query, options) => {
|
|
67
67
|
const resolved = await resolveOptions(options);
|
|
68
68
|
const mode = sanitizeSearchMode(options.mode, resolved.config.defaultSearchMode);
|
|
69
|
-
const contextPackage = await buildContextPackage(resolved.vault, query, parsePositiveInteger(options.limit ?? '12', 12), parsePositiveInteger(options.tokens ?? String(resolved.config.defaultContextTokens), resolved.config.defaultContextTokens),
|
|
69
|
+
const contextPackage = await buildContextPackage(resolved.vault, query, parsePositiveInteger(options.limit ?? '12', 12), parsePositiveInteger(options.tokens ?? String(resolved.config.defaultContextTokens), resolved.config.defaultContextTokens), resolved.agent, mode);
|
|
70
70
|
print(options.json, contextPackage, () => contextPackage.content);
|
|
71
71
|
});
|
|
72
72
|
program
|
|
@@ -77,7 +77,7 @@ export const registerReadCommands = (program) => {
|
|
|
77
77
|
.description('print indexed graph data')
|
|
78
78
|
.action(async (options) => {
|
|
79
79
|
const resolved = await resolveOptions(options);
|
|
80
|
-
const graph = await getGraph(resolved.vault,
|
|
80
|
+
const graph = await getGraph(resolved.vault, resolved.agent);
|
|
81
81
|
print(options.json, graph, () => JSON.stringify(graph, null, 2));
|
|
82
82
|
});
|
|
83
83
|
program
|
|
@@ -98,7 +98,7 @@ export const registerReadCommands = (program) => {
|
|
|
98
98
|
.description('print indexed vault statistics')
|
|
99
99
|
.action(async (options) => {
|
|
100
100
|
const resolved = await resolveOptions(options);
|
|
101
|
-
const stats = await getStats(resolved.vault,
|
|
101
|
+
const stats = await getStats(resolved.vault, resolved.agent);
|
|
102
102
|
print(options.json, stats, () => [
|
|
103
103
|
`Documents: ${stats.documentCount}`,
|
|
104
104
|
`Links: ${stats.linkCount}`,
|
|
@@ -116,7 +116,7 @@ export const registerReadCommands = (program) => {
|
|
|
116
116
|
.description('list unresolved wiki links')
|
|
117
117
|
.action(async (options) => {
|
|
118
118
|
const resolved = await resolveOptions(options);
|
|
119
|
-
const brokenLinks = await getBrokenLinksReport(resolved.vault,
|
|
119
|
+
const brokenLinks = await getBrokenLinksReport(resolved.vault, resolved.agent);
|
|
120
120
|
print(options.json, { brokenLinks }, () => brokenLinks.length === 0
|
|
121
121
|
? 'No broken links found'
|
|
122
122
|
: brokenLinks.map((link) => `${link.fromTitle} (${link.fromPath}) -> ${link.toTitle}`).join('\n'));
|
|
@@ -129,7 +129,7 @@ export const registerReadCommands = (program) => {
|
|
|
129
129
|
.description('list indexed notes without incoming or outgoing links')
|
|
130
130
|
.action(async (options) => {
|
|
131
131
|
const resolved = await resolveOptions(options);
|
|
132
|
-
const orphans = await getOrphansReport(resolved.vault,
|
|
132
|
+
const orphans = await getOrphansReport(resolved.vault, resolved.agent);
|
|
133
133
|
print(options.json, { orphans }, () => orphans.length === 0 ? 'No orphan notes found' : orphans.map((node) => `${node.title} (${node.path})`).join('\n'));
|
|
134
134
|
});
|
|
135
135
|
program
|
|
@@ -140,7 +140,7 @@ export const registerReadCommands = (program) => {
|
|
|
140
140
|
.description('validate indexed vault graph health')
|
|
141
141
|
.action(async (options) => {
|
|
142
142
|
const resolved = await resolveOptions(options);
|
|
143
|
-
const validation = await validateVault(resolved.vault,
|
|
143
|
+
const validation = await validateVault(resolved.vault, resolved.agent);
|
|
144
144
|
print(options.json, validation, () => validation.ok
|
|
145
145
|
? 'Vault validation passed'
|
|
146
146
|
: `Vault validation failed: ${validation.brokenLinks.length} broken links, ${validation.orphans.length} orphan notes`);
|