@andespindola/brainlink 0.1.0-alpha.9 → 0.1.0-beta.1

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 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.
@@ -189,7 +191,9 @@ blink add "Testing Policy" \
189
191
  --content "Run npm run check before final delivery. Related: [[Release Checklist]]. #testing #process"
190
192
  ```
191
193
 
192
- Brainlink does not infer durable graph relationships from generated context. A context result is only a read package for the model. To create a real link in the knowledge graph, the agent must write Markdown that contains an explicit `[[Note Title]]` wiki link and then rebuild the index.
194
+ Brainlink does not infer durable graph relationships from generated context. A context result is only a read package for the model. To create a real link in the knowledge graph, the agent must write Markdown that contains an explicit `[[Note Title]]` wiki link.
195
+
196
+ Writes with `blink add` reindex the vault automatically by default. This can be disabled with `--no-auto-index` and controlled globally with `autoIndexOnWrite` in `brainlink.config.json`.
193
197
 
194
198
  When adding memory, follow this contract:
195
199
 
@@ -198,11 +202,7 @@ When adding memory, follow this contract:
198
202
  - Add retrieval tags such as `#architecture`, `#decision`, `#runbook` or `#preference`.
199
203
  - Do not leave isolated notes unless they are intentionally root concepts.
200
204
 
201
- Rebuild the index:
202
-
203
- ```bash
204
- blink index
205
- ```
205
+ If you disable auto-index, run `blink index` after batched writes.
206
206
 
207
207
  ### 6. Validate Memory Health
208
208
 
@@ -221,7 +221,7 @@ Use this loop during real work:
221
221
  3. Use returned sources as project memory.
222
222
  4. Perform the task.
223
223
  5. Save only durable learnings with `blink add`, including `[[wiki links]]` to related notes.
224
- 6. Run `blink index`.
224
+ 6. Run `blink index` only when auto-index was disabled during a batch.
225
225
  7. Validate with `blink validate`, `blink broken-links` and `blink orphans` when graph links matter.
226
226
 
227
227
  Do not store secrets, credentials, private keys, access tokens or transient chat noise.
@@ -239,8 +239,6 @@ blink add "Auth Decision" \
239
239
  --vault ./vault \
240
240
  --content "We chose JWT for API clients. [[Architecture]] #auth #jwt"
241
241
 
242
- blink index --vault ./vault
243
-
244
242
  blink search "jwt auth" --vault ./vault
245
243
 
246
244
  blink context "how does auth work?" --vault ./vault
@@ -256,6 +254,36 @@ http://127.0.0.1:4321
256
254
 
257
255
  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
256
 
257
+ ## Bucket Vaults
258
+
259
+ Brainlink can use an S3-compatible bucket as the Markdown source of truth:
260
+
261
+ ```bash
262
+ export AWS_REGION="us-east-1"
263
+ export AWS_ACCESS_KEY_ID="..."
264
+ export AWS_SECRET_ACCESS_KEY="..."
265
+
266
+ blink add "Architecture" \
267
+ --vault "s3://my-memory-bucket/brainlink" \
268
+ --content "Bucket Markdown is the source of truth. #architecture"
269
+
270
+ blink index --vault "s3://my-memory-bucket/brainlink"
271
+ blink context "architecture" --vault "s3://my-memory-bucket/brainlink"
272
+ ```
273
+
274
+ For Cloudflare R2, MinIO or another S3-compatible endpoint:
275
+
276
+ ```bash
277
+ export BRAINLINK_S3_ENDPOINT="https://<account-id>.r2.cloudflarestorage.com"
278
+ export BRAINLINK_S3_FORCE_PATH_STYLE=1
279
+ ```
280
+
281
+ Bucket vaults mirror Markdown into a local cache under
282
+ `$BRAINLINK_HOME/bucket-cache`. The bucket remains canonical; the local
283
+ `.brainlink/brainlink.db` stays a disposable index. Run `index` after remote
284
+ bucket changes before relying on `search`, `context`, graph or validation
285
+ commands. Watch mode is only supported for local filesystem vaults.
286
+
259
287
  ## Core Model
260
288
 
261
289
  ```txt
@@ -357,13 +385,23 @@ Available tools:
357
385
  - `brainlink_context`: read indexed context for a task or question.
358
386
  - `brainlink_search`: search indexed notes.
359
387
  - `brainlink_add_note`: write durable Markdown memory and reindex.
388
+ - `brainlink_add_file`: ingest a local file as a note and reindex.
360
389
  - `brainlink_index`: rebuild the vault index.
361
390
  - `brainlink_validate`: validate broken links and orphan notes.
362
- - `brainlink_graph`: read indexed graph nodes and links.
391
+ - `brainlink_graph`: read indexed graph nodes and weighted links.
363
392
  - `brainlink_broken_links`: list unresolved wiki links.
364
393
  - `brainlink_orphans`: list disconnected notes.
365
394
 
366
- 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.
395
+ The same linking rule applies through MCP: `brainlink_context` is read-only, and real graph links require Markdown notes with explicit `[[wiki links]]`. `brainlink_add_note` and `brainlink_add_file` reindex by default and include the index result when enabled.
396
+
397
+ Agents can raise the importance of a relationship by putting priority markers on the same line as a wiki link:
398
+
399
+ ```md
400
+ - [ ] Review [[Architecture]] priority: high
401
+ Related: [[Incident Runbook]] #critical
402
+ ```
403
+
404
+ Indexed edges expose `weight` and `priority` (`low`, `normal`, `high`, `critical`) through CLI JSON, HTTP graph APIs and `brainlink_graph`.
367
405
 
368
406
  ## Graph UI
369
407
 
@@ -378,7 +416,7 @@ By default, the server uses `$HOME/.brainlink/vault`. Pass `--vault ./vault` onl
378
416
  The graph UI shows:
379
417
 
380
418
  - notes as nodes
381
- - `[[wiki links]]` as edges
419
+ - `[[wiki links]]` as weighted edges
382
420
  - backlinks and outgoing links
383
421
  - full Markdown content for the selected note
384
422
  - neutral graph nodes with segment/group metadata
@@ -395,7 +433,7 @@ blink server --vault ./vault --no-index
395
433
 
396
434
  The HTTP API is read-only and exists only to power the graph UI and local inspection workflows.
397
435
 
398
- The server refuses non-loopback hosts by default. Use `--allow-public` only behind your own authentication, authorization and TLS.
436
+ The server always refuses non-loopback hosts. Brainlink HTTP only runs on localhost.
399
437
 
400
438
  Routes:
401
439
 
@@ -437,8 +475,12 @@ Initializes vault metadata. Without an argument, Brainlink initializes the defau
437
475
  ```bash
438
476
  blink add "Note Title" --agent coding-agent --content "Markdown content"
439
477
  blink add "Note Title" --vault ./vault --agent coding-agent --content "Markdown content"
478
+ blink add "Note Title" --vault ./vault --content-file ./notes.md
479
+ blink add "Note Title" --vault ./vault --content-file ./notes.md --no-auto-index
440
480
  ```
441
481
 
482
+ `--content` and `--content-file` are mutually exclusive. Add `--no-auto-index` when you want to defer reindexing.
483
+
442
484
  Creates a Markdown note under `agents/<agent-id>/`. Common secret patterns are blocked by default; use `--allow-sensitive` only for an intentionally protected vault.
443
485
 
444
486
  ### `index`
@@ -492,7 +534,7 @@ blink links --vault ./vault
492
534
  blink links --vault ./vault --agent coding-agent
493
535
  ```
494
536
 
495
- Lists indexed wiki links.
537
+ Lists indexed wiki links. JSON output includes `weight` and `priority` for each relationship.
496
538
 
497
539
  ### `backlinks`
498
540
 
@@ -501,7 +543,7 @@ blink backlinks "Architecture" --vault ./vault
501
543
  blink backlinks "Architecture" --vault ./vault --agent coding-agent
502
544
  ```
503
545
 
504
- Lists notes pointing to a target title.
546
+ Lists notes pointing to a target title, ordered by strongest relationship first. JSON output includes `weight` and `priority`.
505
547
 
506
548
  ### `graph`
507
549
 
@@ -510,7 +552,7 @@ blink graph --vault ./vault --json
510
552
  blink graph --vault ./vault --agent coding-agent --json
511
553
  ```
512
554
 
513
- Prints indexed graph data.
555
+ Prints indexed graph data. Edges include `weight` and `priority` so agents can categorize importance.
514
556
 
515
557
  ### `stats`
516
558
 
@@ -570,7 +612,7 @@ blink server --vault ./vault --watch
570
612
 
571
613
  Starts the local read-only graph UI and HTTP API.
572
614
 
573
- To bind outside localhost, pass `--allow-public` and put the server behind your own auth and TLS.
615
+ The HTTP server only binds to loopback hosts such as `127.0.0.1`, `localhost` or `::1`.
574
616
 
575
617
  ## Machine-Readable Output
576
618
 
@@ -596,22 +638,42 @@ Brainlink reads `brainlink.config.json` or `.brainlink.json` from the current wo
596
638
  "host": "127.0.0.1",
597
639
  "port": 4321,
598
640
  "allowedVaults": [".brainlink-vault"],
641
+ "defaultAgent": "shared",
642
+ "autoIndexOnWrite": true,
599
643
  "defaultSearchLimit": 10,
600
644
  "defaultContextTokens": 2000,
601
645
  "embeddingProvider": "local",
602
646
  "defaultSearchMode": "hybrid",
603
647
  "chunkSize": 1200
604
648
  }
605
- ```
649
+
650
+ `defaultAgent` is optional. When set, CLI and MCP calls that omit `--agent`/`agent` use this value automatically. If not set, behavior remains as before.
651
+
652
+ `autoIndexOnWrite` is optional and defaults to `true`. Set it to `false` to defer indexing after writes.
653
+ ```
606
654
 
607
655
  Use `"embeddingProvider": "none"` when you want FTS-only indexing.
608
656
 
657
+ For local security checks, set your Snyk token in the environment:
658
+
659
+ ```bash
660
+ export SNYK_TOKEN="snyk_..."
661
+ ```
662
+
663
+ For GitHub Actions, add a repository secret `SNYK_TOKEN` and the CI/publish workflows will consume it automatically during build/test.
664
+
609
665
  Set `BRAINLINK_ALLOWED_VAULTS` for external wrappers, including MCP servers, so a tool cannot pass arbitrary `--vault` paths:
610
666
 
611
667
  ```bash
612
668
  export BRAINLINK_ALLOWED_VAULTS="/absolute/path/to/project-vault,/absolute/path/to/team-vault"
613
669
  ```
614
670
 
671
+ Bucket vaults can be allowlisted with the same variable:
672
+
673
+ ```bash
674
+ export BRAINLINK_ALLOWED_VAULTS="s3://my-memory-bucket/brainlink"
675
+ ```
676
+
615
677
  ## Note Format
616
678
 
617
679
  Brainlink supports Markdown with optional frontmatter:
@@ -718,6 +780,7 @@ The alpha includes local semantic retrieval. Remote embedding providers, remote
718
780
  Brainlink is local-first by default.
719
781
 
720
782
  - Do not expose the HTTP server publicly without authentication.
783
+ - Brainlink HTTP is localhost-only and refuses non-loopback hosts.
721
784
  - Brainlink blocks common secret patterns by default when adding notes. Use `--allow-sensitive` only for intentional, protected vaults.
722
785
  - Do not store secrets, credentials, API keys or regulated personal data unless the vault is protected by your own storage controls.
723
786
  - Treat `.brainlink/brainlink.db` as disposable derived data.
@@ -731,3 +794,20 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
731
794
  ## License
732
795
 
733
796
  MIT. See [LICENSE](LICENSE).
797
+
798
+ ### Memory Optimization Loop (1-7)
799
+
800
+ Use this when your agent work needs consistent memory quality:
801
+
802
+ 1. Start with `blink context "<task>" --agent "$BLINK_AGENT" --json`.
803
+ 2. Keep notes focused with explicit `[[wiki links]]` and `#tags`.
804
+ 3. Route agent-specific knowledge to dedicated namespaces under `agents/<agent-id>/`.
805
+ 4. Keep `shared` as a curated global layer only.
806
+ 5. Use targeted queries (`--limit`, explicit terms, `--mode hybrid`) before broad scans.
807
+ 6. Run the sync command after writing notes:
808
+
809
+ ```bash
810
+ npm run brainlink:sync -- --vault ./vault --agent "$BLINK_AGENT"
811
+ ```
812
+
813
+ 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 unless `--allow-public` is passed.
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
- Do not expose the HTTP server on a public interface without adding authentication, authorization and transport security.
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
- const graph = await response.json()
382
- const signature = graphSignature(graph)
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
- loadAgents().then(() => loadGraph({ reset: true })).then(() => {
406
- requestAnimationFrame(render)
407
- setInterval(() => {
408
- loadAgents().then(() => loadGraph()).catch(error => {
409
- elements.stats.textContent = 'Failed to refresh graph'
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
- }, 2000)
413
- }).catch(error => {
414
- elements.stats.textContent = 'Failed to load graph'
415
- console.error(error)
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
- export const getGraphLayout = async (vaultPath, agentId) => createCauliflowerGraphLayout(await getGraph(vaultPath, agentId));
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
- map.set(toTitleKey(document.title), document.id);
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.reduce((state, document) => {
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 assertPublicBindAllowed = (host, allowPublic = false) => {
3
- if (!allowPublic && !isLoopbackHost(host)) {
4
- throw new Error(`Refusing to bind Brainlink server to non-loopback host ${host}. Pass --allow-public only behind your own auth and TLS.`);
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
- return createResponse(createJsonResponse(await getGraphLayout(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
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 { assertPublicBindAllowed } from './server/host-security.js';
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
- assertPublicBindAllowed(input.host, input.allowPublic);
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;